@volter/editor-core 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,1989 @@
1
+ /**
2
+ * Surgical source writer (C7/C8) — the visual-edit "edit source by offset, not by
3
+ * AST rewrite" trick. Given the original source and an element's location (from the
4
+ * OID index), it edits the inline `style={{…}}` object IN PLACE, preserving all
5
+ * other properties (including dynamic ones), formatting, and comments — and refuses
6
+ * to touch expression-bound (dynamic) values (the literal-vs-dynamic guard). Plus a
7
+ * structural delete. Pure string ops (no parser, like visual-edit/writer.ts).
8
+ */
9
+
10
+ import { UTILITY_CLASSES_UNPROVEN, type UtilityClassSupport } from './utility-class-support';
11
+
12
+ export interface StyleProperty {
13
+ name: string;
14
+ /** Raw value text as it appears in source. */
15
+ raw: string;
16
+ literal: boolean;
17
+ valueStart: number;
18
+ valueEnd: number;
19
+ /**
20
+ * The full `key: value` member span — from the first char of the key through the
21
+ * end of the (trimmed) value, EXCLUDING any leading/trailing whitespace or the
22
+ * separating comma (D-A2, `removeInlineStyle`). `memberEnd` always equals
23
+ * `valueEnd` (the member ends exactly where its value ends); kept as a distinct
24
+ * field for readability at removeInlineStyle's call site.
25
+ */
26
+ memberStart: number;
27
+ memberEnd: number;
28
+ }
29
+
30
+ /** Find the `>` that closes the opening tag starting at `start` (a `<`). */
31
+ export function findTagEnd(code: string, start: number): number {
32
+ let depth = 0;
33
+ let str: string | null = null;
34
+ for (let i = start; i < code.length; i++) {
35
+ const c = code[i];
36
+ if (str) {
37
+ if (c === str && code[i - 1] !== '\\') str = null;
38
+ continue;
39
+ }
40
+ // A COMMENT. JSX allows one between attributes and inside an attribute's
41
+ // `{…}` expression alike, and an apostrophe in its prose ("the tab's drag
42
+ // handle", "the palette's active ink") otherwise opens a string that
43
+ // swallows the tag's own `>` and every element after it — measured on
44
+ // `GameHierarchy.tsx` and its neighbours. A self-closing `/` is
45
+ // followed by `>`, never by `/` or `*`, so it cannot be mistaken for one.
46
+ if (c === '/' && code[i + 1] === '/') {
47
+ const newline = code.indexOf('\n', i);
48
+ if (newline < 0) return -1;
49
+ i = newline;
50
+ continue;
51
+ }
52
+ if (c === '/' && code[i + 1] === '*') {
53
+ const close = code.indexOf('*/', i + 2);
54
+ if (close < 0) return -1;
55
+ i = close + 1;
56
+ continue;
57
+ }
58
+ if (c === '"' || c === "'" || c === '`') str = c;
59
+ else if (c === '{') depth++;
60
+ else if (c === '}') depth--;
61
+ else if (c === '>' && depth === 0) return i;
62
+ }
63
+ return -1;
64
+ }
65
+
66
+ /** Locate the inner range of `style={{ … }}` within the opening tag, or null. */
67
+ function findStyleObject(
68
+ code: string,
69
+ tagStart: number,
70
+ tagEnd: number,
71
+ ): { innerStart: number; innerEnd: number } | null {
72
+ const seg = code.slice(tagStart, tagEnd);
73
+ const m = seg.match(/style\s*=\s*\{\{/);
74
+ if (!m || m.index == null) return null;
75
+ const openBraceOuter = tagStart + m.index + m[0].length - 2; // position of first `{` of `{{`
76
+ // inner object starts after the two `{`
77
+ const innerStart = openBraceOuter + 2;
78
+ // find the matching `}}` for the object: track brace depth from innerStart
79
+ let depth = 2; // we are inside two open braces
80
+ let str: string | null = null;
81
+ for (let i = innerStart; i < tagEnd + 2 && i < code.length; i++) {
82
+ const c = code[i];
83
+ if (str) {
84
+ if (c === str && code[i - 1] !== '\\') str = null;
85
+ continue;
86
+ }
87
+ if (c === '"' || c === "'" || c === '`') str = c;
88
+ else if (c === '{') depth++;
89
+ else if (c === '}') {
90
+ depth--;
91
+ if (depth === 1) {
92
+ // this `}` closes the object (one brace — the expression brace — remains)
93
+ return { innerStart, innerEnd: i };
94
+ }
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ const LITERAL_RE = /^\s*('[^']*'|"[^"]*"|`[^`]*`|-?\d+(\.\d+)?(px|em|rem|%|vh|vw)?)\s*$/;
101
+
102
+ /** Parse the top-level properties of a style object body (between the braces). */
103
+ export function analyzeStyleProperties(objBody: string, baseOffset: number): StyleProperty[] {
104
+ const props: StyleProperty[] = [];
105
+ let i = 0;
106
+ const n = objBody.length;
107
+ let str: string | null = null;
108
+ let depth = 0;
109
+ let keyStart = -1;
110
+ let colon = -1;
111
+ const flush = (end: number): void => {
112
+ if (keyStart >= 0 && colon >= 0) {
113
+ const name = objBody.slice(keyStart, colon).replace(/['"]/g, '').trim();
114
+ const raw = objBody.slice(colon + 1, end);
115
+ if (name) {
116
+ const trimmed = raw.trim();
117
+ const lead = raw.length - raw.trimStart().length;
118
+ // A template literal with `${...}` interpolation is DYNAMIC, not literal,
119
+ // even though it is delimited by backticks — never let it be editable.
120
+ const isLiteral = LITERAL_RE.test(raw) && !trimmed.includes('${');
121
+ const valueStart = baseOffset + colon + 1 + lead;
122
+ const valueEnd = valueStart + trimmed.length;
123
+ props.push({
124
+ name,
125
+ raw: trimmed,
126
+ literal: isLiteral,
127
+ valueStart,
128
+ valueEnd,
129
+ memberStart: baseOffset + keyStart,
130
+ memberEnd: valueEnd,
131
+ });
132
+ }
133
+ }
134
+ keyStart = -1;
135
+ colon = -1;
136
+ };
137
+ for (; i < n; i++) {
138
+ const c = objBody[i] as string;
139
+ if (str) {
140
+ if (c === str && objBody[i - 1] !== '\\') str = null;
141
+ continue;
142
+ }
143
+ if (c === '"' || c === "'" || c === '`') {
144
+ str = c;
145
+ continue;
146
+ }
147
+ if (c === '{' || c === '(' || c === '[') depth++;
148
+ else if (c === '}' || c === ')' || c === ']') depth--;
149
+ else if (depth === 0) {
150
+ if (c === ':' && colon < 0) colon = i;
151
+ else if (c === ',') {
152
+ flush(i);
153
+ } else if (keyStart < 0 && /\S/.test(c)) keyStart = i;
154
+ }
155
+ }
156
+ flush(n);
157
+ return props;
158
+ }
159
+
160
+ export interface StyleEditResult {
161
+ code: string;
162
+ changed: boolean;
163
+ /** True if the edit was refused because the existing value is a JS expression. */
164
+ dynamic: boolean;
165
+ /**
166
+ * D-A1: true ONLY when `setInlineStyle` took the APPEND branch — the
167
+ * element's style object had no literal (and no dynamic expression) for this prop
168
+ * before the write, e.g. it was absent entirely or spread-derived (`style={{
169
+ * ...vars }}`). False/absent on a replace, a dynamic-value refusal, or a no-op.
170
+ * Undo must know this: replaying the write with the prior value would find the
171
+ * NOW-appended literal and REPLACE it — restoring a hardcoded value into a spot
172
+ * the source never had one — instead of removing the prop to truly restore the
173
+ * pre-edit source.
174
+ */
175
+ appended?: boolean;
176
+ /**
177
+ * Refused because the property's existing value is a CSS CUSTOM-PROPERTY
178
+ * REFERENCE — `backgroundColor: 'var(--brand-color)'`. Carries the reference
179
+ * text so the refusal can name the token the author would otherwise have lost.
180
+ *
181
+ * A `var()` is a literal string, so the dynamic-expression guard above never
182
+ * saw it: an ordinary colour-picker commit rewrote a design-token binding to
183
+ * `#ff0000`, silently, on one click. Guarding it is the same idiom for the
184
+ * same reason — the source value is a REFERENCE, and replacing it with a
185
+ * literal is a loss no widget can compose back.
186
+ */
187
+ tokenRef?: string;
188
+ }
189
+
190
+ /**
191
+ * THE ONE SENTENCE a `var()` refusal is said in — the writer's error, the
192
+ * adapter's console warning, and the inspector field's read-only reason are
193
+ * all this string, so an author reads the same thing wherever they meet it.
194
+ */
195
+ export function tokenReferenceGuardText(prop: string, tokenRef: string): string {
196
+ return (
197
+ `"${prop}" is a design-token reference (${tokenRef}) in this source, so replacing ` +
198
+ 'it with a literal is guarded — the binding would be lost and no widget can compose ' +
199
+ 'a var() back.'
200
+ );
201
+ }
202
+
203
+ /** The `var(--token, …)` reference a style value's SOURCE TEXT contains, or
204
+ * `null`. Deliberately a scan of the whole value, not an equality test: a
205
+ * composed value (`'1px solid var(--line)'`) loses the reference just as
206
+ * completely when a widget overwrites it. */
207
+ export function customPropertyReference(valueText: string): string | null {
208
+ return /var\(\s*--[A-Za-z0-9_-]+[^)]*\)/.exec(valueText)?.[0] ?? null;
209
+ }
210
+
211
+ /**
212
+ * Set an inline style property on the element at `elementStart` (a `<`). Replaces a
213
+ * literal value, refuses a dynamic one or a `var()` token reference (guards), or
214
+ * appends a new property; inserts a `style={{…}}` if none exists. Other properties
215
+ * are untouched.
216
+ */
217
+ export function setInlineStyle(
218
+ code: string,
219
+ elementStart: number,
220
+ prop: string,
221
+ value: string | number,
222
+ ): StyleEditResult {
223
+ const tagEnd = findTagEnd(code, elementStart);
224
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
225
+ // Escape the value so a quote/backslash can't break source syntax.
226
+ const valueText =
227
+ typeof value === 'number'
228
+ ? String(value)
229
+ : `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
230
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
231
+
232
+ if (styleObj) {
233
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
234
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
235
+ const existing = props.find((p) => p.name === prop);
236
+ if (existing) {
237
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard
238
+ // A literal that REFERENCES a design token is guarded too — see
239
+ // `StyleEditResult.tokenRef`. `dynamic: false`: this is not an
240
+ // expression, and calling it one would put the wrong sentence in front
241
+ // of the author. The ONE sanctioned replacement is another token
242
+ // reference: rebinding `var(--a)` → `var(--b)` is the compose gesture
243
+ // the guard exists to protect (the destruction it prevents is a token
244
+ // silently INLINED to a literal), so a new value that is itself a
245
+ // `var()` passes through.
246
+ const token = customPropertyReference(code.slice(existing.valueStart, existing.valueEnd));
247
+ const rebinding = typeof value === 'string' && customPropertyReference(value) !== null;
248
+ if (token && !rebinding) return { code, changed: false, dynamic: false, tokenRef: token };
249
+ const next = code.slice(0, existing.valueStart) + valueText + code.slice(existing.valueEnd);
250
+ return { code: next, changed: true, dynamic: false };
251
+ }
252
+ // append a property, preserving existing content
253
+ const trimmedBody = body.trimEnd();
254
+ const sep = trimmedBody.length === 0 ? '' : trimmedBody.endsWith(',') ? ' ' : ', ';
255
+ const insertAt = styleObj.innerStart + trimmedBody.length;
256
+ const next = `${code.slice(0, insertAt)}${sep}${prop}: ${valueText}${code.slice(insertAt)}`;
257
+ return { code: next, changed: true, dynamic: false, appended: true };
258
+ }
259
+
260
+ // A whole-attribute dynamic style (`style={expr}`, single brace) exists but is
261
+ // not an editable object literal — refuse rather than appending a duplicate attr.
262
+ const openSeg = code.slice(elementStart, tagEnd);
263
+ if (/style\s*=\s*\{/.test(openSeg)) {
264
+ return { code, changed: false, dynamic: true };
265
+ }
266
+
267
+ // no style attr — insert before the tag end (handle self-closing `/>`)
268
+ const selfClose = code[tagEnd - 1] === '/';
269
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
270
+ const next = `${code.slice(0, insertAt)} style={{ ${prop}: ${valueText} }}${code.slice(insertAt)}`;
271
+ return { code: next, changed: true, dynamic: false };
272
+ }
273
+
274
+ /**
275
+ * D-A2: surgically REMOVE a literal `prop: value` entry from the
276
+ * element's `style={{…}}` object at `elementStart` — the correct undo for a write
277
+ * that took `setInlineStyle`'s APPEND branch (`appended: true`). Fixes up the
278
+ * neighbouring separator/trailing comma so the remaining object stays
279
+ * syntactically valid; if `prop` was the object's ONLY content, collapses to an
280
+ * empty `style={{}}` rather than stripping the attribute entirely (keeps redo — a
281
+ * plain re-append — trivially reversible, and avoids re-deriving the
282
+ * whole-attribute insertion position `setInlineStyle`'s no-style-attr branch
283
+ * uses). `changed: false` (and no-op) if `prop` isn't present as a literal member
284
+ * at all — including a DYNAMIC member (mirrors `setInlineStyle`'s
285
+ * literal-vs-dynamic guard: never remove an expression-bound value the caller
286
+ * didn't write).
287
+ */
288
+ export function removeInlineStyle(
289
+ code: string,
290
+ elementStart: number,
291
+ prop: string,
292
+ ): StyleEditResult {
293
+ const tagEnd = findTagEnd(code, elementStart);
294
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
295
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
296
+ if (!styleObj) return { code, changed: false, dynamic: false }; // no style attr — nothing to remove
297
+
298
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
299
+ const props = analyzeStyleProperties(body, styleObj.innerStart);
300
+ const existing = props.find((p) => p.name === prop);
301
+ if (!existing) return { code, changed: false, dynamic: false }; // absent — nothing to remove
302
+ if (!existing.literal) return { code, changed: false, dynamic: true }; // guard, mirrors setInlineStyle
303
+
304
+ // Prefer consuming a comma that FOLLOWS this member (it precedes others in the
305
+ // list); else one that PRECEDES it (it is last); else this was the ONLY thing in
306
+ // the object — collapse to `{}` instead.
307
+ let after = existing.memberEnd;
308
+ while (after < styleObj.innerEnd && /\s/.test(code[after] ?? '')) after++;
309
+ if (code[after] === ',') {
310
+ let end = after + 1;
311
+ if (code[end] === ' ') end++;
312
+ return {
313
+ code: code.slice(0, existing.memberStart) + code.slice(end),
314
+ changed: true,
315
+ dynamic: false,
316
+ };
317
+ }
318
+
319
+ let before = existing.memberStart - 1;
320
+ while (before >= styleObj.innerStart && /\s/.test(code[before] ?? '')) before--;
321
+ if (code[before] === ',') {
322
+ return {
323
+ code: code.slice(0, before) + code.slice(existing.memberEnd),
324
+ changed: true,
325
+ dynamic: false,
326
+ };
327
+ }
328
+
329
+ return {
330
+ code: code.slice(0, styleObj.innerStart) + code.slice(styleObj.innerEnd),
331
+ changed: true,
332
+ dynamic: false,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Skip one `{…}` JSX expression container whole, from its opening brace to the
338
+ * index AFTER the matching `}` (or `-1` when the file ends first).
339
+ *
340
+ * This is the ONLY place between two tags where a quote opens a string and
341
+ * `//` / `/* *​/` open comments — see {@link findElementEnd}, whose walk
342
+ * this exists to keep out of them.
343
+ */
344
+ function skipJsxExpression(code: string, open: number): number {
345
+ let depth = 0;
346
+ let i = open;
347
+ while (i < code.length) {
348
+ const c = code[i];
349
+ if (c === '/' && code[i + 1] === '/') {
350
+ const newline = code.indexOf('\n', i);
351
+ i = newline < 0 ? code.length : newline + 1;
352
+ continue;
353
+ }
354
+ if (c === '/' && code[i + 1] === '*') {
355
+ const close = code.indexOf('*/', i + 2);
356
+ i = close < 0 ? code.length : close + 2;
357
+ continue;
358
+ }
359
+ if (c === '"' || c === "'" || c === '`') {
360
+ i++;
361
+ while (i < code.length && !(code[i] === c && code[i - 1] !== '\\')) i++;
362
+ i++;
363
+ continue;
364
+ }
365
+ if (c === '{') {
366
+ depth++;
367
+ i++;
368
+ continue;
369
+ }
370
+ if (c === '}') {
371
+ depth--;
372
+ i++;
373
+ if (depth === 0) return i;
374
+ continue;
375
+ }
376
+ // NESTED JSX — `{cond ? <div>the shot's takes</div> : null}`. Inside the
377
+ // element the quotes are TEXT again, so the element is handed back to
378
+ // `findElementEnd`, whose walk knows that; scanning it here would read
379
+ // that apostrophe as a string exactly like the defect this pair fixes.
380
+ // A `<` is only a tag in EXPRESSION-START position — after an identifier,
381
+ // `)` or `]` it is the less-than operator (`a < b`), never JSX.
382
+ if (c === '<' && opensNestedJsx(code, i)) {
383
+ const elementEnd = findElementEnd(code, i);
384
+ if (elementEnd > i) {
385
+ i = elementEnd;
386
+ continue;
387
+ }
388
+ }
389
+ i++;
390
+ }
391
+ return -1;
392
+ }
393
+
394
+ /** Is the `<` at `i`, inside a `{…}` expression, the start of a JSX element? */
395
+ function opensNestedJsx(code: string, i: number): boolean {
396
+ const next = code[i + 1];
397
+ if (next === undefined || !(next === '>' || /[A-Za-z]/.test(next))) return false;
398
+ let before = i - 1;
399
+ while (before >= 0 && /\s/.test(code[before] ?? '')) before--;
400
+ return !/[A-Za-z0-9_$)\]]/.test(code[before] ?? '');
401
+ }
402
+
403
+ /** `<` starts a TAG only before `/`, a name, `>` (a fragment) or `!`. Anything
404
+ * else is ordinary text this walk steps over one character at a time. */
405
+ function startsTag(code: string, i: number): boolean {
406
+ const next = code[i + 1];
407
+ return (
408
+ next !== undefined && (next === '/' || next === '>' || next === '!' || /[A-Za-z]/.test(next))
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Find the end (exclusive) of a full JSX element starting at `<Tag` — balancing
414
+ * nested same-tag elements and skipping `{…}` expressions whole (C8).
415
+ *
416
+ * **A QUOTE BETWEEN TWO TAGS IS TEXT, NOT A STRING**, and reading it as one
417
+ * destroyed source. This walk used to flip into "inside a string" on any `'`,
418
+ * `"` or backtick it met, exactly as {@link findTagEnd} does INSIDE a tag —
419
+ * but between tags those characters are JSX children, and an apostrophe is an
420
+ * ordinary English one. Measured 2026-09-19 on the game template's own
421
+ * `MainScene.tsx`: the JSX comment inside `<group name="Light Rig">` says
422
+ * *"The sun's position… that component's header… than this scene's."* — three
423
+ * apostrophes, so the walk was left mid-"string", never saw `</group>`, and
424
+ * answered with the END OF THE FILE. Every consumer of that answer takes the
425
+ * whole rest of the component: Delete on the Light Rig row would have removed
426
+ * it, copy would have copied it, and `group` wrote a structurally broken file
427
+ * (that is how this was found).
428
+ *
429
+ * So: enter a string only inside a `{…}` expression container, where
430
+ * {@link skipJsxExpression} also handles `//` and block comments — which is
431
+ * what makes a JSX comment's prose invisible to this walk — and step over any
432
+ * OTHER element's opening tag with {@link findTagEnd}, so its attributes'
433
+ * quotes and braces never reach here either. Only our own tag moves `depth`,
434
+ * so another element's children stay in this walk and cost nothing.
435
+ */
436
+ export function findElementEnd(code: string, start: number): number {
437
+ const tagEnd = findTagEnd(code, start);
438
+ if (tagEnd < 0) return -1;
439
+ if (code[tagEnd - 1] === '/') return tagEnd + 1; // self-closing
440
+ const tagNameMatch = code.slice(start + 1, tagEnd).match(/^([A-Za-z0-9_.]+)/);
441
+ const tag = tagNameMatch?.[1];
442
+ if (!tag) return -1;
443
+ let depth = 1;
444
+ let i = tagEnd + 1;
445
+ while (i < code.length && depth > 0) {
446
+ const c = code[i];
447
+ if (c === '{') {
448
+ const after = skipJsxExpression(code, i);
449
+ i = after < 0 ? code.length : after;
450
+ continue;
451
+ }
452
+ if (c !== '<' || !startsTag(code, i)) {
453
+ i++;
454
+ continue;
455
+ }
456
+ if (code.startsWith(`</${tag}`, i)) {
457
+ depth--;
458
+ const close = code.indexOf('>', i);
459
+ i = close < 0 ? code.length : close + 1;
460
+ if (depth === 0) return i;
461
+ continue;
462
+ }
463
+ if (code.startsWith(`<${tag}`, i) && /[\s/>]/.test(code[i + 1 + tag.length] ?? '')) {
464
+ // A nested SAME-TAG element: only a non-self-closing one increments depth.
465
+ // A self-closing `<tag .../>` opens+closes in one tag and must NOT bump depth
466
+ // (else the balancer runs past the real closing tag and over-deletes).
467
+ const nestedTagEnd = findTagEnd(code, i);
468
+ if (nestedTagEnd < 0) {
469
+ // An unreadable opening tag is still an OPEN one: count it and step
470
+ // past the name. Returning here would end the element at its own first
471
+ // child, which is a worse answer than the imperfect scan.
472
+ depth++;
473
+ i += 1 + tag.length;
474
+ continue;
475
+ }
476
+ if (code[nestedTagEnd - 1] !== '/') depth++;
477
+ i = nestedTagEnd + 1;
478
+ continue;
479
+ }
480
+ const otherTagEnd = findTagEnd(code, i);
481
+ i = otherTagEnd < 0 ? i + 1 : otherTagEnd + 1;
482
+ }
483
+ return i;
484
+ }
485
+
486
+ // --- F5: multi-path style routing (inline handled above; className below) ---
487
+
488
+ /**
489
+ * Encode a CSS prop:value as a Tailwind-style utility class. Numeric/color values
490
+ * use ARBITRARY values (`w-[200px]`, `bg-[#059669]`) so the round-trip is LOSSLESS
491
+ * (a named utility like `bg-emerald-600` would NOT recover the exact hex). Enums map
492
+ * to named utilities.
493
+ */
494
+ // Cap 6 (React visual-edit parity): broadened to the grouped-inspector property set. Enum
495
+ // values map to NAMED utilities; each named utility string is unique across groups so the
496
+ // ENUM_GROUP reverse map (dedupe) stays unambiguous.
497
+ const ENUM_UTILITIES: Record<string, Record<string, string>> = {
498
+ display: {
499
+ flex: 'flex',
500
+ block: 'block',
501
+ grid: 'grid',
502
+ inline: 'inline',
503
+ 'inline-block': 'inline-block',
504
+ 'inline-flex': 'inline-flex',
505
+ none: 'hidden',
506
+ },
507
+ flexDirection: {
508
+ row: 'flex-row',
509
+ column: 'flex-col',
510
+ 'row-reverse': 'flex-row-reverse',
511
+ 'column-reverse': 'flex-col-reverse',
512
+ },
513
+ flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' },
514
+ justifyContent: {
515
+ 'flex-start': 'justify-start',
516
+ 'flex-end': 'justify-end',
517
+ center: 'justify-center',
518
+ 'space-between': 'justify-between',
519
+ 'space-around': 'justify-around',
520
+ 'space-evenly': 'justify-evenly',
521
+ },
522
+ alignItems: {
523
+ 'flex-start': 'items-start',
524
+ 'flex-end': 'items-end',
525
+ center: 'items-center',
526
+ baseline: 'items-baseline',
527
+ stretch: 'items-stretch',
528
+ },
529
+ textAlign: {
530
+ left: 'text-left',
531
+ center: 'text-center',
532
+ right: 'text-right',
533
+ justify: 'text-justify',
534
+ },
535
+ position: {
536
+ relative: 'relative',
537
+ absolute: 'absolute',
538
+ fixed: 'fixed',
539
+ sticky: 'sticky',
540
+ static: 'static',
541
+ },
542
+ overflow: {
543
+ visible: 'overflow-visible',
544
+ hidden: 'overflow-hidden',
545
+ scroll: 'overflow-scroll',
546
+ auto: 'overflow-auto',
547
+ },
548
+ borderStyle: {
549
+ none: 'border-none',
550
+ solid: 'border-solid',
551
+ dashed: 'border-dashed',
552
+ dotted: 'border-dotted',
553
+ double: 'border-double',
554
+ },
555
+ textTransform: {
556
+ uppercase: 'uppercase',
557
+ lowercase: 'lowercase',
558
+ capitalize: 'capitalize',
559
+ none: 'normal-case',
560
+ },
561
+ whiteSpace: {
562
+ normal: 'whitespace-normal',
563
+ nowrap: 'whitespace-nowrap',
564
+ pre: 'whitespace-pre',
565
+ 'pre-wrap': 'whitespace-pre-wrap',
566
+ },
567
+ fontStyle: { italic: 'italic', normal: 'not-italic' },
568
+ mixBlendMode: {
569
+ normal: 'mix-blend-normal',
570
+ multiply: 'mix-blend-multiply',
571
+ screen: 'mix-blend-screen',
572
+ overlay: 'mix-blend-overlay',
573
+ },
574
+ };
575
+ // Reverse map: a named utility -> its logical group (for dedupe of enum utilities).
576
+ const ENUM_GROUP: Record<string, string> = (() => {
577
+ const out: Record<string, string> = {};
578
+ for (const [group, m] of Object.entries(ENUM_UTILITIES))
579
+ for (const u of Object.values(m)) out[u] = group;
580
+ return out;
581
+ })();
582
+ // Arbitrary-value utilities. `color`/`fontSize` both map to `text-`, so they use
583
+ // Tailwind TYPE HINTS (`text-[color:..]` vs `text-[length:..]`) to disambiguate —
584
+ // which also makes property-based dedupe unambiguous.
585
+ const ARB_MAP: Record<string, { prefix: string; hint?: 'color' | 'length' }> = {
586
+ backgroundColor: { prefix: 'bg' },
587
+ // `border` covers both border-color and border-width, so — like `text-` — they use type
588
+ // hints to stay unambiguous for property-based dedupe (Cap 6).
589
+ borderColor: { prefix: 'border', hint: 'color' },
590
+ borderWidth: { prefix: 'border', hint: 'length' },
591
+ color: { prefix: 'text', hint: 'color' },
592
+ fontSize: { prefix: 'text', hint: 'length' },
593
+ width: { prefix: 'w' },
594
+ height: { prefix: 'h' },
595
+ minWidth: { prefix: 'min-w' },
596
+ minHeight: { prefix: 'min-h' },
597
+ maxWidth: { prefix: 'max-w' },
598
+ maxHeight: { prefix: 'max-h' },
599
+ padding: { prefix: 'p' },
600
+ paddingTop: { prefix: 'pt' },
601
+ paddingRight: { prefix: 'pr' },
602
+ paddingBottom: { prefix: 'pb' },
603
+ paddingLeft: { prefix: 'pl' },
604
+ margin: { prefix: 'm' },
605
+ marginTop: { prefix: 'mt' },
606
+ marginRight: { prefix: 'mr' },
607
+ marginBottom: { prefix: 'mb' },
608
+ marginLeft: { prefix: 'ml' },
609
+ top: { prefix: 'top' },
610
+ right: { prefix: 'right' },
611
+ bottom: { prefix: 'bottom' },
612
+ left: { prefix: 'left' },
613
+ borderRadius: { prefix: 'rounded' },
614
+ borderTopLeftRadius: { prefix: 'rounded-tl' },
615
+ borderTopRightRadius: { prefix: 'rounded-tr' },
616
+ borderBottomLeftRadius: { prefix: 'rounded-bl' },
617
+ borderBottomRightRadius: { prefix: 'rounded-br' },
618
+ gap: { prefix: 'gap' },
619
+ fontWeight: { prefix: 'font' },
620
+ lineHeight: { prefix: 'leading' },
621
+ letterSpacing: { prefix: 'tracking' },
622
+ flexBasis: { prefix: 'basis' },
623
+ opacity: { prefix: 'opacity' },
624
+ zIndex: { prefix: 'z' },
625
+ boxShadow: { prefix: 'shadow' },
626
+ };
627
+
628
+ const sanitizeClass = (s: string): string => s.replace(/\s+/g, '_');
629
+
630
+ /** Encode a CSS prop:value as a lossless Tailwind-style utility class. */
631
+ export function cssToUtilityClass(prop: string, value: string | number): string {
632
+ const v = String(value);
633
+ if (ENUM_UTILITIES[prop]?.[v]) return ENUM_UTILITIES[prop][v] as string;
634
+ const arb = ARB_MAP[prop];
635
+ if (!arb) return `[${sanitizeClass(prop)}:${sanitizeClass(v)}]`; // arbitrary property — lossless
636
+ const val = typeof value === 'number' ? `${value}px` : v;
637
+ const inner = arb.hint ? `${arb.hint}:${sanitizeClass(val)}` : sanitizeClass(val);
638
+ return `${arb.prefix}-[${inner}]`;
639
+ }
640
+
641
+ /** The dedupe group of an existing utility class (so a re-set replaces same-property classes). */
642
+ function classGroup(cls: string): string {
643
+ if (ENUM_GROUP[cls]) return ENUM_GROUP[cls];
644
+ const arb =
645
+ cls.indexOf('-[') >= 0 ? cls.slice(0, cls.indexOf('-[') + 2) : cls.startsWith('[') ? '[' : '';
646
+ if (!arb) return cls;
647
+ // include a type hint (color:/length:) in the group so text-[color:..] != text-[length:..]
648
+ const hintMatch = cls.match(/\[(color|length):/);
649
+ return hintMatch ? `${arb}${hintMatch[1]}:` : arb;
650
+ }
651
+
652
+ /**
653
+ * Merge a class into the element's `className="..."` literal, or add the
654
+ * attribute.
655
+ *
656
+ * D-A1 assessment: unlike `setInlineStyle`, this function has NO
657
+ * distinguishable append-vs-replace branch to surface as `appended` — it
658
+ * always does the SAME operation (filter out any class in the target's dedupe
659
+ * GROUP, then push the new class), regardless of whether a same-group class
660
+ * existed before. Scoped OUT of this wave's fix: see the design doc's SHIPPED
661
+ * note for why the className route's undo correctness is a separate, broader
662
+ * pre-existing gap, not this bug.
663
+ */
664
+ export function setClassName(code: string, elementStart: number, cls: string): StyleEditResult {
665
+ const tagEnd = findTagEnd(code, elementStart);
666
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
667
+ const openSeg = code.slice(elementStart, tagEnd);
668
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
669
+ if (m && m.index != null) {
670
+ // dedupe by PROPERTY GROUP (not raw prefix) so re-setting any prop replaces its
671
+ // prior class — incl. color-vs-fontSize (both `text-`) and enum/arbitrary-prop.
672
+ const group = classGroup(cls);
673
+ const existing = (m[1] ?? '')
674
+ .split(/\s+/)
675
+ .filter((c) => c && c !== cls && classGroup(c) !== group);
676
+ const merged = [...existing, cls].join(' ').trim();
677
+ const valStart = elementStart + (m.index ?? 0) + m[0].indexOf('"') + 1;
678
+ const valEnd = valStart + (m[1]?.length ?? 0);
679
+ return {
680
+ code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
681
+ changed: true,
682
+ dynamic: false,
683
+ };
684
+ }
685
+ if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true }; // dynamic className
686
+ const selfClose = code[tagEnd - 1] === '/';
687
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
688
+ return {
689
+ code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
690
+ changed: true,
691
+ dynamic: false,
692
+ };
693
+ }
694
+
695
+ /**
696
+ * Route a style edit to the right target (F5). TWO conditions gate the class
697
+ * route, and the second one is the whole point:
698
+ *
699
+ * 1. the element has a literal `className` the class can be merged into, AND
700
+ * 2. the PROJECT was observed to interpret utility classes
701
+ * (`support.supported` — see `utility-class-support.ts` for the probe and
702
+ * why it is strict).
703
+ *
704
+ * Without (2) a `bg-[#059669]` written into a plain-CSS project is inert: it
705
+ * paints nothing in the editor after a remount and nothing in the game's own
706
+ * build, because no transformer exists on either side to compile it. So no
707
+ * evidence ⇒ the inline `style={{…}}` route, which paints in EVERY project,
708
+ * leaving the element's literal className untouched.
709
+ *
710
+ * When neither target is honest — the element's style attribute is a dynamic
711
+ * expression and there is no compiled class route to fall back to — this
712
+ * REFUSES with a named reason (`changed: false`, `dynamic: true`, `error`)
713
+ * rather than writing bytes that never paint.
714
+ */
715
+ export function writeStyleAuto(
716
+ code: string,
717
+ elementStart: number,
718
+ prop: string,
719
+ value: string | number,
720
+ support: UtilityClassSupport = UTILITY_CLASSES_UNPROVEN,
721
+ ): StyleEditResult & { route: 'class' | 'inline'; error?: string } {
722
+ const tagEnd = findTagEnd(code, elementStart);
723
+ const openSeg = tagEnd > 0 ? code.slice(elementStart, tagEnd) : '';
724
+ const inline = setInlineStyle(code, elementStart, prop, value);
725
+ // THE TOKEN GUARD SITS AHEAD OF THE CLASS ROUTE. An inline `var()` beats any
726
+ // utility class, so routing around it would write a class that never paints
727
+ // — the very outcome the refusal below exists to prevent — while leaving the
728
+ // author with no sign their token binding was ignored.
729
+ if (inline.tokenRef) {
730
+ return {
731
+ ...inline,
732
+ route: 'inline',
733
+ error: tokenReferenceGuardText(prop, inline.tokenRef),
734
+ };
735
+ }
736
+ if (support.supported && /className\s*=\s*"/.test(openSeg)) {
737
+ return { ...setClassName(code, elementStart, cssToUtilityClass(prop, value)), route: 'class' };
738
+ }
739
+ if (!inline.changed && inline.dynamic) {
740
+ const why = support.supported
741
+ ? 'the element has no literal className to carry a utility class'
742
+ : `this project does not compile utility classes (${support.evidence})`;
743
+ return {
744
+ ...inline,
745
+ route: 'inline',
746
+ error:
747
+ `no honest target for "${prop}": the element's style is a dynamic expression and ` +
748
+ `${why} — refusing rather than writing source that never paints.`,
749
+ };
750
+ }
751
+ return { ...inline, route: 'inline' };
752
+ }
753
+
754
+ // --- Cap 2: CSS-file editing (visual-edit writer.ts surgicalCssEdit :1109) ---
755
+
756
+ /** camelCase CSS prop -> kebab-case, for matching a declaration in a CSS rule body. */
757
+ export function cssCamelToKebab(s: string): string {
758
+ return s.replace(/([A-Z])/g, '-$1').toLowerCase();
759
+ }
760
+
761
+ /** Find a standalone occurrence of `selectorText` in CSS source (not a substring of a
762
+ * longer selector). Returns the index or -1. */
763
+ function findSelectorIndex(cssSource: string, selectorText: string): number {
764
+ let searchFrom = 0;
765
+ while (searchFrom < cssSource.length) {
766
+ const idx = cssSource.indexOf(selectorText, searchFrom);
767
+ if (idx === -1) break;
768
+ const before = idx > 0 ? cssSource[idx - 1] : '\n';
769
+ if (idx === 0 || /[\s\n{},;]/.test(before ?? '')) {
770
+ const after = cssSource[idx + selectorText.length];
771
+ if (after === '{' || after === ' ' || after === '\n' || after === '\r' || after === '\t') {
772
+ return idx;
773
+ }
774
+ }
775
+ searchFrom = idx + 1;
776
+ }
777
+ return -1;
778
+ }
779
+
780
+ /** The `{ … }` body range of the rule whose selector starts at `selectorIdx`, or null. */
781
+ function findRuleBody(
782
+ cssSource: string,
783
+ selectorIdx: number,
784
+ selectorText: string,
785
+ ): { bodyStart: number; bodyEnd: number } | null {
786
+ let i = selectorIdx + selectorText.length;
787
+ while (i < cssSource.length && cssSource[i] !== '{') i++;
788
+ if (i >= cssSource.length) return null;
789
+ const bodyStart = i + 1;
790
+ let depth = 1;
791
+ i++;
792
+ while (i < cssSource.length && depth > 0) {
793
+ if (cssSource[i] === '{') depth++;
794
+ else if (cssSource[i] === '}') depth--;
795
+ if (depth > 0) i++;
796
+ }
797
+ return { bodyStart, bodyEnd: i };
798
+ }
799
+
800
+ /**
801
+ * Surgically replace (or append) a property value inside a CSS RULE BODY, editing the
802
+ * source in place and preserving all other declarations/formatting (visual-edit
803
+ * `surgicalCssEdit` :1109). Returns the edited source, or `null` when the selector is not
804
+ * present in the file (i.e. it's GENERATED CSS — a Tailwind utility, styled-components —
805
+ * that must not be hand-edited; the caller falls back to class/inline routing).
806
+ */
807
+ export function surgicalCssEdit(
808
+ cssSource: string,
809
+ selectorText: string,
810
+ propName: string,
811
+ newValue: string,
812
+ ): string | null {
813
+ const selectorIdx = findSelectorIndex(cssSource, selectorText);
814
+ if (selectorIdx === -1) return null;
815
+ const rng = findRuleBody(cssSource, selectorIdx, selectorText);
816
+ if (!rng) return null;
817
+ const { bodyStart, bodyEnd } = rng;
818
+ const body = cssSource.slice(bodyStart, bodyEnd);
819
+
820
+ const kebabProp = cssCamelToKebab(propName);
821
+ const escaped = kebabProp.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
822
+ const propRegex = new RegExp(`(^|[;\\n\\s])\\s*(${escaped})\\s*:`, 'm');
823
+ const match = propRegex.exec(body);
824
+
825
+ if (match) {
826
+ const matchStart = bodyStart + match.index + match[0].length; // right after the ':'
827
+ let valEnd = matchStart;
828
+ while (valEnd < bodyEnd && cssSource[valEnd] !== ';' && cssSource[valEnd] !== '}') valEnd++;
829
+ return `${cssSource.slice(0, matchStart)} ${newValue}${cssSource.slice(valEnd)}`;
830
+ }
831
+
832
+ // Property absent — append it before the closing '}', matching existing indentation.
833
+ let indent = ' ';
834
+ for (const line of body.split('\n')) {
835
+ if (line.trim().includes(':')) {
836
+ indent = line.match(/^(\s*)/)?.[1] ?? indent;
837
+ break;
838
+ }
839
+ }
840
+ const trimmed = body.trimEnd();
841
+ const needsSemi = trimmed.length > 0 && !trimmed.endsWith(';');
842
+ const insertion = `${needsSemi ? ';\n' : '\n'}${indent}${kebabProp}: ${newValue};\n`;
843
+ return cssSource.slice(0, bodyEnd) + insertion + cssSource.slice(bodyEnd);
844
+ }
845
+
846
+ /**
847
+ * The breakpoint write (design ledger: breakpoints): surgically edit — or
848
+ * create — `selector`'s rule INSIDE a top-level `@media <media>` block,
849
+ * creating the block itself when the stylesheet has none. The source keeps
850
+ * real `@media` truth; the dev server's serve-time rewrite is what turns it
851
+ * into a per-frame container query for preview. Conditions compare
852
+ * whitespace-normalized, so `(max-width:767px)` finds `(max-width: 767px)`.
853
+ */
854
+ export function surgicalCssEditInMedia(
855
+ cssSource: string,
856
+ media: string,
857
+ selectorText: string,
858
+ propName: string,
859
+ newValue: string,
860
+ ): string {
861
+ const kebabProp = cssCamelToKebab(propName);
862
+ const normalize = (t: string): string => t.replace(/\s+/g, ' ').trim();
863
+ const wanted = normalize(media);
864
+ // Find a top-level @media block with the same condition.
865
+ const mediaRe = /@media([^{]+)\{/g;
866
+ let match = mediaRe.exec(cssSource);
867
+ while (match) {
868
+ if (normalize(match[1] ?? '') === wanted) {
869
+ // Block body range by brace balance.
870
+ let depth = 1;
871
+ let i = match.index + match[0].length;
872
+ while (i < cssSource.length && depth > 0) {
873
+ if (cssSource[i] === '{') depth++;
874
+ else if (cssSource[i] === '}') depth--;
875
+ if (depth > 0) i++;
876
+ }
877
+ const bodyStart = match.index + match[0].length;
878
+ const bodyEnd = i;
879
+ const body = cssSource.slice(bodyStart, bodyEnd);
880
+ const editedBody = surgicalCssEdit(body, selectorText, propName, newValue);
881
+ if (editedBody !== null) {
882
+ return cssSource.slice(0, bodyStart) + editedBody + cssSource.slice(bodyEnd);
883
+ }
884
+ // Selector absent inside the block — insert a fresh rule before its `}`.
885
+ const insertion = ` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n`;
886
+ const needsNl = bodyEnd > bodyStart && cssSource[bodyEnd - 1] !== '\n';
887
+ return (
888
+ cssSource.slice(0, bodyEnd) + (needsNl ? '\n' : '') + insertion + cssSource.slice(bodyEnd)
889
+ );
890
+ }
891
+ match = mediaRe.exec(cssSource);
892
+ }
893
+ // No block with this condition — append one.
894
+ const lead = cssSource.length === 0 || cssSource.endsWith('\n') ? '' : '\n';
895
+ return (
896
+ `${cssSource}${lead}\n@media ${media.trim()} {\n` +
897
+ ` ${selectorText} {\n ${kebabProp}: ${newValue};\n }\n}\n`
898
+ );
899
+ }
900
+
901
+ /** A matched CSS rule as produced by `inspect.ts`'s `getMatchedCssRules` — the subset the
902
+ * CSS write path needs. */
903
+ export interface CssRuleTarget {
904
+ selectorText: string;
905
+ sourceFile: string;
906
+ properties: Record<string, string>;
907
+ }
908
+
909
+ /**
910
+ * Pick the target rule for a property write: the LAST matched rule that declares the
911
+ * property (cascade — last declaration wins), mirroring visual-edit `writeCssRuleChange`
912
+ * :1198. Returns the rule + the prior value (for the undo inverse), or null when no
913
+ * matched rule declares the property (⇒ caller routes to class/inline instead).
914
+ */
915
+ export function pickCssRuleTarget(
916
+ matchedRules: readonly CssRuleTarget[],
917
+ prop: string,
918
+ ): { rule: CssRuleTarget; prevValue: string } | null {
919
+ const kebabProp = cssCamelToKebab(prop);
920
+ let target: CssRuleTarget | null = null;
921
+ for (const rule of matchedRules) {
922
+ if (kebabProp in rule.properties) target = rule;
923
+ }
924
+ if (!target) return null;
925
+ return { rule: target, prevValue: target.properties[kebabProp] ?? '' };
926
+ }
927
+
928
+ /**
929
+ * The element's NAMED STYLE, if it wears one: the LAST matched first-party rule
930
+ * whose selector is exactly one of the element's own classes (`.chip`). This is
931
+ * the Webflow-shaped routing extension to {@link pickCssRuleTarget}: once an
932
+ * element carries a named style, a property the class does not declare YET
933
+ * should land IN the class (surgicalCssEdit appends absent declarations), so
934
+ * the class stays the element's one style home instead of new edits forking
935
+ * back to inline. Only consulted when no matched rule declares the property and
936
+ * the element has no inline declaration of it (inline would win the cascade and
937
+ * the appended declaration would never paint).
938
+ */
939
+ export function namedStyleRuleFor(
940
+ matchedRules: readonly CssRuleTarget[],
941
+ classList: readonly string[],
942
+ ): CssRuleTarget | null {
943
+ let target: CssRuleTarget | null = null;
944
+ for (const rule of matchedRules) {
945
+ const selector = rule.selectorText.trim();
946
+ if (classList.some((cls) => selector === `.${cls}`)) target = rule;
947
+ }
948
+ return target;
949
+ }
950
+
951
+ /** A literal inline-style member, ready to move into a class rule. */
952
+ export interface LiteralInlineStyle {
953
+ /** The JSX member name (camelCase, e.g. `fontSize`). */
954
+ name: string;
955
+ /** The literal value text as authored (quotes included for strings). */
956
+ raw: string;
957
+ }
958
+
959
+ /**
960
+ * The element's LITERAL inline-style members — the declarations
961
+ * "create a named style from this selection" moves into the new class rule.
962
+ * Dynamic members (expressions, interpolated templates) are simply not listed:
963
+ * they are not the writer's to move, and leaving them inline is correct
964
+ * (the element keeps them, cascade-above the class).
965
+ * Empty when there is no literal `style={{…}}` at all.
966
+ */
967
+ export function collectLiteralInlineStyles(
968
+ code: string,
969
+ elementStart: number,
970
+ ): LiteralInlineStyle[] {
971
+ const tagEnd = findTagEnd(code, elementStart);
972
+ if (tagEnd < 0) return [];
973
+ const styleObj = findStyleObject(code, elementStart, tagEnd);
974
+ if (!styleObj) return [];
975
+ const body = code.slice(styleObj.innerStart, styleObj.innerEnd);
976
+ return analyzeStyleProperties(body, styleObj.innerStart)
977
+ .filter((p) => p.literal)
978
+ .map((p) => ({ name: p.name, raw: p.raw }));
979
+ }
980
+
981
+ /**
982
+ * Add ONE class token to a literal `className` (inserting the attribute when
983
+ * absent) — the named-style APPLY gesture. Unlike {@link setClassName} there is
984
+ * no utility-group dedupe: a named style is an identity, not a per-property
985
+ * utility, and applying `.chip` must never evict `.card`. No-op when the token
986
+ * is already present; `dynamic: true` refusal on a `className={expr}`.
987
+ */
988
+ export function addClassNameToken(
989
+ code: string,
990
+ elementStart: number,
991
+ cls: string,
992
+ ): StyleEditResult {
993
+ const tagEnd = findTagEnd(code, elementStart);
994
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
995
+ const openSeg = code.slice(elementStart, tagEnd);
996
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
997
+ if (m && m.index != null) {
998
+ const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
999
+ if (existing.includes(cls)) return { code, changed: false, dynamic: false };
1000
+ const merged = [...existing, cls].join(' ');
1001
+ const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
1002
+ const valEnd = valStart + (m[1]?.length ?? 0);
1003
+ return {
1004
+ code: `${code.slice(0, valStart)}${merged}${code.slice(valEnd)}`,
1005
+ changed: true,
1006
+ dynamic: false,
1007
+ };
1008
+ }
1009
+ if (/className\s*=\s*\{/.test(openSeg)) return { code, changed: false, dynamic: true };
1010
+ const selfClose = code[tagEnd - 1] === '/';
1011
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
1012
+ return {
1013
+ code: `${code.slice(0, insertAt)} className="${cls}"${code.slice(insertAt)}`,
1014
+ changed: true,
1015
+ dynamic: false,
1016
+ };
1017
+ }
1018
+
1019
+ /**
1020
+ * Remove ONE class token from a literal `className` — dropping the attribute
1021
+ * entirely when it empties (an empty `className=""` is authored noise). No-op
1022
+ * when the token is absent; `dynamic: true` refusal on a `className={expr}`.
1023
+ */
1024
+ export function removeClassNameToken(
1025
+ code: string,
1026
+ elementStart: number,
1027
+ cls: string,
1028
+ ): StyleEditResult {
1029
+ const tagEnd = findTagEnd(code, elementStart);
1030
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1031
+ const openSeg = code.slice(elementStart, tagEnd);
1032
+ const m = openSeg.match(/className\s*=\s*"([^"]*)"/);
1033
+ if (!m || m.index == null) {
1034
+ return /className\s*=\s*\{/.test(openSeg)
1035
+ ? { code, changed: false, dynamic: true }
1036
+ : { code, changed: false, dynamic: false };
1037
+ }
1038
+ const existing = (m[1] ?? '').split(/\s+/).filter(Boolean);
1039
+ if (!existing.includes(cls)) return { code, changed: false, dynamic: false };
1040
+ const remaining = existing.filter((c) => c !== cls);
1041
+ if (remaining.length > 0) {
1042
+ const valStart = elementStart + m.index + m[0].indexOf('"') + 1;
1043
+ const valEnd = valStart + (m[1]?.length ?? 0);
1044
+ return {
1045
+ code: `${code.slice(0, valStart)}${remaining.join(' ')}${code.slice(valEnd)}`,
1046
+ changed: true,
1047
+ dynamic: false,
1048
+ };
1049
+ }
1050
+ // Attribute empties — remove it, and the one space that separated it.
1051
+ let attrStart = elementStart + m.index;
1052
+ while (attrStart > elementStart && /\s/.test(code[attrStart - 1] ?? '')) attrStart--;
1053
+ const attrEnd = elementStart + m.index + m[0].length;
1054
+ return { code: code.slice(0, attrStart) + code.slice(attrEnd), changed: true, dynamic: false };
1055
+ }
1056
+
1057
+ // --- Cap 3: text-content editing (visual-edit writer.ts getEditableText :817 /
1058
+ // editTextContent :861) ---
1059
+
1060
+ /** The `<`-relative content range (between the opening tag's `>` and the closing `</`)
1061
+ * of the element at `elementStart`, plus whether it is a pure-text (editable) body. */
1062
+ function findTextContentRange(
1063
+ code: string,
1064
+ elementStart: number,
1065
+ ): { contentStart: number; closingTagStart: number; content: string } | null {
1066
+ if (code[elementStart] !== '<') return null;
1067
+ const tagEnd = findTagEnd(code, elementStart);
1068
+ if (tagEnd < 0 || code[tagEnd - 1] === '/') return null; // self-closing ⇒ no text body
1069
+ const contentStart = tagEnd + 1;
1070
+ const elementEnd = findElementEnd(code, elementStart);
1071
+ if (elementEnd < 0) return null;
1072
+ // The closing tag is the last `</` before elementEnd.
1073
+ let closingTagStart = elementEnd - 1;
1074
+ while (
1075
+ closingTagStart > contentStart &&
1076
+ !(code[closingTagStart] === '<' && code[closingTagStart + 1] === '/')
1077
+ ) {
1078
+ closingTagStart--;
1079
+ }
1080
+ return { contentStart, closingTagStart, content: code.slice(contentStart, closingTagStart) };
1081
+ }
1082
+
1083
+ /**
1084
+ * Return an element's editable text — its content trimmed — IFF the body is pure text (no
1085
+ * `{expression}` and no child `<element>`); else null (visual-edit `getEditableText` :817).
1086
+ * This is the double-click-to-edit gate: only a leaf text node is editable in place.
1087
+ */
1088
+ export function getEditableText(code: string, elementStart: number): string | null {
1089
+ const rng = findTextContentRange(code, elementStart);
1090
+ if (!rng) return null;
1091
+ if (rng.content.includes('{') || rng.content.includes('<')) return null; // dynamic/children
1092
+ const trimmed = rng.content.trim();
1093
+ return trimmed ? trimmed : null;
1094
+ }
1095
+
1096
+ /** Escape the JSX-structural characters so replacement text can't break the source tree
1097
+ * (`<`/`{`/`}` become entities; `&` first so we don't double-encode). */
1098
+ function escapeJsxText(text: string): string {
1099
+ return text
1100
+ .replace(/&/g, '&amp;')
1101
+ .replace(/</g, '&lt;')
1102
+ .replace(/\{/g, '&#123;')
1103
+ .replace(/\}/g, '&#125;');
1104
+ }
1105
+
1106
+ /**
1107
+ * Replace an element's text content in place, preserving the surrounding whitespace
1108
+ * (only the trimmed text portion is swapped — visual-edit `editTextContent` :861). Refuses
1109
+ * (guard) when the body is not pure text (`dynamic: true`), exactly like the read gate.
1110
+ */
1111
+ export function editTextContent(
1112
+ code: string,
1113
+ elementStart: number,
1114
+ newText: string,
1115
+ ): StyleEditResult {
1116
+ const rng = findTextContentRange(code, elementStart);
1117
+ if (!rng) return { code, changed: false, dynamic: false };
1118
+ const { contentStart, closingTagStart, content } = rng;
1119
+ if (content.includes('{') || content.includes('<'))
1120
+ return { code, changed: false, dynamic: true };
1121
+ const leading = content.length - content.trimStart().length;
1122
+ const trailing = content.length - content.trimEnd().length;
1123
+ const replaceStart = contentStart + leading;
1124
+ const replaceEnd = closingTagStart - trailing;
1125
+ const next = code.slice(0, replaceStart) + escapeJsxText(newText) + code.slice(replaceEnd);
1126
+ return { code: next, changed: next !== code, dynamic: false };
1127
+ }
1128
+
1129
+ // --- Cap 4: JSX prop / attribute editing (visual-edit writer.ts analyzeJsxAttributes
1130
+ // :913 / writePropChange :1018 / fetchPropAnalysis :1069) ---
1131
+
1132
+ export interface JsxAttrInfo {
1133
+ name: string;
1134
+ /** Value char range in `code` (exclusive of quotes/braces). -1 for a boolean shorthand. */
1135
+ valueStart: number;
1136
+ valueEnd: number;
1137
+ rawValue: string;
1138
+ /** True if the value is a `{…}` expression container (vs a `"…"` string). */
1139
+ isExpression: boolean;
1140
+ /** True if the value is a literal we can safely rewrite (string, number, boolean). */
1141
+ isLiteral: boolean;
1142
+ /** Byte range of the WHOLE attribute (`speed={2}`), name included — the unit
1143
+ * {@link removePropAttribute} deletes. */
1144
+ attrStart: number;
1145
+ attrEnd: number;
1146
+ }
1147
+
1148
+ /** Skip a `{…}` region (spread or expression), respecting nested braces + strings. Returns
1149
+ * the index just past the matching `}`. */
1150
+ function skipBraces(code: string, from: number): number {
1151
+ let i = from + 1;
1152
+ let depth = 1;
1153
+ let inStr: string | null = null;
1154
+ while (i < code.length && depth > 0) {
1155
+ const ch = code[i];
1156
+ if (inStr) {
1157
+ if (ch === inStr && code[i - 1] !== '\\') inStr = null;
1158
+ } else if (ch === "'" || ch === '"' || ch === '`') inStr = ch;
1159
+ else if (ch === '{') depth++;
1160
+ else if (ch === '}') depth--;
1161
+ i++;
1162
+ }
1163
+ return i;
1164
+ }
1165
+
1166
+ /** Read a quoted string value starting at the opening quote; returns [valueStart, valueEnd,
1167
+ * indexAfterClosingQuote]. */
1168
+ function readQuotedValue(code: string, openQuote: number, delim: string): [number, number, number] {
1169
+ let i = openQuote + 1;
1170
+ const valueStart = i;
1171
+ while (i < code.length && code[i] !== delim) {
1172
+ if (code[i] === '\\') i++;
1173
+ i++;
1174
+ }
1175
+ return [valueStart, i, i + 1];
1176
+ }
1177
+
1178
+ const NUMBER_LITERAL_SOURCE = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?`;
1179
+ const NUMBER_LITERAL_RE = new RegExp(`^${NUMBER_LITERAL_SOURCE}$`);
1180
+
1181
+ /**
1182
+ * A NUMBER-TUPLE literal (`[0, 1.5, 0]`, any arity incl. empty,
1183
+ * ints/floats/negatives, whitespace/newlines/trailing comma tolerated). This
1184
+ * is the 3D value shape R3F props use for
1185
+ * `position`/`rotation`/`scale`/`args`; a tuple containing anything but a
1186
+ * plain signed number literal (`[x, 1, 0]`, `[f(), 0]`) is DYNAMIC and stays
1187
+ * behind the literal-vs-dynamic guard.
1188
+ */
1189
+ export function isNumberTupleLiteral(raw: string): boolean {
1190
+ const trimmed = raw.trim();
1191
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return false;
1192
+ const inner = trimmed.slice(1, -1).trim();
1193
+ if (inner === '') return true; // empty tuple — degenerate but literal
1194
+ const parts = inner.split(',').map((p) => p.trim());
1195
+ // A trailing comma yields one empty final part — tolerate exactly that.
1196
+ if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
1197
+ return parts.every((p) => NUMBER_LITERAL_RE.test(p));
1198
+ }
1199
+
1200
+ const LITERAL_EXPR_RE = new RegExp(`^(?:${NUMBER_LITERAL_SOURCE}|true|false|'[^']*'|"[^"]*")$`);
1201
+
1202
+ /**
1203
+ * Parse the attributes of a JSX opening tag `<Tag …>` (visual-edit `analyzeJsxAttributes`
1204
+ * :913). Handles string values, `{expression}` values (literal detection), boolean
1205
+ * shorthand, and `{...spread}` (skipped). Pure string scan — no parser.
1206
+ */
1207
+ export function analyzeJsxAttributes(
1208
+ code: string,
1209
+ tagStart: number,
1210
+ tagEnd: number,
1211
+ ): JsxAttrInfo[] {
1212
+ const attrs: JsxAttrInfo[] = [];
1213
+ const skipWs = (i: number): number => {
1214
+ while (i <= tagEnd && /\s/.test(code[i] ?? '')) i++;
1215
+ return i;
1216
+ };
1217
+ let i = tagStart + 1; // skip '<'
1218
+ while (i <= tagEnd && !/[\s>/]/.test(code[i] ?? '>')) i++; // skip tag name
1219
+
1220
+ while (i <= tagEnd) {
1221
+ i = skipWs(i);
1222
+ if (i > tagEnd || code[i] === '>' || (code[i] === '/' && code[i + 1] === '>')) break;
1223
+ if (code[i] === '{') {
1224
+ i = skipBraces(code, i); // {...spread}
1225
+ continue;
1226
+ }
1227
+ const nameStart = i;
1228
+ while (i <= tagEnd && /[\w-]/.test(code[i] ?? '')) i++;
1229
+ const name = code.slice(nameStart, i);
1230
+ if (!name) {
1231
+ i++;
1232
+ continue;
1233
+ }
1234
+ const nameEnd = i;
1235
+ i = skipWs(i);
1236
+ if (code[i] !== '=') {
1237
+ attrs.push(booleanShorthand(name, nameStart, nameEnd)); // e.g. `disabled`
1238
+ continue;
1239
+ }
1240
+ i = skipWs(i + 1); // skip '=' + whitespace
1241
+ const parsed = readAttrValue(code, i);
1242
+ if (!parsed) {
1243
+ i++;
1244
+ continue;
1245
+ }
1246
+ attrs.push(makeAttr(code, name, parsed, nameStart));
1247
+ i = parsed.next;
1248
+ }
1249
+ return attrs;
1250
+ }
1251
+
1252
+ /** A boolean-shorthand attribute descriptor (`disabled` ⇒ `true`, no value range). */
1253
+ function booleanShorthand(name: string, attrStart: number, attrEnd: number): JsxAttrInfo {
1254
+ return {
1255
+ name,
1256
+ valueStart: -1,
1257
+ valueEnd: -1,
1258
+ rawValue: 'true',
1259
+ isExpression: false,
1260
+ isLiteral: true,
1261
+ attrStart,
1262
+ attrEnd,
1263
+ };
1264
+ }
1265
+
1266
+ /** Read an attribute value starting at `i` (the delimiter after `=`). Returns the value
1267
+ * range + whether it's an expression + the index past it, or null for an unrecognized
1268
+ * delimiter (the caller advances one char). */
1269
+ function readAttrValue(
1270
+ code: string,
1271
+ i: number,
1272
+ ): { valueStart: number; valueEnd: number; isExpression: boolean; next: number } | null {
1273
+ const delim = code[i];
1274
+ if (delim === '"' || delim === "'") {
1275
+ const [valueStart, valueEnd, next] = readQuotedValue(code, i, delim);
1276
+ return { valueStart, valueEnd, isExpression: false, next };
1277
+ }
1278
+ if (delim === '{') {
1279
+ const valueEnd = skipBraces(code, i) - 1;
1280
+ return { valueStart: i + 1, valueEnd, isExpression: true, next: valueEnd + 1 };
1281
+ }
1282
+ return null;
1283
+ }
1284
+
1285
+ /** Build a `JsxAttrInfo` from a parsed value range (computes rawValue + literal flag). */
1286
+ function makeAttr(
1287
+ code: string,
1288
+ name: string,
1289
+ parsed: { valueStart: number; valueEnd: number; isExpression: boolean; next: number },
1290
+ attrStart: number,
1291
+ ): JsxAttrInfo {
1292
+ const rawValue = code.slice(parsed.valueStart, parsed.valueEnd).trim();
1293
+ const isLiteral =
1294
+ !parsed.isExpression || LITERAL_EXPR_RE.test(rawValue) || isNumberTupleLiteral(rawValue);
1295
+ return {
1296
+ name,
1297
+ valueStart: parsed.valueStart,
1298
+ valueEnd: parsed.valueEnd,
1299
+ rawValue,
1300
+ isExpression: parsed.isExpression,
1301
+ isLiteral,
1302
+ attrStart,
1303
+ attrEnd: parsed.next,
1304
+ };
1305
+ }
1306
+
1307
+ /** Props never surfaced as editable component props (visual-edit `fetchPropAnalysis` skip
1308
+ * set + on* handlers — event handlers are always dynamic). */
1309
+ const PROP_SKIP = new Set(['key', 'ref', 'data-oid', 'style', 'className', 'children']);
1310
+ const isSkippedProp = (name: string): boolean => PROP_SKIP.has(name) || /^on[A-Z]/.test(name);
1311
+
1312
+ /** Split a component's JSX attributes into editable (literal) vs dynamic (expression) —
1313
+ * the Props inspector uses this to gate which props can be written (visual-edit
1314
+ * `fetchPropAnalysis` :1069). */
1315
+ export function analyzeProps(
1316
+ code: string,
1317
+ elementStart: number,
1318
+ ): { editable: string[]; dynamic: string[] } {
1319
+ const empty = { editable: [], dynamic: [] };
1320
+ if (code[elementStart] !== '<') return empty;
1321
+ const tagEnd = findTagEnd(code, elementStart);
1322
+ if (tagEnd < 0) return empty;
1323
+ const editable: string[] = [];
1324
+ const dynamic: string[] = [];
1325
+ for (const attr of analyzeJsxAttributes(code, elementStart, tagEnd)) {
1326
+ if (isSkippedProp(attr.name)) continue;
1327
+ (attr.isLiteral ? editable : dynamic).push(attr.name);
1328
+ }
1329
+ return { editable, dynamic };
1330
+ }
1331
+
1332
+ /** Format the replacement for a prop write, matching the original value's shape (number/
1333
+ * boolean/tuple/quoted expression, or a plain string attribute). Returns `null` when the
1334
+ * new value cannot take the original's shape (a non-tuple replacing a tuple) — the caller
1335
+ * refuses rather than clobbering a structured literal with a quoted string.
1336
+ *
1337
+ * `allowShapeUpgrade` (R2 — OPT-IN, default off so react-dom and every existing path are
1338
+ * byte-identical): permits exactly ONE shape change — a plain number literal replaced by
1339
+ * a number-TUPLE literal for the same prop (the R3F `scale={1.5}` scalar shorthand
1340
+ * upgraded to `scale={[x, y, z]}` by a non-uniform gizmo drag). Dynamic values stay
1341
+ * refused upstream (the `isLiteral` guard in {@link writePropChange} runs before this
1342
+ * function is ever reached), and no other shape pair is upgraded. */
1343
+ function formatPropReplacement(
1344
+ attr: JsxAttrInfo,
1345
+ newValue: string,
1346
+ allowShapeUpgrade = false,
1347
+ ): string | null {
1348
+ if (!attr.isExpression) return newValue; // string attribute — content between the quotes
1349
+ const raw = attr.rawValue;
1350
+ if (NUMBER_LITERAL_RE.test(raw)) {
1351
+ if (isNumberTupleLiteral(newValue)) {
1352
+ // R2: a tuple replacing a plain number is a SHAPE CHANGE — allowed only
1353
+ // under the explicit opt-in; otherwise refused outright (never the
1354
+ // quoted-string fallback below, which would clobber `scale={1.5}` with
1355
+ // a junk string literal).
1356
+ return allowShapeUpgrade ? newValue.trim() : null;
1357
+ }
1358
+ const num = Number(newValue);
1359
+ return Number.isNaN(num) ? `'${newValue}'` : String(num);
1360
+ }
1361
+ if (raw === 'true' || raw === 'false') {
1362
+ return newValue === 'true' || newValue === 'false' ? newValue : `'${newValue}'`;
1363
+ }
1364
+ // W2 — a number-tuple literal is replaced ONLY by another number-tuple
1365
+ // literal, verbatim (the caller controls spacing inside the brackets).
1366
+ if (isNumberTupleLiteral(raw)) {
1367
+ return isNumberTupleLiteral(newValue) ? newValue.trim() : null;
1368
+ }
1369
+ if (/^"[^"]*"$/.test(raw)) return `"${newValue.replace(/"/g, '\\"')}"`;
1370
+ return `'${newValue.replace(/'/g, "\\'")}'`;
1371
+ }
1372
+
1373
+ /**
1374
+ * Format a BRAND-NEW attribute's source text for {@link writePropChange}'s
1375
+ * opt-in `addIfMissing` branch: tuples/numbers/booleans get an expression
1376
+ * container, everything else a double-quoted string attribute.
1377
+ */
1378
+ function formatNewAttr(propName: string, newValue: string): string {
1379
+ if (
1380
+ isNumberTupleLiteral(newValue) ||
1381
+ NUMBER_LITERAL_RE.test(newValue) ||
1382
+ newValue === 'true' ||
1383
+ newValue === 'false'
1384
+ ) {
1385
+ return `${propName}={${newValue.trim()}}`;
1386
+ }
1387
+ return `${propName}="${newValue.replace(/"/g, '&quot;')}"`;
1388
+ }
1389
+
1390
+ /**
1391
+ * Write a component prop change back to source at the call site (visual-edit
1392
+ * `writePropChange` :1018). Refuses (guards) a dynamic (non-literal) prop and skips the
1393
+ * event-handler / structural props. `elementStart` is the call-site element's `<`.
1394
+ *
1395
+ * `opts.addIfMissing` (W2 — OPT-IN, default off so react-dom prop-write behavior is
1396
+ * unchanged): when the prop is absent from the opening tag, APPEND it (` prop={value}`
1397
+ * / ` prop="value"`) before the tag end instead of no-op'ing — the R3F gizmo path needs
1398
+ * this for a mesh whose source never authored a `position`/`rotation`/`scale` tuple
1399
+ * yet.
1400
+ *
1401
+ * `opts.allowShapeUpgrade` (R2 — OPT-IN, default off): permit a number-tuple literal to
1402
+ * replace a plain NUMBER literal for the same prop (scalar→tuple upgrade; see {@link
1403
+ * formatPropReplacement}). The R3F adapter passes it only for `scale` writes coming
1404
+ * from a non-uniform gizmo drag.
1405
+ */
1406
+ /**
1407
+ * Where to put a new attribute on a tag whose attributes each occupy their own
1408
+ * line, and with what indentation — or null when the tag is single-line and the
1409
+ * inline insert is right. Keyed off the LAST attribute actually starting a
1410
+ * line, so a tag mixing both styles follows its own majority tail.
1411
+ */
1412
+ function ownLineAttributeInsert(
1413
+ code: string,
1414
+ elementStart: number,
1415
+ attrs: readonly JsxAttrInfo[],
1416
+ ): { at: number; indent: string } | null {
1417
+ const last = attrs.at(-1);
1418
+ if (!last) return null;
1419
+ // Does this attribute begin its own line?
1420
+ let lineStart = last.attrStart;
1421
+ while (lineStart > elementStart && code[lineStart - 1] !== '\n') lineStart--;
1422
+ if (lineStart <= elementStart) return null; // same line as the tag name
1423
+ const indent = code.slice(lineStart, last.attrStart);
1424
+ if (/\S/.test(indent)) return null; // something else precedes it on the line
1425
+ return { at: last.attrEnd, indent };
1426
+ }
1427
+
1428
+ export function writePropChange(
1429
+ code: string,
1430
+ elementStart: number,
1431
+ propName: string,
1432
+ newValue: string,
1433
+ opts?: { addIfMissing?: boolean; allowShapeUpgrade?: boolean },
1434
+ ): StyleEditResult {
1435
+ if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
1436
+ const tagEnd = findTagEnd(code, elementStart);
1437
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1438
+ if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
1439
+ const attrs = analyzeJsxAttributes(code, elementStart, tagEnd);
1440
+ const attr = attrs.find((a) => a.name === propName);
1441
+ if (!attr) {
1442
+ if (!opts?.addIfMissing) return { code, changed: false, dynamic: false };
1443
+ // A tag that puts its attributes on their own lines gets the new one on its
1444
+ // own line too, indented to match. Appending inline before the `>` is
1445
+ // syntactically fine but reflows the author's formatting — which matters
1446
+ // now that removing and re-adding a prop is an ordinary inspector round
1447
+ // trip (revert to default, then override again).
1448
+ const ownLine = ownLineAttributeInsert(code, elementStart, attrs);
1449
+ if (ownLine) {
1450
+ const next =
1451
+ code.slice(0, ownLine.at) +
1452
+ `\n${ownLine.indent}${formatNewAttr(propName, newValue)}` +
1453
+ code.slice(ownLine.at);
1454
+ return { code: next, changed: true, dynamic: false };
1455
+ }
1456
+ const selfClose = code[tagEnd - 1] === '/';
1457
+ const insertAt = selfClose ? tagEnd - 1 : tagEnd;
1458
+ // Byte-minimal insert: a single leading space only when the preceding
1459
+ // char isn't already whitespace; a trailing space only before a `/` that
1460
+ // isn't already preceded by one. Everything around the insert survives
1461
+ // byte-for-byte.
1462
+ const lead = /\s/.test(code[insertAt - 1] ?? '') ? '' : ' ';
1463
+ const trail = selfClose ? ' ' : '';
1464
+ const next =
1465
+ code.slice(0, insertAt) +
1466
+ `${lead}${formatNewAttr(propName, newValue)}${trail}` +
1467
+ code.slice(insertAt);
1468
+ return { code: next, changed: true, dynamic: false };
1469
+ }
1470
+ if (!attr.isLiteral) return { code, changed: false, dynamic: true };
1471
+ if (attr.valueStart < 0) {
1472
+ // Native JSX boolean shorthand is still a literal. Preserve it for `true`;
1473
+ // expand it surgically for `false` so `<mesh visible />` is as editable as
1474
+ // `<mesh visible={true} />` without teaching the adapter a second writer.
1475
+ if (newValue === 'true') return { code, changed: false, dynamic: false };
1476
+ if (newValue !== 'false') return { code, changed: false, dynamic: false };
1477
+ const next = `${code.slice(0, attr.attrStart)}${propName}={false}${code.slice(attr.attrEnd)}`;
1478
+ return { code: next, changed: true, dynamic: false };
1479
+ }
1480
+ const replacement = formatPropReplacement(attr, newValue, opts?.allowShapeUpgrade === true);
1481
+ if (replacement === null) return { code, changed: false, dynamic: false }; // shape mismatch — refuse
1482
+ const next = code.slice(0, attr.valueStart) + replacement + code.slice(attr.valueEnd);
1483
+ return { code: next, changed: next !== code, dynamic: false };
1484
+ }
1485
+
1486
+ /**
1487
+ * REMOVE a whole attribute from the element at `elementStart` — the inverse of
1488
+ * {@link writePropChange}'s `addIfMissing` append, and what "revert to default"
1489
+ * means in a JSX world: with no attribute, the value in force is the one the
1490
+ * component's own signature declares. (Contrast `removeInlineStyle`, which
1491
+ * removes one member from inside a `style={{…}}` object.)
1492
+ *
1493
+ * Refuses a DYNAMIC value, mirroring every other writer here: never delete an
1494
+ * expression the caller did not write. Deleting the attribute takes its leading
1495
+ * whitespace with it, so `<A a b />` → `<A a />` and a prop on its own line
1496
+ * takes the line, leaving the rest of the tag byte-identical.
1497
+ */
1498
+ export function removePropAttribute(
1499
+ code: string,
1500
+ elementStart: number,
1501
+ propName: string,
1502
+ ): StyleEditResult {
1503
+ if (code[elementStart] !== '<') return { code, changed: false, dynamic: false };
1504
+ const tagEnd = findTagEnd(code, elementStart);
1505
+ if (tagEnd < 0) return { code, changed: false, dynamic: false };
1506
+ if (isSkippedProp(propName)) return { code, changed: false, dynamic: false };
1507
+ const attr = analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === propName);
1508
+ if (!attr) return { code, changed: false, dynamic: false }; // absent — nothing to revert
1509
+ if (!attr.isLiteral) return { code, changed: false, dynamic: true }; // guard
1510
+
1511
+ // Take the separator with the attribute, so a prop on its own line takes its
1512
+ // line and an inline one takes its space. Which side to eat depends on
1513
+ // position: normally the whitespace BEFORE it, but the FIRST attribute must
1514
+ // eat the whitespace after it instead — consuming its leading space would
1515
+ // weld the next attribute onto the tag name.
1516
+ let nameEnd = elementStart + 1;
1517
+ while (nameEnd <= tagEnd && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
1518
+
1519
+ let start = attr.attrStart;
1520
+ while (start > nameEnd && /\s/.test(code[start - 1] ?? '')) start--;
1521
+ let end = attr.attrEnd;
1522
+ if (start <= nameEnd) {
1523
+ start = attr.attrStart;
1524
+ while (end < tagEnd && /\s/.test(code[end] ?? '')) end++;
1525
+ }
1526
+ const next = code.slice(0, start) + code.slice(end);
1527
+ return { code: next, changed: next !== code, dynamic: false };
1528
+ }
1529
+
1530
+ // --- Cap 5: source structural ops (visual-edit writer.ts reorderChild :483 /
1531
+ // duplicateElement :559 / wrapElement :634 / unwrapElement :679 /
1532
+ // reparentElement :743) ---
1533
+
1534
+ export interface StructEditResult {
1535
+ code: string;
1536
+ changed: boolean;
1537
+ }
1538
+
1539
+ /** Expand a JSX element at `elementStart` to its full LINE block (leading whitespace on the
1540
+ * first line, trailing newline on the last) — the unit these line-based ops move/copy. */
1541
+ function lineBlockRange(
1542
+ code: string,
1543
+ elementStart: number,
1544
+ ): { lineStart: number; lineEnd: number } | null {
1545
+ if (code[elementStart] !== '<') return null;
1546
+ const end = findElementEnd(code, elementStart);
1547
+ if (end < 0) return null;
1548
+ let lineStart = elementStart;
1549
+ while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
1550
+ let lineEnd = end;
1551
+ while (lineEnd < code.length && code[lineEnd] !== '\n') lineEnd++;
1552
+ if (lineEnd < code.length && code[lineEnd] === '\n') lineEnd++;
1553
+ return { lineStart, lineEnd };
1554
+ }
1555
+
1556
+ /** The `<` of a parent's closing tag, given the parent's opening `<` (visual-edit's
1557
+ * backwards scan from the element end). */
1558
+ function closingTagStartOf(code: string, parentStart: number): number | null {
1559
+ const parentEnd = findElementEnd(code, parentStart);
1560
+ if (parentEnd < 0) return null;
1561
+ let i = parentEnd;
1562
+ while (i > parentStart && code[i - 1] !== '<') i--;
1563
+ return i - 1; // point at the '<'
1564
+ }
1565
+
1566
+ /** What a lane may ask {@link duplicateElement} to change on the COPY — the
1567
+ * three lane's "a duplicate is visibly a second thing": a fresh `name` (only
1568
+ * when the original carries a literal one — never invented) and a literal
1569
+ * `position` nudged by `positionOffset` (added when absent, since an absent
1570
+ * position IS the origin; left alone when dynamic). */
1571
+ export interface DuplicateRewrite {
1572
+ name?: string;
1573
+ positionOffset?: readonly [number, number, number];
1574
+ }
1575
+
1576
+ /** Nudge one element's literal `position` by an offset — added when absent
1577
+ * (absent IS the origin), left alone when dynamic. Shared by the duplicate
1578
+ * rewrite and the paste-snippet offset. */
1579
+ function offsetElementPosition(
1580
+ code: string,
1581
+ elementStart: number,
1582
+ [dx, dy, dz]: readonly [number, number, number],
1583
+ ): string {
1584
+ const fmt = (n: number): string => String(Number(n.toFixed(3)));
1585
+ const tagEnd = findTagEnd(code, elementStart);
1586
+ const attr =
1587
+ tagEnd < 0
1588
+ ? undefined
1589
+ : analyzeJsxAttributes(code, elementStart, tagEnd).find((a) => a.name === 'position');
1590
+ if (!attr) {
1591
+ return writePropChange(code, elementStart, 'position', `[${fmt(dx)}, ${fmt(dy)}, ${fmt(dz)}]`, {
1592
+ addIfMissing: true,
1593
+ }).code;
1594
+ }
1595
+ if (!attr.isLiteral || !isNumberTupleLiteral(attr.rawValue)) return code;
1596
+ const parts = attr.rawValue
1597
+ .replace(/^\s*\[|\]\s*$/g, '')
1598
+ .split(',')
1599
+ .map((part) => Number(part.trim()));
1600
+ if (parts.length !== 3 || !parts.every((n) => Number.isFinite(n))) return code;
1601
+ const [x, y, z] = parts as [number, number, number];
1602
+ return writePropChange(
1603
+ code,
1604
+ elementStart,
1605
+ 'position',
1606
+ `[${fmt(x + dx)}, ${fmt(y + dy)}, ${fmt(z + dz)}]`,
1607
+ ).code;
1608
+ }
1609
+
1610
+ /**
1611
+ * Shift every TOP-LEVEL element in a paste snippet by one offset — the block
1612
+ * moves as a unit, so relative arrangement inside the copy survives. A paste
1613
+ * that landed byte-identically ON its source read as "nothing was pasted"
1614
+ * to a human tester until they happened to drag the top one off.
1615
+ */
1616
+ export function offsetSnippetPositions(
1617
+ snippet: string,
1618
+ offset: readonly [number, number, number],
1619
+ ): string {
1620
+ let out = snippet;
1621
+ let searchFrom = 0;
1622
+ while (searchFrom < out.length) {
1623
+ const start = out.indexOf('<', searchFrom);
1624
+ if (start < 0) break;
1625
+ if (findElementEnd(out, start) < 0) break;
1626
+ out = offsetElementPosition(out, start, offset);
1627
+ const end = findElementEnd(out, start);
1628
+ if (end <= start) break;
1629
+ searchFrom = end + 1;
1630
+ }
1631
+ return out;
1632
+ }
1633
+
1634
+ /** Duplicate the element's line block, inserting the copy immediately after it (visual-edit
1635
+ * `duplicateElement` :559). `rewrite` edits the copy only — see {@link DuplicateRewrite}. */
1636
+ export function duplicateElement(
1637
+ code: string,
1638
+ elementStart: number,
1639
+ rewrite?: DuplicateRewrite,
1640
+ ): StructEditResult {
1641
+ const r = lineBlockRange(code, elementStart);
1642
+ if (!r) return { code, changed: false };
1643
+ const block = code.slice(r.lineStart, r.lineEnd);
1644
+ let next = code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd);
1645
+ if (!rewrite) return { code: next, changed: true };
1646
+ const copyStart = r.lineEnd + (elementStart - r.lineStart);
1647
+ if (rewrite.name !== undefined) {
1648
+ next = writePropChange(next, copyStart, 'name', rewrite.name).code;
1649
+ }
1650
+ if (rewrite.positionOffset) {
1651
+ next = offsetElementPosition(next, copyStart, rewrite.positionOffset);
1652
+ }
1653
+ return { code: next, changed: true };
1654
+ }
1655
+
1656
+ /** Wrap the element in a new `<wrapperTag>…</wrapperTag>` container, re-indenting the
1657
+ * wrapped block one level (visual-edit `wrapElement` :634). */
1658
+ export function wrapElement(
1659
+ code: string,
1660
+ elementStart: number,
1661
+ wrapperTag = 'div',
1662
+ ): StructEditResult {
1663
+ const r = lineBlockRange(code, elementStart);
1664
+ if (!r) return { code, changed: false };
1665
+ const block = code.slice(r.lineStart, r.lineEnd);
1666
+ const indent = block.match(/^(\s*)/)?.[1] ?? '';
1667
+ const reindented = block
1668
+ .split('\n')
1669
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
1670
+ .join('\n');
1671
+ const wrapper = `${indent}<${wrapperTag}>\n${reindented}${indent}</${wrapperTag}>\n`;
1672
+ return { code: code.slice(0, r.lineStart) + wrapper + code.slice(r.lineEnd), changed: true };
1673
+ }
1674
+
1675
+ /** Unwrap the element — replace it with its children, dedented one level (visual-edit
1676
+ * `unwrapElement` :679). A self-closing element simply deletes.
1677
+ *
1678
+ * For the LINE-BLOCK shape — the opening tag ends its line and the closing tag sits alone
1679
+ * on its own line, exactly what {@link wrapElement} writes — the newline after the opening
1680
+ * tag and the closing tag's leading indent are consumed too, making unwrap the exact BYTE
1681
+ * INVERSE of wrap (previously those survived as a stray blank line plus loose indentation
1682
+ * merged into the following line). Inline children keep the original splice behavior. */
1683
+ export function unwrapElement(code: string, elementStart: number): StructEditResult {
1684
+ if (code[elementStart] !== '<') return { code, changed: false };
1685
+ const end = findElementEnd(code, elementStart);
1686
+ const openTagEnd = findTagEnd(code, elementStart);
1687
+ if (end < 0 || openTagEnd < 0) return { code, changed: false };
1688
+ if (code[openTagEnd - 1] === '/') return deleteElement(code, elementStart); // self-closing
1689
+ let closingTagStart = end;
1690
+ while (closingTagStart > elementStart && code[closingTagStart - 1] !== '<') closingTagStart--;
1691
+ closingTagStart--; // point at '<'
1692
+ const children = code.slice(openTagEnd + 1, closingTagStart);
1693
+ const r = lineBlockRange(code, elementStart);
1694
+ if (!r) return { code, changed: false };
1695
+ const dedentLine = (line: string): string =>
1696
+ line.startsWith(' ') ? line.slice(2) : line.startsWith('\t') ? line.slice(1) : line;
1697
+ const lastNewline = children.lastIndexOf('\n');
1698
+ if (
1699
+ children.startsWith('\n') &&
1700
+ lastNewline > 0 &&
1701
+ /^[ \t]*$/.test(children.slice(lastNewline + 1))
1702
+ ) {
1703
+ // Line-block shape: children are whole lines between the tags.
1704
+ const dedented = children
1705
+ .slice(1, lastNewline + 1)
1706
+ .split('\n')
1707
+ .map(dedentLine)
1708
+ .join('\n');
1709
+ return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1710
+ }
1711
+ const dedented = children.split('\n').map(dedentLine).join('\n');
1712
+ return { code: code.slice(0, r.lineStart) + dedented + code.slice(r.lineEnd), changed: true };
1713
+ }
1714
+
1715
+ /** Insert a new child element `<tag />` just before `parentStart`'s closing tag, indented
1716
+ * to match the parent's children (the "create" primitive).
1717
+ *
1718
+ * `snippet` (R3 — optional; `tag` is ignored when it is provided): a MULTI-LINE JSX
1719
+ * snippet inserted verbatim instead of the bare `<tag />`, each line re-indented one
1720
+ * level under the parent (the R3F preset-create path needs a `<mesh>` with
1721
+ * geometry+material children, not an empty tag). The snippet is authored flush-left with
1722
+ * its own 2-space internal nesting; blank lines stay blank (no trailing whitespace).
1723
+ * Must start with `<` — anything else is refused (`changed: false`), the same
1724
+ * honest-refusal convention every other writer op uses. */
1725
+ export function insertChildElement(
1726
+ code: string,
1727
+ parentStart: number,
1728
+ tag = 'div',
1729
+ snippet?: string,
1730
+ ): StructEditResult {
1731
+ const closingTagStart = closingTagStartOf(code, parentStart);
1732
+ if (closingTagStart == null) return { code, changed: false };
1733
+ let parentLineStart = parentStart;
1734
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1735
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1736
+ let insertPos = closingTagStart;
1737
+ while (insertPos > 0 && code[insertPos - 1] !== '\n') insertPos--;
1738
+ const childIndent = `${parentIndent} `;
1739
+ let child: string;
1740
+ if (snippet !== undefined) {
1741
+ const trimmed = snippet.trim();
1742
+ if (!trimmed.startsWith('<')) return { code, changed: false }; // not a JSX snippet — refuse
1743
+ child = `${trimmed
1744
+ .split('\n')
1745
+ .map((line) => (line.trim().length > 0 ? childIndent + line.trimEnd() : ''))
1746
+ .join('\n')}\n`;
1747
+ } else {
1748
+ child = `${childIndent}<${tag} />\n`;
1749
+ }
1750
+ return { code: code.slice(0, insertPos) + child + code.slice(insertPos), changed: true };
1751
+ }
1752
+
1753
+ /**
1754
+ * Insert a JSX `snippet` as a new SIBLING immediately after the element at
1755
+ * `elementStart`, at that element's own indentation.
1756
+ *
1757
+ * The sibling counterpart to {@link insertChildElement}, and the only way to add
1758
+ * a top-level element to a world whose root JSX is a FRAGMENT: a fragment carries
1759
+ * no OID, so there is no parent offset to insert INTO — but every one of its
1760
+ * children has one, so "append to the world" is expressible as "put this after the
1761
+ * last top-level element". Same line-block unit `duplicateElement` moves, and the
1762
+ * same snippet contract `insertChildElement` documents: flush-left with its own
1763
+ * 2-space internal nesting, must start with `<` (anything else is refused with
1764
+ * `changed: false`), blank lines stay blank.
1765
+ */
1766
+ export function insertSiblingElement(
1767
+ code: string,
1768
+ elementStart: number,
1769
+ snippet: string,
1770
+ ): StructEditResult {
1771
+ const trimmed = snippet.trim();
1772
+ if (!trimmed.startsWith('<')) return { code, changed: false };
1773
+ const r = lineBlockRange(code, elementStart);
1774
+ if (!r) return { code, changed: false };
1775
+ const indent = code.slice(r.lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
1776
+ const block = `${trimmed
1777
+ .split('\n')
1778
+ .map((line) => (line.trim().length > 0 ? indent + line.trimEnd() : ''))
1779
+ .join('\n')}\n`;
1780
+ return { code: code.slice(0, r.lineEnd) + block + code.slice(r.lineEnd), changed: true };
1781
+ }
1782
+
1783
+ /** Reorder the element among its siblings: before `beforeStart` (an OID-resolved sibling
1784
+ * offset), or — when `beforeStart` is null — at the end of `parentStart` (visual-edit
1785
+ * `reorderChild` :483). OID-resolved offsets, immune to index races. */
1786
+ export function reorderChild(
1787
+ code: string,
1788
+ elementStart: number,
1789
+ beforeStart: number | null,
1790
+ parentStart: number | null,
1791
+ ): StructEditResult {
1792
+ const r = lineBlockRange(code, elementStart);
1793
+ if (!r) return { code, changed: false };
1794
+ const { lineStart, lineEnd } = r;
1795
+ const block = code.slice(lineStart, lineEnd);
1796
+ const removedLength = lineEnd - lineStart;
1797
+
1798
+ let targetLineStart: number;
1799
+ if (beforeStart != null) {
1800
+ targetLineStart = beforeStart;
1801
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1802
+ if (targetLineStart === lineStart) return { code, changed: false }; // same position
1803
+ } else if (parentStart != null) {
1804
+ const closingTagStart = closingTagStartOf(code, parentStart);
1805
+ if (closingTagStart == null) return { code, changed: false };
1806
+ targetLineStart = closingTagStart;
1807
+ while (targetLineStart > 0 && code[targetLineStart - 1] !== '\n') targetLineStart--;
1808
+ if (lineStart >= targetLineStart) return { code, changed: false }; // already at end
1809
+ } else {
1810
+ return { code, changed: false };
1811
+ }
1812
+
1813
+ const without = code.slice(0, lineStart) + code.slice(lineEnd);
1814
+ const adj = targetLineStart > lineStart ? targetLineStart - removedLength : targetLineStart;
1815
+ return { code: without.slice(0, adj) + block + without.slice(adj), changed: true };
1816
+ }
1817
+
1818
+ /**
1819
+ * Turn `<Tag … />` into the paired `<Tag …>` / `</Tag>` shape, so the element has an
1820
+ * authored children slot to receive one.
1821
+ *
1822
+ * Why this exists (R3F reparent hardening, R3): in idiomatic R3F source almost every
1823
+ * hierarchy row IS a self-closing component callsite (`<JumpPad name="West" … />`), and
1824
+ * {@link reparentElement}'s `closingTagStartOf` scan finds the element's OWN `<` for such
1825
+ * a tag — so "drop into this row" silently became "insert a PRECEDING SIBLING". Opening
1826
+ * the slot is the honest edit an author would make by hand; it writes no value and
1827
+ * invents no data, it only gives the tag a body. The caller
1828
+ * (`reparent-guard.ts`) has already proven the destination renders its children.
1829
+ *
1830
+ * Refuses (`changed: false`) anything that is not a well-formed self-closing tag.
1831
+ */
1832
+ export function openChildrenSlot(code: string, elementStart: number): StructEditResult {
1833
+ if (code[elementStart] !== '<') return { code, changed: false };
1834
+ const tagEnd = findTagEnd(code, elementStart);
1835
+ if (tagEnd < 0 || code[tagEnd - 1] !== '/') return { code, changed: false };
1836
+ let nameEnd = elementStart + 1;
1837
+ while (nameEnd < code.length && !/[\s>/]/.test(code[nameEnd] ?? '>')) nameEnd++;
1838
+ const tag = code.slice(elementStart + 1, nameEnd);
1839
+ if (!/^[A-Za-z0-9_.]+$/.test(tag)) return { code, changed: false };
1840
+ let lineStart = elementStart;
1841
+ while (lineStart > 0 && code[lineStart - 1] !== '\n') lineStart--;
1842
+ const indent = code.slice(lineStart, elementStart).match(/^(\s*)/)?.[1] ?? '';
1843
+ // Drop the `/` and the whitespace that only existed to separate it.
1844
+ let cut = tagEnd - 1;
1845
+ while (cut > nameEnd && /\s/.test(code[cut - 1] ?? '')) cut--;
1846
+ return {
1847
+ code: `${code.slice(0, cut)}>\n${indent}</${tag}>${code.slice(tagEnd + 1)}`,
1848
+ changed: true,
1849
+ };
1850
+ }
1851
+
1852
+ /** Reparent the element to be the last child of `parentStart`, re-indented to the parent's
1853
+ * child level (visual-edit `reparentElement` :743). Same-file only (the caller guards).
1854
+ *
1855
+ * The R3F hierarchy calls this through `planReparent` (`reparent-guard.ts`), which owns
1856
+ * the scope/transform/legality gates; this function stays the raw textual move. */
1857
+ export function reparentElement(
1858
+ code: string,
1859
+ elementStart: number,
1860
+ parentStart: number,
1861
+ ): StructEditResult {
1862
+ const r = lineBlockRange(code, elementStart);
1863
+ if (!r) return { code, changed: false };
1864
+ const { lineStart: elLineStart, lineEnd: elLineEnd } = r;
1865
+ const block = code.slice(elLineStart, elLineEnd);
1866
+ const closingTagStart = closingTagStartOf(code, parentStart);
1867
+ if (closingTagStart == null) return { code, changed: false };
1868
+
1869
+ let parentLineStart = parentStart;
1870
+ while (parentLineStart > 0 && code[parentLineStart - 1] !== '\n') parentLineStart--;
1871
+ const parentIndent = code.slice(parentLineStart, parentStart).match(/^(\s*)/)?.[1] ?? '';
1872
+ const childIndent = `${parentIndent} `;
1873
+ const reindented = block
1874
+ .split('\n')
1875
+ .map((line) => {
1876
+ const trimmed = line.replace(/^\s*/, '');
1877
+ return trimmed ? childIndent + trimmed : '';
1878
+ })
1879
+ .join('\n');
1880
+
1881
+ const without = code.slice(0, elLineStart) + code.slice(elLineEnd);
1882
+ let adjustedClosing = closingTagStart;
1883
+ if (elLineStart < closingTagStart) adjustedClosing -= elLineEnd - elLineStart;
1884
+ let insertPos = adjustedClosing;
1885
+ while (insertPos > 0 && without[insertPos - 1] !== '\n') insertPos--;
1886
+ return {
1887
+ code: without.slice(0, insertPos) + reindented + without.slice(insertPos),
1888
+ changed: true,
1889
+ };
1890
+ }
1891
+
1892
+ /** Delete the element starting at `elementStart` from source (C8). */
1893
+ export function deleteElement(
1894
+ code: string,
1895
+ elementStart: number,
1896
+ ): { code: string; changed: boolean } {
1897
+ const end = findElementEnd(code, elementStart);
1898
+ if (end < 0) return { code, changed: false };
1899
+ // also consume leading whitespace on the line + trailing newline
1900
+ let s = elementStart;
1901
+ while (s > 0 && (code[s - 1] === ' ' || code[s - 1] === '\t')) s--;
1902
+ let e = end;
1903
+ if (code[e] === '\n') e++;
1904
+ return { code: code.slice(0, s) + code.slice(e), changed: true };
1905
+ }
1906
+
1907
+ /**
1908
+ * Delete MULTIPLE elements from a SINGLE `code` snapshot, given every target's
1909
+ * `elementStart` offset resolved against that SAME snapshot (delete-order-residual
1910
+ * fix — `vite-plugin-ui-oid.ts`'s `handleStructMany`). Sound and caller-order-
1911
+ * independent: every offset is resolved from the file ONCE, up front, then applied
1912
+ * highest-offset-first — each `deleteElement` call only ever removes text AT OR
1913
+ * AFTER its own target, so every offset still queued (all of them numerically
1914
+ * SMALLER, since we process descending) is untouched by it and stays valid. A
1915
+ * nested descendant's `elementStart` is always numerically greater than its
1916
+ * ancestor's (it appears later in source), so this single offset-value rule also
1917
+ * deletes a selected descendant before a selected ancestor, with no separate
1918
+ * ancestor/descendant case to reason about. Unlike re-resolving each target by
1919
+ * re-scanning/re-transforming the file between deletes, this never recomputes an
1920
+ * OID's identity mid-batch — it only ever reads the ALREADY-RESOLVED offsets handed
1921
+ * to it, so it cannot suffer the occurrence-index churn a live re-transform would
1922
+ * introduce (deleting one same-tag sibling shifts every later sibling's own
1923
+ * `component:tag:nthOccurrence` signature, which would silently relabel it as a
1924
+ * DIFFERENT, already-used oid if resolution were re-derived from a fresh transform
1925
+ * mid-batch — the reason `handleStruct`'s per-call resolution is not simply looped
1926
+ * here without change).
1927
+ */
1928
+ export function deleteElements(
1929
+ code: string,
1930
+ elementStarts: readonly number[],
1931
+ ): { code: string; changed: boolean } {
1932
+ const sorted = [...elementStarts].sort((a, b) => b - a); // highest offset first
1933
+ let cur = code;
1934
+ let changed = false;
1935
+ for (const off of sorted) {
1936
+ const result = deleteElement(cur, off);
1937
+ if (result.changed) {
1938
+ cur = result.code;
1939
+ changed = true;
1940
+ }
1941
+ }
1942
+ return { code: cur, changed };
1943
+ }
1944
+
1945
+ /** Group source-addressed sibling JSX elements beneath one ordinary R3F
1946
+ * `<group name="Group">`. The adapter verifies common hierarchy ownership;
1947
+ * this writer additionally requires non-overlapping line blocks with identical
1948
+ * indentation, then performs one offset-stable rewrite from a single snapshot. */
1949
+ export function groupSiblingElements(
1950
+ code: string,
1951
+ elementStarts: readonly number[],
1952
+ wrapperTag = 'group',
1953
+ ): StructEditResult {
1954
+ const starts = [...new Set(elementStarts)].sort((a, b) => a - b);
1955
+ if (starts.length < 2) return { code, changed: false };
1956
+ const blocks = starts.map((start) => ({ start, range: lineBlockRange(code, start) }));
1957
+ if (blocks.some(({ range }) => !range)) return { code, changed: false };
1958
+ const resolved = blocks as { start: number; range: { lineStart: number; lineEnd: number } }[];
1959
+ for (let index = 1; index < resolved.length; index++) {
1960
+ if (resolved[index - 1]!.range.lineEnd > resolved[index]!.range.lineStart) {
1961
+ return { code, changed: false };
1962
+ }
1963
+ }
1964
+ const indents = resolved.map(({ start, range }) => code.slice(range.lineStart, start));
1965
+ const indent = indents[0] ?? '';
1966
+ if (!/^[ \t]*$/.test(indent) || indents.some((candidate) => candidate !== indent)) {
1967
+ return { code, changed: false };
1968
+ }
1969
+ const selected = resolved
1970
+ .map(({ range }) => code.slice(range.lineStart, range.lineEnd))
1971
+ .map((block) =>
1972
+ block
1973
+ .split('\n')
1974
+ .map((line) => (line.length > 0 ? ` ${line}` : line))
1975
+ .join('\n'),
1976
+ )
1977
+ .join('');
1978
+ const insertion = resolved[0]!.range.lineStart;
1979
+ let without = code;
1980
+ for (const { range } of [...resolved].reverse()) {
1981
+ without = without.slice(0, range.lineStart) + without.slice(range.lineEnd);
1982
+ }
1983
+ const labelProp = wrapperTag === 'group' ? 'name' : 'label';
1984
+ const wrapper = `${indent}<${wrapperTag} ${labelProp}="Group">\n${selected}${indent}</${wrapperTag}>\n`;
1985
+ return {
1986
+ code: without.slice(0, insertion) + wrapper + without.slice(insertion),
1987
+ changed: true,
1988
+ };
1989
+ }