@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,3795 @@
1
+ /**
2
+ * ReactRootAuthoringAdapter — the editor's {@link AuthoringAdapter} for a react-kind
3
+ * world (T6.2 slice 2).
4
+ *
5
+ * A react world has no ticking and no mirror (D8) — its authoring
6
+ * entities ARE the `data-oid`-stamped elements of its rendered DOM (the OID
7
+ * instrumentation from the UI visual-edit program, `../ui-source/oid-transform.ts`,
8
+ * whose vite-plugin include this slice widens from the `editable-components` fixture
9
+ * dir to project scope — see `../../vite-plugin-ui-oid.ts`). This adapter derives
10
+ * its hierarchy from the shared DOM projector (`../projection/dom.ts`) under the
11
+ * OID identity, over the world's live DOM root (`RootInstance.reactRoot()`);
12
+ * there is no cached/mirrored state to fall out of sync — every `hierarchy`
13
+ * call re-projects the live DOM.
14
+ *
15
+ * Writes (style/className/delete) reuse the EXISTING T3.2-slice-3 source-write seam
16
+ * verbatim (`../ui-source/source-write-backend.ts`'s `SourceWriteBackend`, the same
17
+ * `/__ui-source/write` + `/__ui-source/struct` dev-server endpoints
18
+ * `vite-plugin-ui-oid.ts` serves for `UIAuthoringAdapter`/`SourceEditPanel`) — this
19
+ * file does NOT invent a second source-writer. Every edit prepares complete next-file
20
+ * text and commits it through one checksum-guarded source history resource. Style,
21
+ * text, prop, and structural changes therefore share exact-byte undo/redo semantics.
22
+ *
23
+ * Persistence mirrors `UIAuthoringAdapter`'s de-stubbed pattern exactly: writes are
24
+ * immediate (server-side, on commit), so `save()` is an honest no-op; `destination`
25
+ * reports whether a source-write backend even exists in this session (absent in a
26
+ * hosted/no-dev-server build — selection/inspection still work, writes report
27
+ * unavailable via a loud console warning instead of silently no-op'ing).
28
+ */
29
+
30
+ import { activeBreakpoint } from '@volter/editor-core/authoring/breakpoint-state';
31
+ import {
32
+ cssTextForStyleValue,
33
+ numericStyleValue,
34
+ preserveNumericStyleUnit,
35
+ } from '@volter/editor-core/authoring/css-numeric-style';
36
+ import { WORLD_SCOPE_NODE_ID } from '@volter/editor-core/authoring/stories-scope';
37
+ import { createStructWritePipe, type StructOpOptions } from '../host/authoring/struct-write-pipe';
38
+ import { getRootPan } from '@volter/editor-core/authoring/world-pan-state';
39
+ import {
40
+ resolvesLiveOnly,
41
+ runWritePipe,
42
+ type WriteAck,
43
+ type WriteResolution,
44
+ } from '@volter/editor-core/authoring/write-pipe';
45
+ import { guideClientEdges } from '@volter/editor-core/components/board-guides';
46
+ import { editorConsole } from '@volter/editor-core/editor-console';
47
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
48
+ import { withProjectSourceHistory } from '@volter/editor-core/history/source-history-backend';
49
+ import { DomProjector, oidDomIdentity, projectOidDom } from '../host/projection/dom';
50
+ import { storyArgPropertyDescriptors } from '../host/stories/story-arg-descriptors';
51
+ import { storyDiscoveryUnavailable } from '@volter/editor-core/stories/story-discovery';
52
+ import { deriveStoryGroupPath, formatStoryGroupPath } from '@volter/editor-core/stories/story-grouping';
53
+ import type { StoryPresentationIndex } from '@volter/editor-core/stories/story-presentation';
54
+ import { subscribeProjectStoryModules } from '@volter/editor-core/stories/story-registry';
55
+ import { showTransientHint } from '@volter/editor-core/transient-hint';
56
+ import {
57
+ browserOrInlineResolver,
58
+ type ComputedStyleResolver,
59
+ camelToKebab,
60
+ type DesignToken,
61
+ type EmptyCandidate,
62
+ findClassRuleSource,
63
+ findEmptyContainers,
64
+ firstPartyStylesheetFiles,
65
+ getCallSiteOid,
66
+ getComponentProps,
67
+ getComputedStyleValue,
68
+ getDesignTokens,
69
+ getMatchedCssRules,
70
+ getReactComponentName,
71
+ type MatchableElement,
72
+ } from '@volter/editor-core/ui-source/inspect';
73
+ import type { ComponentPropSpec, OidEntry } from '@volter/editor-core/ui-source/oid-transform';
74
+ import { relativeImportSpecifier } from '@volter/editor-core/ui-source/relative-import-specifier';
75
+ import type { SourceWriteBackend } from '@volter/editor-core/ui-source/source-write-backend';
76
+ import { writeCsfStory, writeNamedStyle } from '@volter/editor-core/ui-source/source-write-backend';
77
+ import {
78
+ type CssRuleTarget,
79
+ namedStyleRuleFor,
80
+ pickCssRuleTarget,
81
+ tokenReferenceGuardText,
82
+ } from '@volter/editor-core/ui-source/writer';
83
+ import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/editor-core/workspace-document-ids';
84
+ import { activateWorkspaceDocument } from '@volter/editor-core/workspace-document-registry';
85
+ import type {
86
+ AssetDropContext,
87
+ AssetDropProvider,
88
+ AssetSubjectProvider,
89
+ AuthoringAdapter,
90
+ AuthoringAssetSubject,
91
+ AuthoringCapabilities,
92
+ AuthoringProvenance,
93
+ BoxEditProvider,
94
+ ColorSampleProvider,
95
+ ComponentInstanceApplyResult,
96
+ ComponentInstancesProvider,
97
+ DOMRectLike,
98
+ EditorNode,
99
+ HierarchyProvider,
100
+ InspectorProvider,
101
+ NodeCreationSite,
102
+ PersistenceProvider,
103
+ PickProvider,
104
+ PropertyDescriptor,
105
+ RectProvider,
106
+ RelatedSubjectsProvider,
107
+ SelectionProvider,
108
+ StoriesProvider,
109
+ StoryRef,
110
+ StructureProvider,
111
+ TextProvider,
112
+ TruthProvider,
113
+ WriteAnchorKind,
114
+ } from '@volter/editor-project/adapter';
115
+
116
+ /**
117
+ * The minimal structural shape this adapter needs from a live DOM element —
118
+ * deliberately NOT `HTMLElement` so it stays testable with a plain-object fixture
119
+ * headlessly (this repo's vitest environment is `node`, no jsdom — use the
120
+ * repo's DOM-stub + fixture patterns instead). A real `HTMLElement` satisfies
121
+ * this structurally: `tagName`
122
+ * (uppercase, per the DOM spec — lower-cased for labels/kind below), `children`
123
+ * (an `HTMLCollection`, `Array.from`-able), and `getAttribute` reading the REAL
124
+ * `data-oid="…"` attribute `transformSource` stamped into the JSX (a genuine DOM
125
+ * attribute at runtime, not a mirror).
126
+ */
127
+ export interface OidElementLike {
128
+ readonly tagName: string;
129
+ /** Real DOM nodes expose this. Optional keeps headless fixtures minimal. */
130
+ readonly namespaceURI?: string | null;
131
+ readonly children: ArrayLike<OidElementLike>;
132
+ getAttribute(name: string): string | null;
133
+ /**
134
+ * `unknown` rather than a structural record — a real `CSSStyleDeclaration` has
135
+ * NO string index signature (TS models it as a fixed set of named properties
136
+ * plus methods), so it can't structurally satisfy `Record<string, unknown>`.
137
+ * Read through {@link styleProp} below, which handles both shapes.
138
+ */
139
+ readonly style?: unknown;
140
+ /**
141
+ * D12 (B4) — the element's live viewport rect, for `pickable.pick`'s
142
+ * geometric hit-test (see that provider's doc comment for why NOT
143
+ * `elementFromPoint`). Optional so existing plain-object test fixtures
144
+ * (which never provide one) keep type-checking unchanged — a node with no
145
+ * `getBoundingClientRect` simply never wins a pick (its rect is treated as
146
+ * absent, never a fabricated 0×0 that could win a tie).
147
+ */
148
+ getBoundingClientRect?(): {
149
+ left: number;
150
+ top: number;
151
+ right: number;
152
+ bottom: number;
153
+ width: number;
154
+ height: number;
155
+ };
156
+ /**
157
+ * T0 (spec 27 §2) — the element's rendered text, read for `TextProvider.get`'s
158
+ * "has child elements" gate below. Optional so pre-existing `OidElementLike`
159
+ * fixtures (none of which set it) keep satisfying the interface unchanged.
160
+ */
161
+ readonly textContent?: string | null;
162
+ /** Present on real DOM/SVG elements; used only for atomic inline-SVG assets. */
163
+ readonly outerHTML?: string;
164
+ }
165
+
166
+ const MAX_ELEMENT_LABEL_LENGTH = 64;
167
+
168
+ /** Compact the browser's semantic text into a stable hierarchy-row label. */
169
+ function normalizeElementText(value: string | null | undefined): string | null {
170
+ const text = value?.replace(/\s+/g, ' ').trim();
171
+ if (!text) return null;
172
+ if (text.length <= MAX_ELEMENT_LABEL_LENGTH) return text;
173
+ return `${text.slice(0, MAX_ELEMENT_LABEL_LENGTH - 1).trimEnd()}…`;
174
+ }
175
+
176
+ /** Read one style property off an `OidElementLike.style` of either shape (a real
177
+ * `CSSStyleDeclaration` or a plain-object test fixture). */
178
+ export function styleProp(style: unknown, prop: string): unknown {
179
+ return style ? (style as Record<string, unknown>)[prop] : undefined;
180
+ }
181
+
182
+ const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
183
+
184
+ /**
185
+ * Normalize a CSS color VALUE into `#rrggbb` for a `type: 'color'`
186
+ * {@link PropertyDescriptor} (`<input type="color">` only accepts exactly
187
+ * that shape — an unparseable value makes the browser silently coerce the
188
+ * input to black). A real `CSSStyleDeclaration` always serializes an inline
189
+ * color property as `rgb(r, g, b)`/`rgba(r, g, b, a)` — EVEN WHEN the author
190
+ * wrote a hex literal in JSX (`el.style.color = '#3ddc65'` reads back as
191
+ * `"rgb(61, 220, 101)"`, verified empirically against a real Chromium page,
192
+ * not assumed) — never hex. Without this normalization every color-typed
193
+ * style field would show black regardless of its real value against a real
194
+ * browser DOM (the plain-object test fixtures never caught this: a
195
+ * hand-built fixture's `style` can hold the author's hex string directly,
196
+ * which real CSSOM never does). An already-hex value (a fixture, or a
197
+ * property this repo's CSSOM happens to serialize as hex) passes through
198
+ * unchanged; a value matching neither shape returns `undefined` (the
199
+ * generic inspector's own '#ffffff' fallback) rather than handing the input
200
+ * an invalid string.
201
+ */
202
+ export function cssColorToHex(raw: unknown): string | undefined {
203
+ if (typeof raw !== 'string' || raw === '') return undefined;
204
+ if (raw.startsWith('#')) return raw;
205
+ const m = RGB_RE.exec(raw);
206
+ if (!m) return undefined;
207
+ const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
208
+ return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
209
+ }
210
+
211
+ /** One resolved `BoxEditProvider` patch-key mapping (spec 27 §4, B1). */
212
+ export interface BoxEditPropMapping {
213
+ /** The CSS style prop to write (already the target, e.g. `x` → `left`). */
214
+ prop: string;
215
+ /** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
216
+ * spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
217
+ cssValue: (v: number) => string;
218
+ }
219
+
220
+ /**
221
+ * `BoxEditProvider` patch-key → CSS-prop mapping (spec 27 B1), shared by BOTH
222
+ * authoring adapters' `boxEdit.apply/end` (`dom-authoring-adapter.ts`
223
+ * imports this rather than redefining it — same DRY reuse as
224
+ * {@link cssColorToHex}/{@link numericStyleValue}/{@link styleProp} below).
225
+ * `width`/`height`/`margin*`/`padding*` map 1:1 to their same-named style
226
+ * prop. `x`/`y` map to `left`/`top` ONLY when the node is
227
+ * absolutely/fixed-positioned (there is no `left`/`top` to move on a
228
+ * static/relative node) — `isPositioned` is a caller-resolved boolean (each
229
+ * adapter reads its own computed-style resolver) — a mismatched key returns
230
+ * `null` to mean "drop this key"; the CALLER does its own loud
231
+ * `console.warn` so the adapter's own name appears in the message. `rotate`
232
+ * (deg) maps to the `transform` prop as a `rotate(<deg>deg)` string.
233
+ */
234
+ export function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
235
+ switch (key) {
236
+ case 'width':
237
+ case 'height':
238
+ case 'marginTop':
239
+ case 'marginRight':
240
+ case 'marginBottom':
241
+ case 'marginLeft':
242
+ case 'paddingTop':
243
+ case 'paddingRight':
244
+ case 'paddingBottom':
245
+ case 'paddingLeft':
246
+ return { prop: key, cssValue: (v) => `${v}px` };
247
+ case 'x':
248
+ return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
249
+ case 'y':
250
+ return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
251
+ case 'rotate':
252
+ return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
253
+ default:
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A pasteboard `<At>` mount — detected by the STRUCTURAL marker the pasteboard
260
+ * capability's helper renders (`data-vgai-at="true"`), never by filename or
261
+ * import path, so a project's freely edited copy of the helper keeps working
262
+ * (the LiveModuleDocument precedent: detection is structural). An `x`/`y` drag
263
+ * on one of these writes the callsite's `x`/`y` JSX literals instead of inline
264
+ * `left`/`top` style — the authored form the pasteboard design ratified
265
+ * (docs/PARITY-PROGRAM-HANDOFF.md, queue item 4: "drag = OID literal write to
266
+ * x/y").
267
+ */
268
+ function isPasteboardAtElement(el: OidElementLike): boolean {
269
+ return el.getAttribute('data-vgai-at') === 'true';
270
+ }
271
+
272
+ const OID_ATTR = 'data-oid';
273
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
274
+ const VOID_HTML_ELEMENTS = new Set([
275
+ 'area',
276
+ 'base',
277
+ 'br',
278
+ 'col',
279
+ 'embed',
280
+ 'hr',
281
+ 'img',
282
+ 'input',
283
+ 'link',
284
+ 'meta',
285
+ 'param',
286
+ 'source',
287
+ 'track',
288
+ 'wbr',
289
+ ]);
290
+
291
+ /** Empty-layout hints are for HTML elements that could receive content.
292
+ * SVG geometry (especially zero-width `line`/`path` bounds) and HTML void
293
+ * elements are leaves by definition, not collapsed layout containers. */
294
+ function canContainDomChildren(el: OidElementLike, tag: string): boolean {
295
+ const namespace = el.namespaceURI;
296
+ if (namespace !== undefined && namespace !== null && namespace !== HTML_NAMESPACE) return false;
297
+ return !VOID_HTML_ELEMENTS.has(tag);
298
+ }
299
+
300
+ function projectSourcePath(path: string): string {
301
+ const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
302
+ const marker = normalized.lastIndexOf('/src/');
303
+ return marker >= 0 ? normalized.slice(marker + 1) : normalized;
304
+ }
305
+
306
+ /** One OID-tagged DOM element resolved into the adapter's internal tree. */
307
+ interface OidNode {
308
+ /** Disambiguated entity id — see {@link walkOidTree}'s doc comment. */
309
+ id: string;
310
+ /** The raw OID (may repeat across sibling `OidNode`s — see below). */
311
+ oid: string;
312
+ tag: string;
313
+ el: OidElementLike;
314
+ parentId: string | null;
315
+ childIds: string[];
316
+ }
317
+
318
+ export interface OidTree {
319
+ /** Every OID-tagged node, keyed by its (disambiguated) entity id. */
320
+ nodes: Map<string, OidNode>;
321
+ /** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
322
+ rootIds: string[];
323
+ }
324
+
325
+ /**
326
+ * Walk a react world's live DOM root for `data-oid`-carrying elements.
327
+ *
328
+ * This is the adapter-facing view over {@link projectOidDom}: the projector
329
+ * owns identity, nesting, and the SVG-atomic rule; this wrapper keeps the
330
+ * `OidNode` shape existing callers (tests, ingest sibling probe, play-live
331
+ * authoring) already read — `oid` and `tag` are derived from the live
332
+ * element the projector handed back.
333
+ *
334
+ * OID → entity id disambiguation lives on the projector (`oidDomIdentity`):
335
+ * the first element carrying a given oid keeps `id === oid`; the Nth repeat
336
+ * gets `id === "${oid}#${n}"`. An unstamped wrapper is transparent — its
337
+ * children attach to the nearest stamped ancestor.
338
+ */
339
+ export function walkOidTree(root: OidElementLike): OidTree {
340
+ return oidTreeFromProjection(projectOidDom(root));
341
+ }
342
+
343
+ function oidTreeFromProjection(projection: {
344
+ readonly nodes: ReadonlyMap<
345
+ string,
346
+ { object: OidElementLike; parentId: string | null; childIds: readonly string[] }
347
+ >;
348
+ readonly rootIds: readonly string[];
349
+ }): OidTree {
350
+ const nodes = new Map<string, OidNode>();
351
+ for (const [id, node] of projection.nodes) {
352
+ nodes.set(id, {
353
+ id,
354
+ oid: node.object.getAttribute(OID_ATTR) ?? id,
355
+ tag: node.object.tagName.toLowerCase(),
356
+ el: node.object,
357
+ parentId: node.parentId,
358
+ childIds: [...node.childIds],
359
+ });
360
+ }
361
+ return { nodes, rootIds: [...projection.rootIds] };
362
+ }
363
+
364
+ /**
365
+ * Cap 6 (React visual-edit parity): the RICH GROUPED inspector model — the figma-style
366
+ * panels (Layout / Position / Spacing / Type / Fill / Stroke / Effects / Transform), each
367
+ * property tagged with a `group` the generic inspector renders as a titled sub-section
368
+ * (`PropertyDescriptor.group`). Every property here is covered by the writer's broadened
369
+ * `ARB_MAP`/`ENUM_UTILITIES` class routing (or inline style), so a set writes real source.
370
+ * (Bespoke widgets — HSV picker, scrub inputs, gradient/multi-shadow editors — are the
371
+ * one descoped part of Cap 6; the generic color/number/enum/string inputs render each of
372
+ * these functionally.)
373
+ */
374
+ const STYLE_PROPERTIES: ReadonlyArray<{
375
+ prop: string;
376
+ label: string;
377
+ type: PropertyDescriptor['type'];
378
+ group: string;
379
+ options?: string[];
380
+ }> = [
381
+ // -- Layout --
382
+ {
383
+ prop: 'display',
384
+ label: 'Display',
385
+ type: 'enum',
386
+ group: 'Layout',
387
+ options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
388
+ },
389
+ {
390
+ prop: 'flexDirection',
391
+ label: 'Direction',
392
+ type: 'enum',
393
+ group: 'Layout',
394
+ options: ['row', 'column', 'row-reverse', 'column-reverse'],
395
+ },
396
+ {
397
+ prop: 'flexWrap',
398
+ label: 'Wrap',
399
+ type: 'enum',
400
+ group: 'Layout',
401
+ options: ['nowrap', 'wrap', 'wrap-reverse'],
402
+ },
403
+ {
404
+ prop: 'justifyContent',
405
+ label: 'Justify',
406
+ type: 'enum',
407
+ group: 'Layout',
408
+ options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
409
+ },
410
+ {
411
+ prop: 'alignItems',
412
+ label: 'Align',
413
+ type: 'enum',
414
+ group: 'Layout',
415
+ options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
416
+ },
417
+ { prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
418
+ // -- Grid (design ledger: grid authoring). Figma's GridLayoutMixin is a
419
+ // strict subset of CSS Grid (counts/sizes/gaps/placement), so the carriers
420
+ // ARE the CSS properties: templates as strings (repeat()/minmax()/fr all
421
+ // legal — nothing dumbed down), gaps split per axis, placement on the
422
+ // child. SemanticLayout renders the container set when display is grid,
423
+ // and the grid-item set behind its own disclosure. --
424
+ { prop: 'gridTemplateColumns', label: 'Columns', type: 'string', group: 'Layout' },
425
+ { prop: 'gridTemplateRows', label: 'Rows', type: 'string', group: 'Layout' },
426
+ {
427
+ prop: 'gridAutoFlow',
428
+ label: 'Flow',
429
+ type: 'enum',
430
+ group: 'Layout',
431
+ options: ['row', 'column', 'row dense', 'column dense'],
432
+ },
433
+ { prop: 'rowGap', label: 'Row Gap', type: 'number', group: 'Layout' },
434
+ { prop: 'columnGap', label: 'Col Gap', type: 'number', group: 'Layout' },
435
+ { prop: 'gridColumn', label: 'Grid Col', type: 'string', group: 'Layout' },
436
+ { prop: 'gridRow', label: 'Grid Row', type: 'string', group: 'Layout' },
437
+ {
438
+ prop: 'justifySelf',
439
+ label: 'Justify Self',
440
+ type: 'enum',
441
+ group: 'Layout',
442
+ options: ['auto', 'start', 'center', 'end', 'stretch'],
443
+ },
444
+ {
445
+ prop: 'overflow',
446
+ label: 'Overflow',
447
+ type: 'enum',
448
+ group: 'Layout',
449
+ options: ['visible', 'hidden', 'scroll', 'auto'],
450
+ },
451
+ { prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
452
+ { prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
453
+ { prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
454
+ { prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
455
+ { prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
456
+ { prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
457
+ // -- Position --
458
+ {
459
+ prop: 'position',
460
+ label: 'Position',
461
+ type: 'enum',
462
+ group: 'Position',
463
+ options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
464
+ },
465
+ { prop: 'top', label: 'Top', type: 'number', group: 'Position' },
466
+ { prop: 'right', label: 'Right', type: 'number', group: 'Position' },
467
+ { prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
468
+ { prop: 'left', label: 'Left', type: 'number', group: 'Position' },
469
+ { prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
470
+ { prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
471
+ { prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
472
+ { prop: 'flexBasis', label: 'Basis', type: 'string', group: 'Position' },
473
+ {
474
+ prop: 'alignSelf',
475
+ label: 'Self Align',
476
+ type: 'enum',
477
+ group: 'Position',
478
+ options: ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
479
+ },
480
+ // -- Spacing (per-side) --
481
+ { prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
482
+ { prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
483
+ { prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
484
+ { prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
485
+ { prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
486
+ { prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
487
+ { prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
488
+ { prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
489
+ // -- Type --
490
+ { prop: 'color', label: 'Color', type: 'color', group: 'Type' },
491
+ { prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
492
+ { prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
493
+ { prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
494
+ { prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
495
+ {
496
+ prop: 'textAlign',
497
+ label: 'Align',
498
+ type: 'enum',
499
+ group: 'Type',
500
+ options: ['left', 'center', 'right', 'justify'],
501
+ },
502
+ {
503
+ prop: 'textTransform',
504
+ label: 'Transform',
505
+ type: 'enum',
506
+ group: 'Type',
507
+ options: ['none', 'uppercase', 'lowercase', 'capitalize'],
508
+ },
509
+ { prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
510
+ { prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
511
+ {
512
+ prop: 'textDecoration',
513
+ label: 'Decoration',
514
+ type: 'enum',
515
+ group: 'Type',
516
+ options: ['none', 'underline', 'line-through', 'overline'],
517
+ },
518
+ // -- Fill --
519
+ { prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
520
+ // Cap 6 (§5 gap-fill): computed style never round-trips the `background`
521
+ // shorthand — `backgroundImage` is the prop that actually reads back
522
+ // (see `inspector.get`'s computed-style path below), so the gradient
523
+ // widget for a STYLE property must key off it, not `background`.
524
+ { prop: 'backgroundImage', label: 'Fills', type: 'string', group: 'Fill' },
525
+ // Image-fill companions (design ledger: fill-as-list) — global, not
526
+ // per-layer, in v1; SemanticFill shows them only when an image layer exists.
527
+ {
528
+ prop: 'backgroundSize',
529
+ label: 'Size',
530
+ type: 'enum',
531
+ group: 'Fill',
532
+ options: ['auto', 'cover', 'contain'],
533
+ },
534
+ { prop: 'backgroundPosition', label: 'Pos', type: 'string', group: 'Fill' },
535
+ {
536
+ prop: 'backgroundRepeat',
537
+ label: 'Repeat',
538
+ type: 'enum',
539
+ group: 'Fill',
540
+ options: ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'],
541
+ },
542
+ // -- Stroke --
543
+ { prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
544
+ { prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
545
+ {
546
+ prop: 'borderStyle',
547
+ label: 'Border Style',
548
+ type: 'enum',
549
+ group: 'Stroke',
550
+ options: ['none', 'solid', 'dashed', 'dotted', 'double'],
551
+ },
552
+ { prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
553
+ // -- Effects --
554
+ { prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
555
+ { prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
556
+ {
557
+ prop: 'mixBlendMode',
558
+ label: 'Blend',
559
+ type: 'enum',
560
+ group: 'Effects',
561
+ options: [
562
+ 'normal',
563
+ 'multiply',
564
+ 'screen',
565
+ 'overlay',
566
+ 'darken',
567
+ 'lighten',
568
+ 'color-dodge',
569
+ 'color-burn',
570
+ 'hard-light',
571
+ 'soft-light',
572
+ 'difference',
573
+ 'exclusion',
574
+ 'hue',
575
+ 'saturation',
576
+ 'color',
577
+ 'luminosity',
578
+ ],
579
+ },
580
+ { prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
581
+ { prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
582
+ { prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
583
+ {
584
+ prop: 'cursor',
585
+ label: 'Cursor',
586
+ type: 'enum',
587
+ group: 'Effects',
588
+ options: [
589
+ 'auto',
590
+ 'default',
591
+ 'pointer',
592
+ 'text',
593
+ 'move',
594
+ 'wait',
595
+ 'help',
596
+ 'crosshair',
597
+ 'not-allowed',
598
+ 'grab',
599
+ 'grabbing',
600
+ 'zoom-in',
601
+ 'zoom-out',
602
+ 'col-resize',
603
+ 'row-resize',
604
+ 'none',
605
+ ],
606
+ },
607
+ // -- Transform --
608
+ { prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
609
+ ];
610
+ const STYLE_PATH_PREFIX = 'style.';
611
+ /** Where this adapter's writes land — the ONE spelling, read by both the
612
+ * save-status provider and the per-edit ack the pipe returns, so the two can
613
+ * never name different places. */
614
+ const JSX_SOURCE_DESTINATION =
615
+ 'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)';
616
+ /** …and the honest floor when no writer is bound. */
617
+ const NO_BACKEND_DESTINATION = 'component source (JSX) — no source-write backend in this session';
618
+ /** Cap 4: inspector path prefix for a component's editable props (`prop.<name>`). */
619
+ const PROP_PATH_PREFIX = 'prop.';
620
+ /** Cap 7: inspector path prefix for the document's design tokens (`token.<name>`). */
621
+ const TOKEN_PATH_PREFIX = 'token.';
622
+ const PORTABLE_STORY_ARG_PATH_PREFIX = 'story.arg.';
623
+ /** D3.R1 (spec 27 §2/§5 U4 precedent, reopen fix) — the `guardedPaths` key for
624
+ * Cap 3's text edit (`editText`), so a dynamic-body refusal is recorded and
625
+ * surfaced through the SAME session-scoped after-touch mechanism the
626
+ * style/prop U4 widgets use — not a real inspector path (text has no
627
+ * `properties()` descriptor), just a stable key for `` `${id}|${TEXT_PATH}` ``. */
628
+ const TEXT_PATH = 'text';
629
+ /** The dynamic-expression guard's ONE sentence — the console warning and the
630
+ * field's read-only reason are the same string, so an author reads the same
631
+ * thing wherever they meet it (`tokenReferenceGuardText` is its var() twin). */
632
+ /**
633
+ * The selection-repair watch: 30 × 40ms ≈ 1.2s after an HMR update.
634
+ * MEASURED: the first drop lands 100-150ms after the update, and one edit can
635
+ * produce more than one update — so the window covers the whole remount storm
636
+ * without outliving the gesture that caused it.
637
+ */
638
+ const SELECTION_REPAIR_ATTEMPTS = 30;
639
+ /**
640
+ * The REPAIRED case is named ONCE PER SESSION, the `reportUndeclaredStoryMedium`
641
+ * idiom: it is a real open defect and has to be visible, but it fires on every
642
+ * source edit in this lane, and a console that is permanently non-empty is a
643
+ * console nobody can use to find the next real thing. A repair that CANNOT be
644
+ * made stays loud every time — there the author actually lost something.
645
+ */
646
+ let reportedSelectionRepair = false;
647
+ const SELECTION_REPAIR_INTERVAL_MS = 40;
648
+
649
+ const DYNAMIC_EXPRESSION_GUARD =
650
+ 'The source value is a dynamic expression, so replacing it with a literal is guarded.';
651
+
652
+ /**
653
+ * WHICH LONGHANDS EACH CSS SHORTHAND OWNS — the table behind
654
+ * {@link ReactRootAuthoringAdapter.expandShorthandsFor}.
655
+ *
656
+ * React refuses to hold both halves of one property at once: writing
657
+ * `paddingTop` beside an authored `padding: 16` raises its own "Removing a
658
+ * style property during rerender (paddingTop) when a conflicting property is
659
+ * set (padding) can lead to styling bugs", and which of the two wins becomes
660
+ * key-order dependent.
661
+ *
662
+ * The combo widgets already solved the UNIFORM direction by REMOVING the
663
+ * longhands (`RadiusRow`/`ComboRow` call `inspector.remove` for every corner
664
+ * and side). This is that same gesture in the other direction: on the first
665
+ * per-side write, the shorthand is EXPANDED into its longhands and removed, so
666
+ * the element is authored in exactly one vocabulary either way.
667
+ *
668
+ * Nested on purpose — `border` owns three shorthands that each own four
669
+ * longhands — so an expansion walks outermost-in and each level is one entry.
670
+ */
671
+ const SHORTHAND_LONGHANDS: Readonly<Record<string, readonly string[]>> = {
672
+ border: ['borderWidth', 'borderStyle', 'borderColor'],
673
+ borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'],
674
+ borderStyle: ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'],
675
+ borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'],
676
+ borderRadius: [
677
+ 'borderTopLeftRadius',
678
+ 'borderTopRightRadius',
679
+ 'borderBottomRightRadius',
680
+ 'borderBottomLeftRadius',
681
+ ],
682
+ padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
683
+ margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
684
+ inset: ['top', 'right', 'bottom', 'left'],
685
+ };
686
+
687
+ /** Longhand -> its immediate shorthand, derived from the table above so the
688
+ * two directions cannot disagree. */
689
+ const SHORTHAND_OF: ReadonlyMap<string, string> = new Map(
690
+ Object.entries(SHORTHAND_LONGHANDS).flatMap(([shorthand, longhands]) =>
691
+ longhands.map((longhand) => [longhand, shorthand] as const),
692
+ ),
693
+ );
694
+
695
+ /** The shorthands covering `prop`, OUTERMOST FIRST — `borderTopWidth` gives
696
+ * `['border', 'borderWidth']`. Empty for a property no shorthand owns. */
697
+ function shorthandChain(prop: string): readonly string[] {
698
+ const chain: string[] = [];
699
+ for (let cursor = SHORTHAND_OF.get(prop); cursor; cursor = SHORTHAND_OF.get(cursor)) {
700
+ chain.unshift(cursor);
701
+ }
702
+ return chain;
703
+ }
704
+ /** D20 — fallback id for callers that supply portable stories without the
705
+ * richer per-CSF-document projection. Story ids remain Storybook-owned. */
706
+ const PORTABLE_DOCUMENT_ID = 'portable-document';
707
+ /** Name of a portable document that has neither an explicit label nor a
708
+ * module path to derive a group path from. */
709
+ const PORTABLE_DOCUMENT_LABEL = 'React Preview';
710
+ /** `prop -> declared type`, so `inspector.get()` knows when to run a style
711
+ * value through {@link numericStyleValue} instead of handing it back raw
712
+ * (a real CSSOM length value is always a unit-suffixed string). */
713
+ const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
714
+ STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
715
+ );
716
+
717
+ /**
718
+ * U2 (spec 27 §5 C2) — a per-side/per-corner CSS LONGHAND → the SHORTHAND that
719
+ * governs it in the longhand's absence. Used by `inspector.remove` (below): when
720
+ * a uniform border/radius edit removes a stale longhand override, the removed
721
+ * longhand's optimistic echo is set to the shorthand's current (just-committed)
722
+ * value — so an `inspector.get(longhand)` right after removal reports the value
723
+ * the corner actually renders at (the shorthand), not a stale override or an
724
+ * empty computed read, and it stays consistent once HMR clears the echo (the
725
+ * longhand is gone from source, so the shorthand cascades to it). Purely the
726
+ * longhands the C2 combo rows can write — the shorthand set itself is uniform.
727
+ */
728
+ const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
729
+ borderTopLeftRadius: 'borderRadius',
730
+ borderTopRightRadius: 'borderRadius',
731
+ borderBottomLeftRadius: 'borderRadius',
732
+ borderBottomRightRadius: 'borderRadius',
733
+ borderTopWidth: 'borderWidth',
734
+ borderRightWidth: 'borderWidth',
735
+ borderBottomWidth: 'borderWidth',
736
+ borderLeftWidth: 'borderWidth',
737
+ borderTopStyle: 'borderStyle',
738
+ borderRightStyle: 'borderStyle',
739
+ borderBottomStyle: 'borderStyle',
740
+ borderLeftStyle: 'borderStyle',
741
+ borderTopColor: 'borderColor',
742
+ borderRightColor: 'borderColor',
743
+ borderBottomColor: 'borderColor',
744
+ borderLeftColor: 'borderColor',
745
+ };
746
+
747
+ /**
748
+ * D3.e (spec 27 §2 T0 leftover, §6 D3 "Insert-child submenu") — the kinds
749
+ * `structure.create`'s `wrapperTag` genuinely inserts. `insertChildElement`
750
+ * (`ui-source/writer.ts:1036`) writes `<tag />` VERBATIM for whatever tag
751
+ * string it is given — it has no allow-list of its own, and no void/non-void
752
+ * element distinction (every insert is self-closing, syntactically valid
753
+ * JSX for every one of these real HTML element names). So this is a CURATED
754
+ * subset, not a writer-enforced ceiling. A React root node's native kind is
755
+ * its literal HTML tag name.
756
+ */
757
+ const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
758
+ { kind: 'div', label: 'Container' },
759
+ { kind: 'span', label: 'Text' },
760
+ { kind: 'p', label: 'Paragraph' },
761
+ { kind: 'button', label: 'Button' },
762
+ { kind: 'a', label: 'Link' },
763
+ { kind: 'img', label: 'Image' },
764
+ { kind: 'ul', label: 'List' },
765
+ { kind: 'li', label: 'List Item' },
766
+ ];
767
+
768
+ const IMAGE_ASSET_RE = /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i;
769
+
770
+ type DomAssetDropPlan =
771
+ | {
772
+ ok: true;
773
+ parentId: string;
774
+ snippet: string;
775
+ ensureImport?: { name: string; module: string; kind: 'default' | 'named' };
776
+ }
777
+ | { ok: false; reason: string };
778
+
779
+ function serializedComponentValue(value: unknown, spec: ComponentPropSpec): string {
780
+ if (spec.type === 'number') return String(Number(value));
781
+ if (spec.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false';
782
+ if (spec.type === 'vec3' && Array.isArray(value)) {
783
+ return `[${value.map((entry) => Number(entry)).join(', ')}]`;
784
+ }
785
+ if (spec.type === 'json') return JSON.stringify(value);
786
+ return JSON.stringify(String(value));
787
+ }
788
+
789
+ export interface ReactRootAuthoringOptions {
790
+ /** Optional wider DOM root used only for atomic asset discovery. The
791
+ * hierarchy still walks the constructor root; a multi-story board can thus
792
+ * expose assets from every mounted frame without mixing their node trees. */
793
+ assetRoot?: OidElementLike | undefined;
794
+ /** T3.2 slice-3 write seam. Absent ⇒ no dev-server backend in this session (a
795
+ * hosted/browser build) — selection/inspection still work; writes report
796
+ * unavailable (see the class doc comment). */
797
+ writeBackend?: SourceWriteBackend | undefined;
798
+ /** Cap 1 (React visual-edit parity): resolves an element to its COMPUTED style so
799
+ * `inspector.get` reflects class- and CSS-file-styled properties, not just inline
800
+ * style. Defaults to `window.getComputedStyle` in a browser, or the element's inline
801
+ * `.style` under vitest's `node` env (so headless fixtures work). Injectable for
802
+ * headless tests that want to drive the real computed-value path. */
803
+ computedStyle?: ComputedStyleResolver | undefined;
804
+ /** Cap 2 (React visual-edit parity): resolves an element to the first-party CSS rules
805
+ * that match it (source file + selector + declared properties), so a style edit whose
806
+ * property lives in a CSS FILE routes to that file instead of inline/class. Defaults to
807
+ * `inspect.ts`'s `getMatchedCssRules` against the live document. Injectable for headless
808
+ * tests. */
809
+ matchedCssRules?: ((el: unknown) => CssRuleTarget[]) | undefined;
810
+ /** Cap 7 (React visual-edit parity): the document's design tokens (`:root` custom
811
+ * properties). Defaults to `inspect.ts`'s `getDesignTokens` against the live `:root`
812
+ * (empty under vitest's `node` env). Injectable for headless tests. */
813
+ designTokens?: (() => DesignToken[]) | undefined;
814
+ /** D20 — portable Storybook CSF stories associated with this native React
815
+ * root. They are the canonical provider exposed to the shell. */
816
+ portableStories?: ReadonlyArray<PortableStoryRef> | undefined;
817
+ /** Source documents projected above their own portable CSF stories. A
818
+ * document with no explicit `label` is named by its GROUP PATH — the one
819
+ * presentation-only grouping model (`stories/story-grouping.ts`): the
820
+ * CSF meta `title` when authored, else the module path relative to the
821
+ * project `src/`. Grouping is labels only here; ids, roles, kinds, and
822
+ * which stories a document owns are unchanged by it. */
823
+ portableDocuments?:
824
+ | ReadonlyArray<{
825
+ id: string;
826
+ label?: string | undefined;
827
+ path?: string | undefined;
828
+ /** Composed CSF meta title when the module authored one. */
829
+ title?: string | undefined;
830
+ storyIds: ReadonlyArray<string>;
831
+ }>
832
+ | undefined;
833
+ /** Native Storybook Category → Folder → Component index. When present,
834
+ * both the hierarchy and the board consume this same derived projection;
835
+ * CSF remains the only authored data. */
836
+ portableHierarchy?: StoryPresentationIndex | undefined;
837
+ /** Initially mounted portable story id (normally the explicit/default CSF
838
+ * story selected by the design-time layer). */
839
+ activePortableStoryId?: string | undefined;
840
+ /** Re-render hook for a portable CSF selection. The design-time layer owns
841
+ * Storybook mounting; this adapter owns only selection/provider routing. */
842
+ onPortableStoryApplied?: ((storyId: string | null) => void) | undefined;
843
+ /** Session-scoped Storybook Controls write. The design-time layer remounts
844
+ * the same composed story with these args; CSF remains the canonical
845
+ * definition and Reset restores its composed defaults. */
846
+ onPortableStoryArgsChanged?:
847
+ | ((storyId: string, args: Record<string, unknown>) => void)
848
+ | undefined;
849
+ /**
850
+ * Overrides the JSX-source provenance below. The one caller is play mode
851
+ * (`react-play-live-authoring.ts`), whose `writeBackend` writes the LIVE DOM
852
+ * rather than source — the badge/tooltip must say so, exactly as the adopted
853
+ * three scene and the live Pixi stage do. Absent ⇒ the source-code provenance
854
+ * every authoring session gets.
855
+ */
856
+ provenance?: AuthoringProvenance | undefined;
857
+ }
858
+
859
+ export interface PortableStoryRef extends StoryRef {
860
+ args?: Readonly<Record<string, unknown>>;
861
+ /** The CSF export name — what the story WRITE half (save-as/rename/delete)
862
+ * addresses in source. Optional so fixtures stay minimal; a ref without it
863
+ * simply cannot be written. */
864
+ name?: string;
865
+ /** Project-relative `*.stories.tsx` path — the write half's file. */
866
+ modulePath?: string;
867
+ }
868
+
869
+ /** The default: this adapter's own truth is the component's JSX source. */
870
+ const JSX_SOURCE_PROVENANCE: AuthoringProvenance = {
871
+ source: 'source-code',
872
+ label: 'jsx',
873
+ detail: 'The JSX source is the document — edits write back to the component source file.',
874
+ };
875
+
876
+ /** The element's literal class tokens, off the live DOM `class` attribute. */
877
+ function classTokensOf(el: unknown): string[] {
878
+ const e = el as { getAttribute?(name: string): string | null };
879
+ const raw = typeof e?.getAttribute === 'function' ? (e.getAttribute('class') ?? '') : '';
880
+ return raw.split(/\s+/).filter(Boolean);
881
+ }
882
+
883
+ /** Does the LIVE inline style declare this property? Deliberately the live
884
+ * read, not the source: a preview-patched inline value also wins the cascade,
885
+ * so the conservative answer keeps a class-routed write from landing a
886
+ * declaration that never paints. */
887
+ function liveInlineDeclares(el: unknown, prop: string): boolean {
888
+ const style = (el as { style?: { getPropertyValue?(name: string): string } }).style;
889
+ if (typeof style?.getPropertyValue !== 'function') return false;
890
+ return style.getPropertyValue(camelToKebab(prop)) !== '';
891
+ }
892
+
893
+ export class ReactRootAuthoringAdapter implements AuthoringAdapter {
894
+ readonly capabilities: AuthoringCapabilities;
895
+
896
+ /** Assigned in the constructor (an option may override it — see
897
+ * {@link ReactRootAuthoringOptions.provenance}), never re-assigned after. */
898
+ readonly provenance: AuthoringProvenance;
899
+ private readonly writeBackend: SourceWriteBackend | undefined;
900
+ /** The shared DOM projector under the OID identity. */
901
+ private readonly projector = new DomProjector(oidDomIdentity<OidElementLike>());
902
+ private hierarchySnapshotCache: OidTree | null = null;
903
+ private hierarchySnapshotClearQueued = false;
904
+ private readonly computedStyle: ComputedStyleResolver;
905
+ private readonly matchedCssRules: (el: unknown) => CssRuleTarget[];
906
+ private readonly designTokens: () => DesignToken[];
907
+ private readonly designTokenValue: (name: string) => string | undefined;
908
+ /** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
909
+ * SAME `/__ui-source/index` the OID store already serves — see `index()` on
910
+ * `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
911
+ * (honest degradation, not a second index). */
912
+ private oidIndex: Map<string, OidEntry> = new Map();
913
+ private dirty = false;
914
+ /**
915
+ * A4 (spec 27 §3) — the optimistic value echo. `inspector.set` writes the just-committed
916
+ * value here BEFORE its async source-write returns; `inspector.get` reads THROUGH it (a
917
+ * hit wins over the live-DOM walk), so an edited field shows the NEW value within one
918
+ * frame of commit instead of snapping back to the old value until the source-write + Vite
919
+ * HMR re-render land (that timing WAS the desync bug). Keyed by OID-signature entity id
920
+ * (which survives the react remount — see {@link walkOidTree}) → full inspector path →
921
+ * value. Cleared per-entry the instant a write is refused/coerced-away (so it can't go
922
+ * stale showing a value the source never took), and wholesale once HMR actually lands
923
+ * ({@link reconcileEchoAfterReload}) so the now-updated live DOM is authoritative again.
924
+ */
925
+ private readonly valueEcho = new Map<string, Map<string, unknown>>();
926
+ /**
927
+ * U4 (spec 27 §5 "Widgets: dynamic-expression read-only indicator") — the
928
+ * smallest honest version of the indicator: no new backend literality
929
+ * PROBE (that's real new `SourceWriteBackend` surface, deferred to Phase-E
930
+ * scale). Instead this surfaces the write path's EXISTING refusal signal —
931
+ * `writeStyleEntry`/`writePropEdit` already set `res.dynamic` when the
932
+ * backend refuses because the target is a dynamic `{expression}`, clearing
933
+ * the optimistic echo and warning. Once THAT has happened for a given
934
+ * `id|path`, `properties()` marks its descriptor `readonly: true` on every
935
+ * subsequent call, so the widget disables itself (`KindRowProps.disabled`)
936
+ * instead of silently re-offering an edit the source will refuse again.
937
+ * This is PREDICTIVE-AFTER-TOUCH, not predictive-BEFORE-touch (the field is
938
+ * still editable — and will visibly refuse once — the very first time);
939
+ * a pre-touch indicator needs the backend probe noted above. Session-scoped
940
+ * (never persisted) — cleared for an id on `dispose()`-adjacent resets only
941
+ * via normal adapter lifetime, same scope as `valueEcho`.
942
+ */
943
+ private readonly guardedPaths = new Map<string, string>();
944
+ /**
945
+ * D4 (spec27 §6 D4, layer-tree "lock" toggle) — SESSION-LOCAL node ids the
946
+ * layer tree/overlay has marked locked. Deliberately NOT persisted/written
947
+ * to source: a react
948
+ * component has no lock schema field, and authoring one with no
949
+ * runtime reader would violate this repo's "no described field without a
950
+ * consumer" rule (CLAUDE.md; the SAME reason `ui.ts` dropped its dead
951
+ * `locked` field). Mirrors `world-session-state.ts`'s per-world pick-lock.
952
+ * D4.R1 — now READ by `pickable.pick` below (a locked node is skipped by
953
+ * canvas click/marquee pick, matching the first-party three raycast's
954
+ * own locked-skip in `viewport-raycast.ts`) and by
955
+ * `RootSelectionOverlay.collectMarqueeCandidates` (via
956
+ * `inspector.get(id, 'locked')`, which reads this Set — see the
957
+ * `inspector.get`/`set` `'locked'` case below) for the marquee pool.
958
+ * Deliberately NOT consulted by `hierarchy`/`selection.set` — a locked
959
+ * node stays selectable/unlockable from the layer tree, mirroring the
960
+ * first-party adapter's own "locked blocks the raycast, not the
961
+ * hierarchy" behavior. Cleared only by adapter disposal (new Set per
962
+ * adapter instance/mount).
963
+ */
964
+ private readonly lockedIds = new Set<string>();
965
+ private readonly assetRoot: OidElementLike;
966
+ /**
967
+ * D3.R4 (reopen fix), widened by D3.R5 — a COUNTER (not a boolean) of source writes
968
+ * whose own pre-HMR `notifyIngestEdit()` carries no fresh DOM shape, still awaiting
969
+ * their post-HMR "reload landed" reconcile. Incremented by every SUCCESSFUL write on
970
+ * a path with no `valueEcho` of its own: structural writes and `editText`.
971
+ * A successful text write flips `hasText`, itself a
972
+ * `findEmptyContainers` hint-eligibility criterion — text populates no `valueEcho`,
973
+ * so neither existing reconcile branch fired for it).
974
+ *
975
+ * A structural/text op's own `notifyIngestEdit()` (right after the write response
976
+ * lands) fires BEFORE the HMR remount that actually changes the DOM — so a memoized
977
+ * hover-render cache keyed on that notify's `storeVersion`
978
+ * (`RootSelectionOverlay`'s `emptyHintsCacheRef`) pins the PRE-HMR tree shape.
979
+ * `reconcileEchoAfterReload` is the "HMR actually landed" signal; it used to no-op
980
+ * whenever `valueEcho` was empty — true for every one of the sites above, since only
981
+ * A4's value-echo path (style/prop) ever populates it.
982
+ *
983
+ * COUNTER, not boolean (the D3.R5 shape): a boolean cleared unconditionally on the
984
+ * FIRST reconcile after it was set, so two rapid writes each of whose OWN HMR fires a
985
+ * separate `vite:afterUpdate` would reconcile once and then no-op on the second
986
+ * reload-landed signal — the second write's hint delta going stale forever (a
987
+ * residual the boolean shape left undocumented). Each successful qualifying write
988
+ * increments this counter; `reconcileEchoAfterReload` notifies and decrements by
989
+ * exactly one whenever it is above zero (in addition to notifying whenever `valueEcho`
990
+ * is non-empty), so N pending writes need N reconciles to fully drain — matching N
991
+ * real `vite:afterUpdate` events in production.
992
+ */
993
+ private pendingSourceReconcile = 0;
994
+ /**
995
+ * The last non-empty selection this adapter's tree served — the input to
996
+ * {@link repairSelectionAfterReprojection}.
997
+ */
998
+ private lastResolvedSelection: readonly string[] = [];
999
+ /** One repair watcher per re-projection; see {@link scheduleSelectionRepair}. */
1000
+ private selectionRepairWatching = false;
1001
+ /** A4 — unsubscribes this adapter's `vite:afterUpdate` reconcile hook (see the constructor);
1002
+ * `undefined` when there is no HMR context. */
1003
+ private readonly disposeReloadSignal: (() => void) | undefined;
1004
+ // Mutable on purpose: `writeStory` keeps this list truthful about the file
1005
+ // it just rewrote (rename updates the addressed entry, delete drops it) —
1006
+ // see the blind-walk finding in that method.
1007
+ private readonly portableStories: PortableStoryRef[];
1008
+ private readonly portableStoryArgs = new Map<string, Record<string, unknown>>();
1009
+ private readonly portableDocuments: ReadonlyArray<{
1010
+ id: string;
1011
+ label: string;
1012
+ path?: string | undefined;
1013
+ storyIds: ReadonlyArray<string>;
1014
+ }>;
1015
+ private readonly portableHierarchy: StoryPresentationIndex | null;
1016
+ private readonly portableDefaultStoryId: string | null;
1017
+ private readonly onPortableStoryApplied: ((storyId: string | null) => void) | undefined;
1018
+ private readonly onPortableStoryArgsChanged:
1019
+ | ((storyId: string, args: Record<string, unknown>) => void)
1020
+ | undefined;
1021
+ /** The currently applied portable story, or `null` when this root has no
1022
+ * portable CSF document. */
1023
+ private activeStoryId: string | null = null;
1024
+ /**
1025
+ * T0 (spec 27 §4, B1) — the currently-open `boxEdit` begin/apply×N/end gesture (a
1026
+ * drag), or `null` between gestures. `touched` maps the RESOLVED CSS prop (already
1027
+ * patch-key-mapped, e.g. `x` → `left`) to the FINAL value `end` should commit — a
1028
+ * `Map` so the last `apply` in the gesture wins, matching an in-flight drag's most
1029
+ * recent pointer position. `priorInline` captures each touched prop's ORIGINAL inline
1030
+ * value, LAZILY on the prop's first `apply` in this gesture (i.e. BEFORE `apply`
1031
+ * mutates the live style) — `apply` writes the live DOM directly for zero-latency
1032
+ * preview, which would otherwise corrupt `writeStyleEntry`'s own prior-value capture
1033
+ * (it reads `n.el.style` fresh, and by `end` time that already holds the LAST applied
1034
+ * preview value, not the true pre-gesture one) — this is why `writeStyleEntry` takes
1035
+ * an explicit override rather than re-deriving `prev` itself for a box-edit commit.
1036
+ */
1037
+ private boxEditSession: {
1038
+ id: string;
1039
+ touched: Map<string, string | number>;
1040
+ priorInline: Map<string, string>;
1041
+ /** The one hint this gesture already showed for a move it cannot write. */
1042
+ hinted?: boolean;
1043
+ } | null = null;
1044
+
1045
+ /**
1046
+ * Serializes {@link commitBoxEdit} runs so two gestures never overlap.
1047
+ *
1048
+ * `boxEdit.end()` fire-and-forgets an ASYNC commit (it cannot await — the
1049
+ * gesture API is synchronous, driven straight from pointer/key handlers), and
1050
+ * `editor-hotkeys.ts` drives `apply()`+`end()` on EVERY keypress. Hold an
1051
+ * arrow key, or align a multi-selection, and the second commit calls
1052
+ * a second source-history gesture while the first is still awaiting its
1053
+ * writes. Chaining here keeps adapter preview/echo updates ordered too; the
1054
+ * scoped history backend independently serializes each complete callback.
1055
+ */
1056
+ private boxEditTail: Promise<void> = Promise.resolve();
1057
+
1058
+ constructor(
1059
+ private readonly root: OidElementLike,
1060
+ private readonly store: EditorShellStore,
1061
+ opts: ReactRootAuthoringOptions = {},
1062
+ ) {
1063
+ this.assetRoot = opts.assetRoot ?? root;
1064
+ this.provenance = opts.provenance ?? JSX_SOURCE_PROVENANCE;
1065
+ this.writeBackend = withProjectSourceHistory(opts.writeBackend, store.projectHistory);
1066
+ this.computedStyle = opts.computedStyle ?? browserOrInlineResolver;
1067
+ this.matchedCssRules =
1068
+ opts.matchedCssRules ??
1069
+ ((el) => getMatchedCssRules(el as MatchableElement) as CssRuleTarget[]);
1070
+ // Game CSS is SCOPED (`@scope ([data-vgai-game-styles])`, measured:
1071
+ // a project stylesheet's `:root { --x }` never reaches the editor page's
1072
+ // root), so the document root sees no game token — the adapter's OWN
1073
+ // mounted root is inside the scope and inherits them all. Fall back to
1074
+ // the page root for a fixture root that is not a real element.
1075
+ const tokenStyle = () => {
1076
+ const root = this.root as unknown as Element;
1077
+ // Tokens inherit DOWNWARD from each game-CSS scope root
1078
+ // (`[data-vgai-game-styles]`), which sits BELOW this adapter's layer —
1079
+ // so read the first scope root's computed style, not the layer's
1080
+ // (measured: the layer sees none of the game's custom properties).
1081
+ const el =
1082
+ typeof Element !== 'undefined' && root instanceof Element
1083
+ ? (root.querySelector('[data-vgai-game-styles]') ??
1084
+ root.closest?.('[data-vgai-game-styles]') ??
1085
+ root)
1086
+ : typeof document !== 'undefined'
1087
+ ? document.documentElement
1088
+ : null;
1089
+ const computed =
1090
+ typeof getComputedStyle === 'function' && el instanceof Element
1091
+ ? (getComputedStyle(el) as unknown as Parameters<typeof getDesignTokens>[0])
1092
+ : undefined;
1093
+ return computed;
1094
+ };
1095
+ this.designTokens = opts.designTokens ?? (() => getDesignTokens(tokenStyle()));
1096
+ // A field read is ONE computed custom property. Re-enumerating and tracing
1097
+ // the entire token catalog for each field made coverage/selection quadratic
1098
+ // (3,324 reads cost 6.6s under Code-OSS). Read live so same-turn CSS changes
1099
+ // remain visible; this is not a stale-value cache.
1100
+ this.designTokenValue = opts.designTokens
1101
+ ? (name) => opts.designTokens!().find((token) => token.name === name)?.value
1102
+ : (name) => tokenStyle()?.getPropertyValue(name).trim() || undefined;
1103
+ this.portableStories = [...(opts.portableStories ?? [])];
1104
+ for (const story of this.portableStories) {
1105
+ this.portableStoryArgs.set(story.id, { ...(story.args ?? {}) });
1106
+ }
1107
+ const declaredPortableDocuments =
1108
+ opts.portableDocuments ??
1109
+ (this.portableStories.length > 0
1110
+ ? [
1111
+ {
1112
+ id: PORTABLE_DOCUMENT_ID,
1113
+ label: PORTABLE_DOCUMENT_LABEL,
1114
+ storyIds: this.portableStories.map((story) => story.id),
1115
+ },
1116
+ ]
1117
+ : []);
1118
+ const knownStoryIds = new Set(this.portableStories.map((story) => story.id));
1119
+ const claimedStoryIds = new Set<string>();
1120
+ const documentIds = new Set<string>();
1121
+ const portableDocuments = declaredPortableDocuments.flatMap((document) => {
1122
+ if (documentIds.has(document.id)) return [];
1123
+ documentIds.add(document.id);
1124
+ const storyIds = document.storyIds.filter((storyId) => {
1125
+ if (!knownStoryIds.has(storyId) || claimedStoryIds.has(storyId)) return false;
1126
+ claimedStoryIds.add(storyId);
1127
+ return true;
1128
+ });
1129
+ if (storyIds.length === 0) return [];
1130
+ return [
1131
+ {
1132
+ id: document.id,
1133
+ // The group path IS the document's name when the caller doesn't
1134
+ // impose one — an authored CSF title, else the module's own path
1135
+ // under the project `src/` (which degrades to the per-module
1136
+ // grouping this hierarchy already had).
1137
+ label:
1138
+ document.label ??
1139
+ (document.path
1140
+ ? formatStoryGroupPath(
1141
+ deriveStoryGroupPath({ modulePath: document.path, title: document.title }),
1142
+ )
1143
+ : PORTABLE_DOCUMENT_LABEL),
1144
+ ...(document.path ? { path: document.path } : {}),
1145
+ storyIds,
1146
+ },
1147
+ ];
1148
+ });
1149
+ const unassignedStoryIds = this.portableStories
1150
+ .map((story) => story.id)
1151
+ .filter((storyId) => !claimedStoryIds.has(storyId));
1152
+ if (unassignedStoryIds.length > 0) {
1153
+ portableDocuments.push({
1154
+ id: documentIds.has(PORTABLE_DOCUMENT_ID)
1155
+ ? `${PORTABLE_DOCUMENT_ID}:unassigned`
1156
+ : PORTABLE_DOCUMENT_ID,
1157
+ label: PORTABLE_DOCUMENT_LABEL,
1158
+ storyIds: unassignedStoryIds,
1159
+ });
1160
+ }
1161
+ this.portableDocuments = portableDocuments;
1162
+ this.portableHierarchy = opts.portableHierarchy ?? null;
1163
+ this.portableDefaultStoryId = this.portableStories.some(
1164
+ (story) => story.id === opts.activePortableStoryId,
1165
+ )
1166
+ ? (opts.activePortableStoryId ?? null)
1167
+ : (this.portableStories[0]?.id ?? null);
1168
+ this.onPortableStoryApplied = opts.onPortableStoryApplied;
1169
+ this.onPortableStoryArgsChanged = opts.onPortableStoryArgsChanged;
1170
+ this.activeStoryId = this.portableDefaultStoryId;
1171
+ this.capabilities = {
1172
+ transform: false, // no 3D gizmo — DOM has no Object3D pose (§1.F)
1173
+ inspectorFields: true,
1174
+ persist: true,
1175
+ };
1176
+ // A4 (spec 27 §3) — re-sync the optimistic echo once HMR has actually re-rendered the
1177
+ // react tree. Vite fires `vite:afterUpdate` after it applies an HMR update — here, the
1178
+ // dev-server source-write this adapter triggered (→ file watcher → react-refresh
1179
+ // re-render) — so that event IS the "reload landed" signal (the adapter otherwise never
1180
+ // learns about reloads; play-mode.ts owns the component-class HMR handler, not this
1181
+ // per-world adapter). Absent under a hosted/no-dev-server build and under vitest's `node`
1182
+ // env (no `import.meta.hot`), where the unit test drives `reconcileEchoAfterReload()`
1183
+ // directly to simulate a landed reload.
1184
+ const hot = import.meta.hot;
1185
+ if (hot) {
1186
+ const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
1187
+ hot.on('vite:afterUpdate', onAfterUpdate);
1188
+ this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
1189
+ } else {
1190
+ // THE HOSTED TIER'S "RELOAD LANDED" SIGNAL. No HMR here: a source write
1191
+ // re-projects this root when the story registry re-publishes (the
1192
+ // content-write refresh in `project-story-discovery.ts` reloads the
1193
+ // story modules the write reached and the board re-renders its
1194
+ // frames). That re-projection drops the selection exactly like the
1195
+ // HMR one, and with no signal the repair above never ran — measured on
1196
+ // production build 57: drag a HUD element on the board, the write
1197
+ // lands, and the selection box, hierarchy row and Inspector all go
1198
+ // empty until the author clicks again. The registry's publish is the
1199
+ // moment to watch.
1200
+ const unsubscribe = subscribeProjectStoryModules(() => this.reconcileEchoAfterReload());
1201
+ this.disposeReloadSignal = unsubscribe;
1202
+ }
1203
+ if (this.writeBackend?.index) {
1204
+ this.writeBackend
1205
+ .index()
1206
+ .then((idx) => {
1207
+ this.oidIndex = new Map(Object.entries(idx));
1208
+ this.store.notifyIngestEdit();
1209
+ })
1210
+ .catch(() => {
1211
+ // Honest degradation: labels stay tag-only if the index can't be fetched.
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ private snapshot(): OidTree {
1217
+ this.projector.project(this.root);
1218
+ return oidTreeFromProjection({
1219
+ nodes: this.projector.nodes,
1220
+ rootIds: [...this.projector.rootIds],
1221
+ });
1222
+ }
1223
+
1224
+ /**
1225
+ * One coherent tree for a synchronous hierarchy consumer. Composite roots
1226
+ * walk every child adapter by calling `node(id)` once per row; projecting the
1227
+ * complete DOM on every lookup turns that ordinary walk into O(rows²).
1228
+ * Inspector and write paths keep using {@link snapshot} directly, so only
1229
+ * hierarchy reads share this task-bounded view and every later task sees the
1230
+ * current DOM.
1231
+ */
1232
+ private hierarchySnapshot(): OidTree {
1233
+ this.hierarchySnapshotCache ??= this.snapshot();
1234
+ if (!this.hierarchySnapshotClearQueued) {
1235
+ this.hierarchySnapshotClearQueued = true;
1236
+ queueMicrotask(() => {
1237
+ this.hierarchySnapshotCache = null;
1238
+ this.hierarchySnapshotClearQueued = false;
1239
+ });
1240
+ }
1241
+ return this.hierarchySnapshotCache;
1242
+ }
1243
+
1244
+ private async refreshOidIndex(): Promise<void> {
1245
+ const index = await this.writeBackend?.index?.();
1246
+ if (!index) return;
1247
+ this.oidIndex = new Map(Object.entries(index));
1248
+ this.store.notifyIngestEdit();
1249
+ }
1250
+
1251
+ // --- A4: optimistic value echo (see {@link valueEcho}) ---
1252
+
1253
+ /** Record the value just committed by `inspector.set`, so `inspector.get(id, path)`
1254
+ * returns it immediately (before the async source-write + HMR land). */
1255
+ private setEcho(id: string, path: string, value: unknown): void {
1256
+ let byPath = this.valueEcho.get(id);
1257
+ if (!byPath) {
1258
+ byPath = new Map();
1259
+ this.valueEcho.set(id, byPath);
1260
+ }
1261
+ byPath.set(path, value);
1262
+ }
1263
+
1264
+ /** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
1265
+ * reverts to the live-DOM (unchanged) value rather than sticking on a value the source
1266
+ * never took (the "stale in the other direction" guard for rejected writes). */
1267
+ private clearEcho(id: string, path: string): void {
1268
+ const byPath = this.valueEcho.get(id);
1269
+ if (!byPath) return;
1270
+ byPath.delete(path);
1271
+ if (byPath.size === 0) this.valueEcho.delete(id);
1272
+ }
1273
+
1274
+ /** U4 — record that `id`'s `path` was REFUSED BY A SOURCE GUARD, with the
1275
+ * guard's own sentence, so the NEXT `properties()` call marks its descriptor
1276
+ * `readonly` and shows that sentence. The reason is carried rather than
1277
+ * re-derived because the guards differ: a dynamic `{expression}` and a
1278
+ * `var(--token)` reference are both un-overwritable literals to the writer
1279
+ * and completely different facts to the author. */
1280
+ private markGuarded(id: string, path: string, reason: string): void {
1281
+ this.guardedPaths.set(`${id}|${path}`, reason);
1282
+ }
1283
+
1284
+ /** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
1285
+ * echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
1286
+ * distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
1287
+ * still wins over the live-DOM walk. */
1288
+ private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
1289
+ const byPath = this.valueEcho.get(id);
1290
+ if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
1291
+ return { hit: false, value: undefined };
1292
+ }
1293
+
1294
+ /**
1295
+ * A4 — the "HMR reload landed" reconcile (fired by the constructor's `vite:afterUpdate`
1296
+ * hook, or called directly to simulate a landed reload). The echo held the freshly-set
1297
+ * values while the async source-write + HMR re-render were in flight; once HMR has
1298
+ * re-rendered the react tree the LIVE DOM is authoritative again — including any
1299
+ * server-side value coercion — so drop the whole echo and notify, and every open inspector
1300
+ * re-reads the now-updated DOM. (An unrelated module's `afterUpdate` that fires before THIS
1301
+ * edit's HMR is a benign race: the field momentarily re-reads the old DOM, then this
1302
+ * edit's own `afterUpdate` reconciles it — the echo self-heals on the next tick.)
1303
+ *
1304
+ * SELECTION IS NOT UNTOUCHED, and this comment used to say it was. The ids ARE
1305
+ * OID-signature ids in the store, re-resolvable against the remounted tree — but
1306
+ * MEASURED 2026-08-30 the remount empties the edit tab's selection set anyway, ~100-150ms
1307
+ * after the update lands, for a remount from ANY source (a hand edit of the file does it
1308
+ * with no inspector write involved). {@link scheduleSelectionRepair} is the repair, and
1309
+ * its doc comment carries the measurement and what is still unknown about the cause.
1310
+ *
1311
+ * D3.R4 (reopen fix), widened by D3.R5 — ALSO the "landed" signal for every pending
1312
+ * source write counted by {@link pendingSourceReconcile} (see its doc comment for the
1313
+ * full site list and the counter-vs-boolean rationale): each of those writes' own
1314
+ * `notifyIngestEdit()` fires before the HMR remount lands, so it never carries fresh
1315
+ * DOM shape on its own; this reconcile is what does, once the remount has actually
1316
+ * happened. Decrements the counter by exactly one per call (never resets it to zero)
1317
+ * so N pending writes drain over N reconciles, each one notifying — not just the
1318
+ * first.
1319
+ */
1320
+ reconcileEchoAfterReload(): void {
1321
+ // BEFORE the early return: a re-projection triggered by a source edit this
1322
+ // adapter did NOT make (a hand edit, another lane's write) has neither an
1323
+ // echo nor a pending reconcile, and it drops the selection exactly the
1324
+ // same way.
1325
+ this.scheduleSelectionRepair();
1326
+ if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
1327
+ this.valueEcho.clear();
1328
+ if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
1329
+ this.store.notifyIngestEdit();
1330
+ }
1331
+
1332
+ /**
1333
+ * WATCH ONE RE-PROJECTION for the selection it drops, and put it back.
1334
+ *
1335
+ * MEASURED 2026-08-30, on a scaffolded project's dom root: any HMR remount
1336
+ * of the project's source empties the edit tab's selection set ~100-150ms
1337
+ * after the update lands — including a remount triggered by APPENDING A
1338
+ * COMMENT to the file, with no inspector write anywhere in the picture. The
1339
+ * ids themselves are stable across the remount (the same oid resolves
1340
+ * before and after), so nothing about the selection was invalidated; it was
1341
+ * simply lost. The reported symptom — a second inspector write "degrading"
1342
+ * to `live-only (not saved)`, or throwing "No Inspector subject is active."
1343
+ * — is that loss arriving at the write door.
1344
+ *
1345
+ * Which line empties the set is NOT yet pinned: `select(null)`,
1346
+ * `selectMultiple([])` and `applySelectionBeforePresentation([])` were each
1347
+ * traced live through a reproduction and NONE of them fires. So this repairs
1348
+ * the observable rather than the cause, and says so out loud when it does —
1349
+ * a selection that vanishes under an author's cursor is not something to fix
1350
+ * quietly.
1351
+ *
1352
+ * It is deliberately NOT a write QUEUE. Queueing the second write behind the
1353
+ * re-projection would not help: the selection is already gone when that
1354
+ * write is issued, so a queued write resolves against the same empty
1355
+ * subject. The seam that needs repairing is the selection, not the ordering.
1356
+ *
1357
+ * SELF-LIMITING, three ways: one watcher per re-projection, a bounded window
1358
+ * (the measured loss lands inside it), and a repair that only restores ids
1359
+ * which STILL RESOLVE in the remounted tree — so it can never fabricate a
1360
+ * selection, and never fights a legitimate deselect that happened outside
1361
+ * the window.
1362
+ */
1363
+ private scheduleSelectionRepair(): void {
1364
+ if (this.selectionRepairWatching) return;
1365
+ if (this.lastResolvedSelection.length === 0) return;
1366
+ this.selectionRepairWatching = true;
1367
+ let attempts = 0;
1368
+ let reported = false;
1369
+ const tick = (): void => {
1370
+ attempts++;
1371
+ // The watcher runs the WHOLE window rather than stopping at the first
1372
+ // repair: one edit can produce several update events, and a repair
1373
+ // followed by a second remount that drops it again is the shape the
1374
+ // measurement showed. It reports once per watch, so a repeat is a
1375
+ // restore, not a second wall of console.
1376
+ reported = this.repairSelectionAfterReprojection(reported) || reported;
1377
+ if (attempts >= SELECTION_REPAIR_ATTEMPTS) {
1378
+ this.selectionRepairWatching = false;
1379
+ return;
1380
+ }
1381
+ setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
1382
+ };
1383
+ setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
1384
+ }
1385
+
1386
+ /**
1387
+ * One repair attempt. Returns whether it SAID something, so the watcher can
1388
+ * report once and restore as many times as the re-projection needs.
1389
+ *
1390
+ * The remembered set is NOT consumed: `selection.get` overwrites it with
1391
+ * every non-empty selection it serves, so it always names the last selection
1392
+ * the author actually had, and a second drop inside the same window is
1393
+ * repaired from the same truth.
1394
+ */
1395
+ private repairSelectionAfterReprojection(alreadyReported: boolean): boolean {
1396
+ const remembered = this.lastResolvedSelection;
1397
+ if (remembered.length === 0) return false;
1398
+ if (this.store.selectedEntityIds.size > 0) return false; // not lost (yet)
1399
+ const tree = this.snapshot();
1400
+ const alive = remembered.filter((id) => tree.nodes.has(id));
1401
+ if (alive.length === 0) {
1402
+ if (!alreadyReported) {
1403
+ console.warn(
1404
+ '[ReactRootAuthoringAdapter] the re-projection dropped the selection ' +
1405
+ `(${remembered.join(', ')}) and none of those ids resolve in the remounted tree, so ` +
1406
+ 'it cannot be restored. An inspector write issued now has no subject — re-select.',
1407
+ );
1408
+ }
1409
+ this.lastResolvedSelection = [];
1410
+ return true;
1411
+ }
1412
+ if (!alreadyReported && !reportedSelectionRepair) {
1413
+ reportedSelectionRepair = true;
1414
+ console.warn(
1415
+ '[ReactRootAuthoringAdapter] OPEN DEFECT, repaired: an HMR re-projection drops this ' +
1416
+ "root's selection, and the editor is putting it back — restoring " +
1417
+ `${alive.length} of ${remembered.length} id(s) that still resolve in the remounted ` +
1418
+ `tree (${alive.join(', ')}). A write issued in that window has no subject. Named once ` +
1419
+ 'per session; the repair runs on every re-projection.',
1420
+ );
1421
+ }
1422
+ this.store.selectMultiple([...alive]);
1423
+ return true;
1424
+ }
1425
+
1426
+ /** A4 — release the `vite:afterUpdate` reconcile subscription. Idempotent; safe when no
1427
+ * HMR context was present (the disposer is `undefined`). */
1428
+ disposeReactRootAdapter(): void {
1429
+ this.disposeReloadSignal?.();
1430
+ }
1431
+
1432
+ private componentName(n: OidNode): string | null {
1433
+ const entry = this.oidIndex.get(n.oid);
1434
+ return entry?.component ?? getReactComponentName(n.el) ?? null;
1435
+ }
1436
+
1437
+ private elementLabel(n: OidNode, tree: OidTree): string {
1438
+ // Preserve the explicit escape hatch, but make ordinary DOM semantics
1439
+ // sufficient by default. Authored React should not need component splits
1440
+ // or `data-vgai-name` merely to produce a legible hierarchy.
1441
+ const editorLabel = normalizeElementText(n.el.getAttribute('data-vgai-name'));
1442
+ if (editorLabel) return editorLabel;
1443
+
1444
+ const labelledBy = n.el.getAttribute('aria-labelledby');
1445
+ if (labelledBy) {
1446
+ const labels = labelledBy
1447
+ .split(/\s+/)
1448
+ .map((id) =>
1449
+ Array.from(tree.nodes.values()).find(
1450
+ (candidate) => candidate.el.getAttribute('id') === id,
1451
+ ),
1452
+ )
1453
+ .map((candidate) => normalizeElementText(candidate?.el.textContent))
1454
+ .filter((label): label is string => label !== null);
1455
+ const combinedLabel = normalizeElementText(labels.join(' '));
1456
+ if (combinedLabel) return combinedLabel;
1457
+ }
1458
+
1459
+ const accessibleLabel = normalizeElementText(n.el.getAttribute('aria-label'));
1460
+ if (accessibleLabel) return accessibleLabel;
1461
+
1462
+ // A semantic section without explicit ARIA normally takes its identity
1463
+ // from its heading. Restrict this to direct children so a large nested
1464
+ // subtree cannot accidentally lend an unrelated heading to its parent.
1465
+ if (['section', 'article', 'aside', 'nav', 'main', 'header', 'footer'].includes(n.tag)) {
1466
+ const heading = n.childIds
1467
+ .map((id) => tree.nodes.get(id))
1468
+ .find((child) => child !== undefined && /^h[1-6]$/.test(child.tag));
1469
+ const headingText = normalizeElementText(heading?.el.textContent);
1470
+ if (headingText) return headingText;
1471
+ }
1472
+
1473
+ // Text-bearing HTML elements are already named by their content in the
1474
+ // browser/accessibility model. Use the same identity, with a short cap so
1475
+ // a paragraph cannot turn into an unbounded hierarchy row.
1476
+ if (
1477
+ /^(h[1-6]|p|span|label|button|a|time|output|dt|dd|li|legend|caption|summary)$/.test(n.tag)
1478
+ ) {
1479
+ const text = normalizeElementText(n.el.textContent);
1480
+ if (text) return text;
1481
+ }
1482
+
1483
+ for (const attribute of ['alt', 'title', 'placeholder', 'name']) {
1484
+ const value = normalizeElementText(n.el.getAttribute(attribute));
1485
+ if (value) return value;
1486
+ }
1487
+
1488
+ const id = n.el.getAttribute('id');
1489
+ if (id) return `#${id}`;
1490
+ // A styled anonymous element names itself by its first class — the same
1491
+ // identity a stylesheet addresses it by. `.reticle-dot` beats a fifth
1492
+ // bare "span" row (blind-walk beat 2: finding the crosshair's dot meant
1493
+ // clicking five identical rows and reading W/H off each).
1494
+ const firstClass = (n.el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0];
1495
+ if (firstClass) return `${n.tag}.${firstClass}`;
1496
+ return n.tag;
1497
+ }
1498
+
1499
+ private toEditorNode(n: OidNode, tree: OidTree = this.snapshot()): EditorNode {
1500
+ // A component name marks only the boundary where that component enters the
1501
+ // native tree. Descendants owned by the same component use their own DOM
1502
+ // identity, avoiding the old `Game2048Page:div` repeated on every row.
1503
+ const component = this.componentName(n);
1504
+ const parent = n.parentId ? tree.nodes.get(n.parentId) : null;
1505
+ const parentComponent = parent ? this.componentName(parent) : null;
1506
+ const isComponentBoundary = component !== null && component !== parentComponent;
1507
+ const crossSurfaceId = n.el.getAttribute('data-vgai-hierarchy-id');
1508
+ const crossSurfaceParentId = n.el.getAttribute('data-vgai-hierarchy-parent-id');
1509
+ const crossSurfaceOrderAttribute = n.el.getAttribute('data-vgai-hierarchy-order');
1510
+ const crossSurfaceGroupLabel = n.el.getAttribute('data-vgai-hierarchy-group-label');
1511
+ const crossSurfaceOrder =
1512
+ crossSurfaceOrderAttribute === null ? undefined : Number(crossSurfaceOrderAttribute);
1513
+ const node: EditorNode = {
1514
+ id: n.id,
1515
+ label: isComponentBoundary ? component : this.elementLabel(n, tree),
1516
+ ...(isComponentBoundary ? { secondaryLabel: `<${n.tag}>` } : {}),
1517
+ role: isComponentBoundary ? 'component' : 'element',
1518
+ kind: n.tag,
1519
+ parentId: n.parentId,
1520
+ childIds: n.childIds,
1521
+ ...(crossSurfaceId === null ? {} : { crossSurfaceId }),
1522
+ ...(crossSurfaceParentId === null ? {} : { crossSurfaceParentId }),
1523
+ ...(crossSurfaceOrder === undefined || !Number.isInteger(crossSurfaceOrder)
1524
+ ? {}
1525
+ : { crossSurfaceOrder }),
1526
+ ...(crossSurfaceGroupLabel === null ? {} : { crossSurfaceGroupLabel }),
1527
+ };
1528
+ if (this.portableStories.length > 0 && node.parentId === null) {
1529
+ return { ...node, parentId: this.activeStoryId };
1530
+ }
1531
+ return node;
1532
+ }
1533
+
1534
+ /** Exact DOM-native input signature for the composite's semantic join.
1535
+ * Shell notifications unrelated to this root leave it unchanged. The
1536
+ * nearest semantic ancestor and traversal order are included because
1537
+ * transparent DOM wrappers fold between explicitly identified rows. */
1538
+ private crossSurfaceStructureSignature(): string | null {
1539
+ const tree = this.hierarchySnapshot();
1540
+ const semanticIds = new Set<string>();
1541
+ for (const node of tree.nodes.values()) {
1542
+ if (
1543
+ node.el.getAttribute('data-vgai-hierarchy-id') !== null ||
1544
+ node.el.getAttribute('data-vgai-hierarchy-parent-id') !== null ||
1545
+ node.el.getAttribute('data-vgai-hierarchy-order') !== null ||
1546
+ node.el.getAttribute('data-vgai-hierarchy-group-label') !== null
1547
+ ) {
1548
+ semanticIds.add(node.id);
1549
+ }
1550
+ }
1551
+ if (semanticIds.size === 0) return null;
1552
+ return JSON.stringify(
1553
+ [...tree.nodes.values()].flatMap((node) => {
1554
+ if (!semanticIds.has(node.id)) return [];
1555
+ let parentId = node.parentId;
1556
+ while (parentId !== null && !semanticIds.has(parentId)) {
1557
+ parentId = tree.nodes.get(parentId)?.parentId ?? null;
1558
+ }
1559
+ return [
1560
+ [
1561
+ node.id,
1562
+ parentId,
1563
+ node.el.getAttribute('data-vgai-hierarchy-id'),
1564
+ node.el.getAttribute('data-vgai-hierarchy-parent-id'),
1565
+ node.el.getAttribute('data-vgai-hierarchy-order'),
1566
+ node.el.getAttribute('data-vgai-hierarchy-group-label'),
1567
+ ],
1568
+ ];
1569
+ }),
1570
+ );
1571
+ }
1572
+
1573
+ private portableDocumentNode(document: (typeof this.portableDocuments)[number]): EditorNode {
1574
+ return {
1575
+ id: document.id,
1576
+ label: document.label,
1577
+ ...(document.path ? { secondaryLabel: document.path } : {}),
1578
+ role: 'document',
1579
+ kind: 'tsx',
1580
+ parentId: null,
1581
+ childIds: [...document.storyIds],
1582
+ };
1583
+ }
1584
+
1585
+ private portableDocumentForStory(storyId: string) {
1586
+ return this.portableDocuments.find((document) => document.storyIds.includes(storyId)) ?? null;
1587
+ }
1588
+
1589
+ private portableHierarchyNode(id: string) {
1590
+ return this.portableHierarchy?.nodes.find((node) => node.id === id) ?? null;
1591
+ }
1592
+
1593
+ private portableStoriesForNode(nodeId: string): StoryRef[] {
1594
+ // The empty id is NOT a node — it is the world-level question
1595
+ // ({@link WORLD_SCOPE_NODE_ID}), and this provider's answer to it is its
1596
+ // whole list, because its WRITES are world-level: `active`/`apply` ignore
1597
+ // the node id, so applying any story remounts the whole react root. Left to
1598
+ // the per-node path below it fell into the "unrecognized node" branch and
1599
+ // answered whatever the ACTIVE story's component owns — a scope answer that
1600
+ // changed with UI state, so the shell's probe classified this adapter
1601
+ // differently depending on what was mounted.
1602
+ if (nodeId === WORLD_SCOPE_NODE_ID) return [...this.portableStories];
1603
+ if (!this.portableHierarchy) return [...this.portableStories];
1604
+ const hierarchyNode = this.portableHierarchyNode(nodeId);
1605
+ if (hierarchyNode && hierarchyNode.role !== 'component') return [];
1606
+ const componentId =
1607
+ hierarchyNode?.role === 'component'
1608
+ ? hierarchyNode.id
1609
+ : (this.portableHierarchy.storyParentIds.get(nodeId) ??
1610
+ this.portableHierarchy.storyParentIds.get(this.activeStoryId ?? ''));
1611
+ if (!componentId) return [];
1612
+ const component = this.portableHierarchyNode(componentId);
1613
+ const storyIds = new Set(component?.childIds ?? []);
1614
+ return this.portableStories.filter((story) => storyIds.has(story.id));
1615
+ }
1616
+
1617
+ private portableStoryNode(story: StoryRef, rootIds: string[]): EditorNode {
1618
+ const active = story.id === this.activeStoryId;
1619
+ return {
1620
+ id: story.id,
1621
+ label: story.label,
1622
+ role: 'story',
1623
+ kind: 'story',
1624
+ parentId:
1625
+ this.portableHierarchy?.storyParentIds.get(story.id) ??
1626
+ this.portableDocumentForStory(story.id)?.id ??
1627
+ PORTABLE_DOCUMENT_ID,
1628
+ childIds: active ? rootIds : [],
1629
+ };
1630
+ }
1631
+
1632
+ readonly hierarchy: HierarchyProvider = {
1633
+ crossSurfaceStructureSignature: () => this.crossSurfaceStructureSignature(),
1634
+ roots: () => {
1635
+ const tree = this.hierarchySnapshot();
1636
+ const { nodes, rootIds } = tree;
1637
+ if (this.portableStories.length > 0) {
1638
+ if (this.portableHierarchy) {
1639
+ return this.portableHierarchy.rootIds.flatMap((id) => {
1640
+ const node = this.portableHierarchy?.nodes.find((candidate) => candidate.id === id);
1641
+ return node ? [{ ...node, childIds: [...node.childIds] }] : [];
1642
+ });
1643
+ }
1644
+ return this.portableDocuments.map((document) => this.portableDocumentNode(document));
1645
+ }
1646
+ return rootIds.map((id) => this.toEditorNode(nodes.get(id)!, tree));
1647
+ },
1648
+ node: (id) => {
1649
+ if (this.portableStories.length > 0) {
1650
+ const hierarchyNode = this.portableHierarchy?.nodes.find(
1651
+ (candidate) => candidate.id === id,
1652
+ );
1653
+ if (hierarchyNode) return { ...hierarchyNode, childIds: [...hierarchyNode.childIds] };
1654
+ const document = this.portableDocuments.find((candidate) => candidate.id === id);
1655
+ if (document) return this.portableDocumentNode(document);
1656
+ const story = this.portableStories.find((candidate) => candidate.id === id);
1657
+ if (story) return this.portableStoryNode(story, this.hierarchySnapshot().rootIds);
1658
+ }
1659
+ const tree = this.hierarchySnapshot();
1660
+ const n = tree.nodes.get(id);
1661
+ return n ? this.toEditorNode(n, tree) : null;
1662
+ },
1663
+ // No `object3D`/`idForObject3D`: react entities are DOM, not Object3D —
1664
+ // the concept does not apply here (no 3D gizmo binding).
1665
+ };
1666
+
1667
+ readonly selection: SelectionProvider = {
1668
+ get: () => {
1669
+ const ids = [...this.store.selectedEntityIds];
1670
+ // Remember the last NON-EMPTY selection so a re-projection that drops it
1671
+ // can be repaired — see `repairSelectionAfterReprojection`. Recorded on
1672
+ // the read because that is the one call every projection of the
1673
+ // selection already makes; the adapter has no store subscription of its
1674
+ // own and adding one to observe a value it is handed anyway would be a
1675
+ // second source of the same fact.
1676
+ if (ids.length > 0) this.lastResolvedSelection = ids;
1677
+ return ids;
1678
+ },
1679
+ set: (ids) => this.store.selectMultiple(ids),
1680
+ resolve: (rawId, options) => {
1681
+ const raw = this.hierarchy.node(rawId);
1682
+ if (!raw) return null;
1683
+
1684
+ const innerToOuter: EditorNode[] = [];
1685
+ let cursor: EditorNode | null = raw;
1686
+ let guard = 0;
1687
+ while (cursor && guard++ < 1000) {
1688
+ if (
1689
+ cursor.role === 'component' ||
1690
+ cursor.role === 'instance' ||
1691
+ cursor.role === 'boundary'
1692
+ ) {
1693
+ innerToOuter.push(cursor);
1694
+ }
1695
+ cursor = cursor.parentId ? this.hierarchy.node(cursor.parentId) : null;
1696
+ }
1697
+ const chain = innerToOuter.reverse();
1698
+ if (chain.length === 0) {
1699
+ return { id: rawId };
1700
+ }
1701
+
1702
+ const scopeIndex = options?.scopeId
1703
+ ? chain.findIndex((candidate) => candidate.id === options.scopeId)
1704
+ : -1;
1705
+ let resolvedIndex = scopeIndex >= 0 ? scopeIndex + 1 : 0;
1706
+ if (options?.intent === 'deep') resolvedIndex += 1;
1707
+
1708
+ // Unlike the R3F projection, React keeps authored host elements in the
1709
+ // hierarchy. Once every component boundary on the route is open, the
1710
+ // exact DOM element becomes the honest selection subject.
1711
+ const subject = chain[resolvedIndex] ?? raw;
1712
+ return { id: subject.id };
1713
+ },
1714
+ };
1715
+
1716
+ readonly truth: TruthProvider = {
1717
+ resolve: (id): { site: NodeCreationSite; writeAnchorKind: WriteAnchorKind | undefined } => {
1718
+ const node = this.snapshot().nodes.get(id);
1719
+ if (!node) {
1720
+ return {
1721
+ site: { anchored: false, reason: 'This row is a design document, not a JSX element.' },
1722
+ writeAnchorKind: undefined,
1723
+ };
1724
+ }
1725
+ const entry = this.oidIndex.get(node.oid);
1726
+ const writeAnchorKind = this.writeBackend ? 'source-prop' : 'live-only';
1727
+ if (!entry) {
1728
+ return {
1729
+ site: { anchored: false, reason: 'Source metadata is still loading or unavailable.' },
1730
+ writeAnchorKind,
1731
+ };
1732
+ }
1733
+ const file = projectSourcePath(entry.file);
1734
+ return {
1735
+ site: {
1736
+ anchored: true,
1737
+ kind: 'source',
1738
+ file,
1739
+ line: entry.line,
1740
+ col: entry.col,
1741
+ display: `${file}:${entry.line}`,
1742
+ },
1743
+ writeAnchorKind,
1744
+ };
1745
+ },
1746
+ };
1747
+
1748
+ readonly related: RelatedSubjectsProvider = {
1749
+ links: (id) => {
1750
+ const node = this.hierarchy.node(id);
1751
+ if (node?.role !== 'component' || this.stories.storiesFor(id).length === 0) return [];
1752
+ return [
1753
+ {
1754
+ // `definition:` marks this as the subject's OPEN-FOR-EDIT target (the
1755
+ // component's own UI Components document), so the inspector surfaces it
1756
+ // as the labeled Edit button by kind, never by list position.
1757
+ id: `definition:${UI_COMPONENTS_DOCUMENT_ID}`,
1758
+ title: 'Open in UI Components',
1759
+ open: () => {
1760
+ void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
1761
+ },
1762
+ },
1763
+ ];
1764
+ },
1765
+ };
1766
+
1767
+ private instanceSource(id: string): {
1768
+ node: OidNode;
1769
+ callSiteOid: string;
1770
+ callSite: OidEntry;
1771
+ props: Record<string, string>;
1772
+ } | null {
1773
+ const node = this.snapshot().nodes.get(id);
1774
+ if (!node || this.hierarchy.node(id)?.role !== 'component') return null;
1775
+ const component = getComponentProps(node.el);
1776
+ if (!component) return null;
1777
+ const callSite = this.oidIndex.get(component.callSiteOid);
1778
+ if (!callSite || !/^[A-Z]/.test(callSite.tag)) return null;
1779
+ return {
1780
+ node,
1781
+ callSiteOid: component.callSiteOid,
1782
+ callSite,
1783
+ props: component.props,
1784
+ };
1785
+ }
1786
+
1787
+ private instanceProp(
1788
+ id: string,
1789
+ path: string,
1790
+ ): {
1791
+ source: NonNullable<ReturnType<ReactRootAuthoringAdapter['instanceSource']>>;
1792
+ prop: string;
1793
+ spec: ComponentPropSpec;
1794
+ authored: NonNullable<OidEntry['authoredProps']>[number];
1795
+ } | null {
1796
+ if (!path.startsWith(PROP_PATH_PREFIX)) return null;
1797
+ const source = this.instanceSource(id);
1798
+ if (!source) return null;
1799
+ const prop = path.slice(PROP_PATH_PREFIX.length);
1800
+ const spec = source.callSite.props?.find((candidate) => candidate.name === prop);
1801
+ const authored = source.callSite.authoredProps?.find((candidate) => candidate.name === prop);
1802
+ return spec && authored ? { source, prop, spec, authored } : null;
1803
+ }
1804
+
1805
+ private affectedInstances(id: string, prop: string): number {
1806
+ const selected = this.instanceSource(id);
1807
+ if (!selected) return 0;
1808
+ let count = 0;
1809
+ for (const candidate of this.snapshot().nodes.values()) {
1810
+ const component = getComponentProps(candidate.el);
1811
+ if (!component) continue;
1812
+ const entry = this.oidIndex.get(component.callSiteOid);
1813
+ if (!entry || entry.tag !== selected.callSite.tag) continue;
1814
+ if (candidate.id === id || !entry.authoredProps?.some((item) => item.name === prop)) count++;
1815
+ }
1816
+ return Math.max(1, count);
1817
+ }
1818
+
1819
+ readonly instances: ComponentInstancesProvider = {
1820
+ openComponent: () => {
1821
+ void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
1822
+ },
1823
+ describe: (id) => {
1824
+ const source = this.instanceSource(id);
1825
+ if (!source || this.stories.storiesFor(id).length === 0) return null;
1826
+ const overrides = Object.entries(source.props).flatMap(([prop, value]) => {
1827
+ const path = `${PROP_PATH_PREFIX}${prop}`;
1828
+ const detail = this.instanceProp(id, path);
1829
+ if (!detail) return [];
1830
+ const canApplyToComponent =
1831
+ detail.authored.literal &&
1832
+ detail.spec.defaultValue !== undefined &&
1833
+ source.node.oid !== source.callSiteOid &&
1834
+ !!this.writeBackend?.runGesture &&
1835
+ !!this.writeBackend.writeComponentDefault &&
1836
+ !!this.writeBackend.removeProp;
1837
+ return [
1838
+ {
1839
+ path,
1840
+ label: prop,
1841
+ value,
1842
+ ...(detail.spec.defaultText === undefined
1843
+ ? {}
1844
+ : { defaultText: detail.spec.defaultText }),
1845
+ canApplyToComponent,
1846
+ affectedInstanceCount: this.affectedInstances(id, prop),
1847
+ ...(canApplyToComponent
1848
+ ? {}
1849
+ : {
1850
+ applyUnavailableReason: !detail.authored.literal
1851
+ ? 'The callsite value is a dynamic expression, so it cannot become a component literal.'
1852
+ : detail.spec.defaultValue === undefined
1853
+ ? 'The component default is computed or absent, so source cannot be changed safely.'
1854
+ : 'This session cannot atomically update the component and its callsite.',
1855
+ }),
1856
+ },
1857
+ ];
1858
+ });
1859
+ return {
1860
+ componentName: source.callSite.tag,
1861
+ sourcePath: projectSourcePath(source.callSite.file),
1862
+ overrides,
1863
+ };
1864
+ },
1865
+ revert: async (id, paths) => {
1866
+ const source = this.instanceSource(id);
1867
+ const backend = this.writeBackend;
1868
+ if (!source || !backend?.removeProp) return;
1869
+ const props = paths
1870
+ .map((path) => this.instanceProp(id, path)?.prop)
1871
+ .filter((prop): prop is string => !!prop);
1872
+ let changed = false;
1873
+ const remove = async (selected: SourceWriteBackend): Promise<void> => {
1874
+ for (const prop of props) {
1875
+ const result = await selected.removeProp?.(source.callSiteOid, prop);
1876
+ changed ||= result?.changed === true;
1877
+ }
1878
+ };
1879
+ if (props.length > 1 && backend.runGesture) {
1880
+ await backend.runGesture(`Revert ${props.length} Overrides`, remove);
1881
+ } else {
1882
+ await remove(backend);
1883
+ }
1884
+ if (!changed) return;
1885
+ this.dirty = true;
1886
+ this.pendingSourceReconcile++;
1887
+ await this.refreshOidIndex();
1888
+ return { destination: JSX_SOURCE_DESTINATION, persisted: true };
1889
+ },
1890
+ applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
1891
+ const detail = this.instanceProp(id, path);
1892
+ const backend = this.writeBackend;
1893
+ if (
1894
+ !detail ||
1895
+ !detail.authored.literal ||
1896
+ detail.spec.defaultValue === undefined ||
1897
+ detail.source.node.oid === detail.source.callSiteOid ||
1898
+ !backend?.runGesture ||
1899
+ !backend.writeComponentDefault ||
1900
+ !backend.removeProp
1901
+ ) {
1902
+ return {
1903
+ changed: false,
1904
+ message: 'Apply was refused because both literal source writes are not available.',
1905
+ };
1906
+ }
1907
+ const value = detail.source.props[detail.prop];
1908
+ await backend.runGesture(
1909
+ `Apply ${detail.prop} to ${detail.source.callSite.tag}`,
1910
+ async (scoped) => {
1911
+ const applied = await scoped.writeComponentDefault?.(
1912
+ detail.source.node.oid,
1913
+ detail.prop,
1914
+ serializedComponentValue(value, detail.spec),
1915
+ );
1916
+ if (!applied?.changed) {
1917
+ throw new Error(applied?.error ?? 'The component default did not change.');
1918
+ }
1919
+ const reverted = await scoped.removeProp?.(detail.source.callSiteOid, detail.prop);
1920
+ if (!reverted?.changed) {
1921
+ throw new Error(reverted?.error ?? 'The callsite override was not removed.');
1922
+ }
1923
+ },
1924
+ );
1925
+ this.dirty = true;
1926
+ this.pendingSourceReconcile++;
1927
+ await this.refreshOidIndex();
1928
+ return {
1929
+ changed: true,
1930
+ message: `${detail.prop} now defaults to ${String(value)} in ${detail.source.callSite.tag}.`,
1931
+ write: { destination: JSX_SOURCE_DESTINATION, persisted: true },
1932
+ };
1933
+ },
1934
+ };
1935
+
1936
+ /**
1937
+ * D12 (B4) — GEOMETRIC rect hit-test over the live OID tree, NOT
1938
+ * `document.elementFromPoint` (which SKIPS a `pointer-events:none` wrapper —
1939
+ * this layer's resting CSS state at design time, `design-time-layers.ts`'s
1940
+ * `applySessionStyle`). Reuses `ui-editor/react-store.ts`'s established
1941
+ * `hitTestAttr` ranking rule (smallest-area element containing the point
1942
+ * wins — the deepest/most-specific node; ties broken by later tree-walk
1943
+ * order — the topmost) over each OID node's own `getBoundingClientRect()`,
1944
+ * inlined here rather than sharing that function directly since this walks
1945
+ * `OidNode`s already resolved by `walkOidTree`, not a live `querySelectorAll`
1946
+ * over a DOM attribute string.
1947
+ *
1948
+ * An element that EXPLICITLY opts out with inline `pointer-events:none` is
1949
+ * skipped. The design-time host itself is `pointer-events:none`, so checking
1950
+ * computed style would incorrectly inherit that host policy into every
1951
+ * authorable descendant. Reading the node's own declaration instead preserves
1952
+ * click-to-author descendants while making an authored full-viewport
1953
+ * click-through wrapper behave as click-through here too.
1954
+ *
1955
+ * The winning id is the SAME disambiguated id `hierarchy`/`selection`
1956
+ * already use (this IS `walkOidTree`'s own id space) — a hit routes
1957
+ * straight into `composite.selection.set([id])` with no translation. A
1958
+ * catalog node (`catalog:<key>`) has no DOM element behind it at all, so it
1959
+ * can never be a candidate here — only ordinary OID nodes are walked
1960
+ * (correct: nothing on screen corresponds to a bare catalog entry).
1961
+ */
1962
+ readonly pickable: PickProvider = {
1963
+ pick: (clientX, clientY) => {
1964
+ const { nodes } = this.snapshot();
1965
+ let bestId: string | null = null;
1966
+ let bestArea = Number.POSITIVE_INFINITY;
1967
+ let bestOrder = -1;
1968
+ let order = -1;
1969
+ for (const node of nodes.values()) {
1970
+ order++;
1971
+ // D4.R1 — a locked node is SKIPPED, not returned: exactly the
1972
+ // `viewport-raycast.ts` first-party semantics ("skip locked
1973
+ // entities in viewport selection", falling through to whatever
1974
+ // unlocked node is behind/around it). See `lockedIds`'s doc
1975
+ // comment for why this Set, not a real inspector-backed field.
1976
+ if (this.lockedIds.has(node.id)) continue;
1977
+ if (styleProp(node.el.style, 'pointerEvents') === 'none') continue;
1978
+ const rect = node.el.getBoundingClientRect?.();
1979
+ if (!rect) continue; // no live rect (test fixture, or unmounted) — never a candidate
1980
+ if (
1981
+ clientX < rect.left ||
1982
+ clientX > rect.right ||
1983
+ clientY < rect.top ||
1984
+ clientY > rect.bottom
1985
+ ) {
1986
+ continue;
1987
+ }
1988
+ const area = rect.width * rect.height;
1989
+ // smallest-area (deepest) wins; ties broken by later tree-order (topmost)
1990
+ if (area < bestArea || (area === bestArea && order > bestOrder)) {
1991
+ bestId = node.id;
1992
+ bestArea = area;
1993
+ bestOrder = order;
1994
+ }
1995
+ }
1996
+ return bestId === null ? null : this.resolvePlacementUnit(bestId);
1997
+ },
1998
+ };
1999
+
2000
+ /**
2001
+ * The pasteboard's PICK RULE: a hit anywhere inside an `<At>` subtree
2002
+ * selects the At — the placement unit whose `x`/`y` a drag writes — never
2003
+ * the helper's internals. Without this, the deepest-wins rule above picked
2004
+ * a Swatch's inner chip div (a STATIC node whose x/y drag drops by design),
2005
+ * and the drag gesture that followed re-picked it out from under the
2006
+ * selected At on pointerdown, so no pasteboard drag could ever commit
2007
+ * (measured on the moodboard acceptance run, 2026-08-30). Deeper selection
2008
+ * stays reachable through the hierarchy panel, which projects the full
2009
+ * subtree. Structural (`data-vgai-at`), so it costs nothing outside
2010
+ * pasteboard content.
2011
+ */
2012
+ private resolvePlacementUnit(pickedId: string): string {
2013
+ const { nodes } = this.snapshot();
2014
+ const picked = nodes.get(pickedId);
2015
+ const el = picked?.el as unknown as
2016
+ | { closest?(selector: string): unknown; getAttribute?(name: string): string | null }
2017
+ | undefined;
2018
+ if (!el?.closest) return pickedId;
2019
+ if (el.getAttribute?.('data-vgai-at') === 'true') return pickedId;
2020
+ const at = el.closest('[data-vgai-at="true"]');
2021
+ if (!at) return pickedId;
2022
+ for (const [id, node] of nodes) {
2023
+ if ((node.el as unknown) === at) return id;
2024
+ }
2025
+ return pickedId;
2026
+ }
2027
+
2028
+ /** Host rect to subtract for {@link rects}' HOST-RELATIVE geometry — `this.root`
2029
+ * IS the world's own mounted DOM layer (`world.mounted.container` / the ingest
2030
+ * sibling's `layer`, see the constructor call sites), i.e. exactly the
2031
+ * `position:absolute; inset:0` per-world surface the overlay (Phase A3) is
2032
+ * itself hosted over. No new constructor option is needed — reusing the
2033
+ * proven `react-store.ts` `nodeRect` pattern (element rect minus stage/host
2034
+ * rect) against the field this adapter already holds. */
2035
+ private hostRect(): { left: number; top: number } {
2036
+ const r = this.root.getBoundingClientRect?.();
2037
+ return { left: r?.left ?? 0, top: r?.top ?? 0 };
2038
+ }
2039
+
2040
+ private toHostRelative(r: {
2041
+ left: number;
2042
+ top: number;
2043
+ width: number;
2044
+ height: number;
2045
+ }): DOMRectLike {
2046
+ const host = this.hostRect();
2047
+ const zoom = getRootPan().zoom;
2048
+ return {
2049
+ x: (r.left - host.left) / zoom,
2050
+ y: (r.top - host.top) / zoom,
2051
+ width: r.width / zoom,
2052
+ height: r.height / zoom,
2053
+ };
2054
+ }
2055
+
2056
+ /**
2057
+ * T0 (spec 27 §2) — per-node screen geometry for the DOM visual editor's
2058
+ * overlay/snap/measure math. `rect`/`contextRects` return HOST-RELATIVE
2059
+ * coordinates (see {@link hostRect}'s doc comment) — the overlay this feeds
2060
+ * (Phase A3, `RootSelectionOverlay`) is itself mounted as a
2061
+ * `position:absolute; inset:0` layer over the same per-world DOM host, so a
2062
+ * host-relative rect is exactly what it can draw against with no further
2063
+ * translation (matching the established `ui-editor/react-store.ts`
2064
+ * `nodeRect` pattern this reuses).
2065
+ */
2066
+ readonly rects: RectProvider = {
2067
+ rect: (id) => {
2068
+ const n = this.snapshot().nodes.get(id);
2069
+ const r = n?.el.getBoundingClientRect?.();
2070
+ return r ? this.toHostRelative(r) : null;
2071
+ },
2072
+ contextRects: (id) => {
2073
+ const { nodes } = this.snapshot();
2074
+ const n = nodes.get(id);
2075
+ if (!n) return {};
2076
+ const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
2077
+ const parentRect = parentNode?.el.getBoundingClientRect?.();
2078
+ const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
2079
+ (sid) => sid !== id,
2080
+ );
2081
+ const siblings = siblingIds
2082
+ .map((sid) => nodes.get(sid)?.el.getBoundingClientRect?.())
2083
+ .filter((r): r is NonNullable<typeof r> => r != null)
2084
+ .map((r) => this.toHostRelative(r));
2085
+ // Padding box (CSS box model, inside the border) — border widths read off
2086
+ // the same computed-style resolver the inspector uses; absent/unparsable
2087
+ // border widths degrade to 0 (padding box === border box), never thrown.
2088
+ const el = n.el;
2089
+ const rect = el.getBoundingClientRect?.();
2090
+ let paddingBox: DOMRectLike | undefined;
2091
+ if (rect) {
2092
+ const bt =
2093
+ numericStyleValue(getComputedStyleValue(el, 'borderTopWidth', this.computedStyle)) ?? 0;
2094
+ const br =
2095
+ numericStyleValue(getComputedStyleValue(el, 'borderRightWidth', this.computedStyle)) ?? 0;
2096
+ const bb =
2097
+ numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth', this.computedStyle)) ??
2098
+ 0;
2099
+ const bl =
2100
+ numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth', this.computedStyle)) ?? 0;
2101
+ paddingBox = this.toHostRelative({
2102
+ left: rect.left + bl,
2103
+ top: rect.top + bt,
2104
+ width: Math.max(0, rect.width - bl - br),
2105
+ height: Math.max(0, rect.height - bt - bb),
2106
+ });
2107
+ }
2108
+ // Persistent board guides (board-guides.ts), converted from client
2109
+ // space into the same host-relative units as every rect above.
2110
+ const guideClients = guideClientEdges();
2111
+ const host = this.hostRect();
2112
+ const zoom = getRootPan().zoom;
2113
+ const guideEdges =
2114
+ guideClients.x.length > 0 || guideClients.y.length > 0
2115
+ ? {
2116
+ x: guideClients.x.map((cx) => (cx - host.left) / zoom),
2117
+ y: guideClients.y.map((cy) => (cy - host.top) / zoom),
2118
+ }
2119
+ : undefined;
2120
+ return {
2121
+ ...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
2122
+ ...(siblings.length ? { siblings } : {}),
2123
+ ...(paddingBox ? { paddingBox } : {}),
2124
+ ...(guideEdges ? { guideEdges } : {}),
2125
+ };
2126
+ },
2127
+ // D3.c (spec 27 §6) — every currently-empty OID container: no visible
2128
+ // (OID or non-OID) child ELEMENT, no text, feeding the pure
2129
+ // `findEmptyContainers` math (`ui-source/inspect.ts:463`) unchanged. Rule
2130
+ // zero stays intact — the DOM READ happens HERE, in the adapter; the
2131
+ // overlay/shell only ever sees the already-filtered `{id, rect,
2132
+ // displayName}` result.
2133
+ emptyContainers: () => {
2134
+ const { nodes } = this.snapshot();
2135
+ const candidates: EmptyCandidate[] = [];
2136
+ for (const n of nodes.values()) {
2137
+ const r = n.el.getBoundingClientRect?.();
2138
+ if (!r || !canContainDomChildren(n.el, n.tag)) continue;
2139
+ candidates.push({
2140
+ oid: n.id,
2141
+ rect: this.toHostRelative(r),
2142
+ displayName: n.tag,
2143
+ hasVisibleChildren: n.el.children.length > 0,
2144
+ hasText: (n.el.textContent ?? '').trim().length > 0,
2145
+ });
2146
+ }
2147
+ return findEmptyContainers(candidates).map((c) => ({
2148
+ id: c.oid,
2149
+ rect: c.rect,
2150
+ displayName: c.displayName,
2151
+ }));
2152
+ },
2153
+ };
2154
+
2155
+ /**
2156
+ * D3.d (spec 27 §6) — the eyedropper FALLBACK color-sample path: pick the
2157
+ * topmost OID node at the point (reusing this adapter's OWN `pickable.pick`,
2158
+ * never `elementFromPoint`), then walk its ancestor chain collecting each
2159
+ * node's RAW (un-normalized) computed `background-color` — hit-element
2160
+ * first, matching `effectiveColorFromChain`'s expected order. Raw, not
2161
+ * `cssColorToHex`-normalized, so a `transparent`/`rgba(0,0,0,0)` background
2162
+ * is recognizable as such by `isTransparentBackground` (the hex form would
2163
+ * lose that signal — see the contract's own doc comment on
2164
+ * `ColorSampleProvider`).
2165
+ */
2166
+ readonly colorSample: ColorSampleProvider = {
2167
+ backgroundChainAt: (clientX, clientY) => {
2168
+ const hitId = this.pickable.pick(clientX, clientY);
2169
+ if (!hitId) return null;
2170
+ const { nodes } = this.snapshot();
2171
+ const chain: string[] = [];
2172
+ let cur: OidNode | undefined = nodes.get(hitId);
2173
+ while (cur) {
2174
+ chain.push(getComputedStyleValue(cur.el, 'backgroundColor', this.computedStyle));
2175
+ cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
2176
+ }
2177
+ return chain;
2178
+ },
2179
+ };
2180
+
2181
+ /**
2182
+ * T0 (spec 27 §4, B1) — spatial drag-resize/move/spacing → source write, for
2183
+ * non-Object3D (DOM) nodes. `apply` is LIVE PREVIEW ONLY: it mutates the live
2184
+ * element's inline style directly, with ZERO backend traffic (acceptance:311 —
2185
+ * a resize drag must not spam the dev server with a write per frame). `end`
2186
+ * commits every touched prop ONCE through the existing {@link writeStyleEntry}
2187
+ * write pipeline (CSS-file routing + append-aware undo preserved unchanged),
2188
+ * composed into exactly ONE undo entry per gesture (acceptance:310) even when
2189
+ * the gesture touched multiple props (e.g. a corner-resize writes both `width`
2190
+ * and `height`). See {@link boxEditSession}'s doc comment for the
2191
+ * `priorInline` capture this depends on for a correct undo inverse.
2192
+ */
2193
+ readonly boxEdit: BoxEditProvider = {
2194
+ begin: (id) => {
2195
+ // A stale, never-`end`ed session (caller bug) is simply replaced — its
2196
+ // preview mutations are already live on the DOM either way.
2197
+ this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
2198
+ },
2199
+ apply: (id, patch) => {
2200
+ const session = this.boxEditSession;
2201
+ if (!session || session.id !== id) return; // no open gesture for this id
2202
+ const n = this.snapshot().nodes.get(id);
2203
+ if (!n) return; // unresolved id — no-op (per contract)
2204
+ const pos = getComputedStyleValue(n.el, 'position', this.computedStyle);
2205
+ const isPositioned = pos === 'absolute' || pos === 'fixed';
2206
+ for (const [key, v] of Object.entries(patch)) {
2207
+ const mapped = mapBoxEditPatchKey(key, isPositioned);
2208
+ if (!mapped) {
2209
+ console.warn(
2210
+ `[ReactRootAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
2211
+ (key === 'x' || key === 'y'
2212
+ ? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
2213
+ 'no left/top to move'
2214
+ : 'unrecognized box-edit patch key'),
2215
+ );
2216
+ // SAY IT WHERE THE HAND IS. A drag on a flow-positioned element
2217
+ // writes nothing — the board moves only absolutely/fixed positioned
2218
+ // nodes (spec:322) — and the console line above was the only
2219
+ // account of it: "I can't move this" was a tester's verdict on the
2220
+ // health bar (runhuman pass 133). Once per gesture.
2221
+ if ((key === 'x' || key === 'y') && !session.hinted) {
2222
+ session.hinted = true;
2223
+ showTransientHint(
2224
+ `${n.tag} flows in its layout (position: ${pos}), so a drag ` +
2225
+ 'cannot move it. Set Position to "abs" in the Inspector to place it freely; ' +
2226
+ 'drag a handle to resize.',
2227
+ );
2228
+ }
2229
+ continue;
2230
+ }
2231
+ // Lazily snapshot the TRUE pre-gesture inline value the first time THIS
2232
+ // prop is touched in this gesture — before mutating it — see
2233
+ // `boxEditSession`'s doc comment for why this can't be re-derived later.
2234
+ if (!session.priorInline.has(mapped.prop)) {
2235
+ const prevRaw = styleProp(n.el.style, mapped.prop);
2236
+ session.priorInline.set(mapped.prop, prevRaw == null ? '' : String(prevRaw));
2237
+ }
2238
+ // x/y arrive HOST-RELATIVE (the rect provider's space), but CSS
2239
+ // left/top are OFFSET-PARENT-relative. They only coincide when the
2240
+ // offset parent sits at the host origin — which a story-frame child
2241
+ // never does, so a board drag used to write BOARD coordinates into
2242
+ // the element's source (left:'2968px' on a 1280-wide frame; found by
2243
+ // the blind walk, the element vanished outside its own frame).
2244
+ // parentOrigin = rect − offsetLeft is constant through the gesture
2245
+ // (both shift together as we mutate), and an element positioned via
2246
+ // translate() cancels exactly: rect includes the transform, so the
2247
+ // written left lands the VISUAL box at the requested position.
2248
+ let write = v;
2249
+ if ((key === 'x' || key === 'y') && typeof v === 'number') {
2250
+ const el = n.el as unknown as {
2251
+ getBoundingClientRect?: () => {
2252
+ left: number;
2253
+ top: number;
2254
+ width: number;
2255
+ height: number;
2256
+ };
2257
+ offsetLeft?: number;
2258
+ offsetTop?: number;
2259
+ };
2260
+ const rect = el.getBoundingClientRect?.();
2261
+ if (rect && typeof el.offsetLeft === 'number' && typeof el.offsetTop === 'number') {
2262
+ const hostRel = this.toHostRelative(rect);
2263
+ const parentOrigin = key === 'x' ? hostRel.x - el.offsetLeft : hostRel.y - el.offsetTop;
2264
+ // Whole-pixel, like every other gesture write (see the spacing
2265
+ // scrub's own doc comment): the patch is grid/edge-snapped, but
2266
+ // edge targets and this origin are MEASURED (rect ÷ zoom), so
2267
+ // without rounding a 65%-zoom drag authors `left:
2268
+ // '215.00005607057415px'` — measurement noise, not intent.
2269
+ write = Math.round(v - parentOrigin);
2270
+ }
2271
+ }
2272
+ const cssValue = mapped.cssValue(write);
2273
+ if (n.el.style) (n.el.style as Record<string, unknown>)[mapped.prop] = cssValue;
2274
+ // Commit numerics as plain numbers (matching every other numeric style
2275
+ // write in this adapter — see `writeStyle`'s callers), transform as a
2276
+ // string — NOT the px-suffixed preview string, which is preview-only.
2277
+ session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : write);
2278
+ }
2279
+ },
2280
+ end: (id) => {
2281
+ const session = this.boxEditSession;
2282
+ this.boxEditSession = null;
2283
+ if (!session || session.id !== id || session.touched.size === 0) return;
2284
+ // Queue behind any still-running commit — see `boxEditTail`. A failed
2285
+ // commit must not poison the chain for every later gesture, so the tail
2286
+ // absorbs the rejection (commitBoxEdit reports its own failures).
2287
+ this.boxEditTail = this.boxEditTail.then(
2288
+ () => this.commitBoxEdit(id, session.touched, session.priorInline),
2289
+ () => this.commitBoxEdit(id, session.touched, session.priorInline),
2290
+ );
2291
+ void this.boxEditTail.catch((error) => {
2292
+ console.error('[ReactRootAuthoringAdapter] box edit failed and was not committed.', error);
2293
+ });
2294
+ },
2295
+ };
2296
+
2297
+ /**
2298
+ * T0 (spec 27 §4, B1) — commit every prop touched by one `boxEdit` gesture, each
2299
+ * through {@link writeStyleEntry} (so CSS-file routing / append-aware undo are
2300
+ * unchanged), then compose all resulting entries into exactly ONE undo entry
2301
+ * (acceptance:310) whose inverse runs in REVERSE order and whose redo runs in
2302
+ * gesture order — mirroring how a multi-statement edit undoes as one unit
2303
+ * elsewhere in this adapter.
2304
+ */
2305
+ private async commitBoxEdit(
2306
+ id: string,
2307
+ touched: Map<string, string | number>,
2308
+ priorInline: Map<string, string>,
2309
+ ): Promise<void> {
2310
+ const wasDirty = this.dirty;
2311
+ // A pasteboard `<At>` mount routes its position to the CALLSITE's `x`/`y`
2312
+ // JSX literals rather than inline `left`/`top` style — the drag's live
2313
+ // preview still moved `el.style.left/top` (the helper renders exactly that
2314
+ // from `x`/`y`), but the AUTHORED truth is the props, so that is what the
2315
+ // commit writes. Resolved from the fiber (the helper's own internal stamp
2316
+ // sits on the same element, so the element's `data-oid` alone would name
2317
+ // the helper file, not the callsite in the pasteboard file).
2318
+ const node = this.snapshot().nodes.get(id);
2319
+ const atCallSiteOid = node && isPasteboardAtElement(node.el) ? getCallSiteOid(node.el) : null;
2320
+ const atPropForStyle = (prop: string): 'x' | 'y' | null =>
2321
+ atCallSiteOid === null ? null : prop === 'left' ? 'x' : prop === 'top' ? 'y' : null;
2322
+ // A4 — echo every touched prop BEFORE any write, so the inspector field shows
2323
+ // the dragged value at once (same discipline as `inspector.set`). The echo holds
2324
+ // the BARE numeric (a `type:'number'` descriptor's `Inspector` field needs an
2325
+ // actual number — see `inspector.get`'s `numericStyleValue` note), NOT the
2326
+ // px-suffixed source form D1 writes below. An At placement echoes on the
2327
+ // PROP path instead (its Props section is where x/y render).
2328
+ for (const [prop, value] of touched) {
2329
+ const atProp = atPropForStyle(prop);
2330
+ if (atProp && typeof value === 'number') {
2331
+ this.setEcho(id, `${PROP_PATH_PREFIX}${atProp}`, String(Math.round(value)));
2332
+ } else {
2333
+ this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
2334
+ }
2335
+ }
2336
+ // A GESTURE THAT WROTE NOTHING SAYS SO. Every refusal below only warned in
2337
+ // the browser console, so a drag whose commit was refused looked done —
2338
+ // the element sat where the hand left it (live preview), the history had
2339
+ // an "Edit 2 Styles" entry to undo, and undo reverted nothing because
2340
+ // nothing had been written; a normal reload then showed the element back
2341
+ // where it was (runhuman passes 135/137/138/140, all on Windows Chrome —
2342
+ // the macOS instrument writes and undoes the same gesture cleanly). The
2343
+ // refusal is now an editor-console ERROR and a hint, naming the reason.
2344
+ let applied = 0;
2345
+ this.lastStyleWriteRefusal = null;
2346
+ const commit = async (backend: SourceWriteBackend | undefined): Promise<void> => {
2347
+ for (const [prop, value] of touched) {
2348
+ const atProp = atPropForStyle(prop);
2349
+ if (atProp && typeof value === 'number') {
2350
+ await this.writeAtPlacement(id, atCallSiteOid as string, atProp, value, backend);
2351
+ applied += 1;
2352
+ continue;
2353
+ }
2354
+ // D1 (spec 27 §4 B2/B3 reload-safety) — a numeric length-prop value must
2355
+ // persist to JSX SOURCE in a form React honors on REMOUNT. React 19 DROPS a
2356
+ // bare UNITLESS numeric STRING (the writer quotes `String(152)` → `width:
2357
+ // '152'`) on reload — the element collapses to content size (verified in real
2358
+ // Chromium + React 19) — but HONORS a quoted CSS length (`width: '152px'`).
2359
+ // `boxEdit.apply` stores length props as bare NUMBERS and CSS-string props
2360
+ // (`transform` → `'rotate(90deg)'`) as strings, so a numeric value here is
2361
+ // EXACTLY the set of length props (width/height/left/top/margin*/padding*)
2362
+ // needing a `px` unit — suffix only those, leaving `transform` untouched.
2363
+ // (The live-preview `el.style` path already applied a px-suffixed string via
2364
+ // `mapBoxEditPatchKey.cssValue`; only the persisted-source form was unitless.)
2365
+ // Localized to this box-edit commit — the color/`inspector.set` write path is
2366
+ // deliberately NOT changed.
2367
+ const writeValue = typeof value === 'number' ? `${value}px` : value;
2368
+ if (await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop), backend)) {
2369
+ applied += 1;
2370
+ }
2371
+ }
2372
+ if (applied === 0 && touched.size > 0) {
2373
+ const reason = this.lastStyleWriteRefusal ?? 'the write was refused without a reason';
2374
+ editorConsole.error(`The last edit on this element was NOT saved — ${reason}`, 'authoring');
2375
+ showTransientHint('Not saved: the edit could not be written to source — see the Console.');
2376
+ }
2377
+ };
2378
+ // The history-managed backend supplies an explicit scoped writer. Holding
2379
+ // the callback as one queue entry makes the gesture failure-atomic and
2380
+ // prevents unrelated inspector writes from being absorbed/interleaved.
2381
+ try {
2382
+ if (this.writeBackend?.runGesture) {
2383
+ await this.writeBackend.runGesture(`Edit ${touched.size} Styles`, commit);
2384
+ } else {
2385
+ await commit(this.writeBackend);
2386
+ }
2387
+ } catch (error) {
2388
+ const node = this.snapshot().nodes.get(id);
2389
+ for (const [prop, prior] of priorInline) {
2390
+ this.clearEcho(id, `${STYLE_PATH_PREFIX}${prop}`);
2391
+ if (node?.el.style) (node.el.style as Record<string, unknown>)[prop] = prior;
2392
+ }
2393
+ this.dirty = wasDirty;
2394
+ this.store.notifyIngestEdit();
2395
+ throw error;
2396
+ }
2397
+ }
2398
+
2399
+ /**
2400
+ * The pasteboard placement write: one `<At>` callsite prop (`x` or `y`) as a
2401
+ * JSX NUMBER LITERAL, `addIfMissing` because the helper defaults both to 0
2402
+ * and an author legitimately omits them until the first drag. Refusals
2403
+ * follow {@link writePropEdit}'s contract: a dynamic expression (`x={GRID *
2404
+ * 2}`) is authored truth the drag must not flatten — guarded and warned by
2405
+ * name, never overwritten. Runs inside {@link commitBoxEdit}'s gesture, so a
2406
+ * corner drag that writes both axes stays one undo entry.
2407
+ */
2408
+ private async writeAtPlacement(
2409
+ id: string,
2410
+ callSiteOid: string,
2411
+ prop: 'x' | 'y',
2412
+ value: number,
2413
+ backend: SourceWriteBackend | undefined,
2414
+ ): Promise<void> {
2415
+ const echoPath = `${PROP_PATH_PREFIX}${prop}`;
2416
+ const b = backend ?? this.writeBackend;
2417
+ if (!b?.writeProp) {
2418
+ this.clearEcho(id, echoPath);
2419
+ console.warn(
2420
+ `[ReactRootAuthoringAdapter] cannot write At placement "${prop}" on "${id}": no ` +
2421
+ 'source-write backend with prop support in this session (hosted/no dev server).',
2422
+ );
2423
+ return;
2424
+ }
2425
+ const res = await b.writeProp(callSiteOid, prop, String(Math.round(value)), {
2426
+ addIfMissing: true,
2427
+ });
2428
+ if (!res.changed) {
2429
+ this.clearEcho(id, echoPath);
2430
+ if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
2431
+ this.store.notifyIngestEdit();
2432
+ console.warn(
2433
+ `[ReactRootAuthoringAdapter] At placement write refused/no-op for oid ` +
2434
+ `"${callSiteOid}" prop "${prop}": ${
2435
+ res.dynamic
2436
+ ? 'value is a dynamic expression (guarded) — authored truth the drag must not flatten'
2437
+ : (res.error ?? 'no change')
2438
+ }`,
2439
+ );
2440
+ return;
2441
+ }
2442
+ this.dirty = true;
2443
+ }
2444
+
2445
+ /**
2446
+ * T0 (spec 27 §2) — brings the react adapter's existing off-contract `editText`
2447
+ * (below) onto the contract. `get` is a best-effort CLIENT-side read: the live
2448
+ * DOM can only rule out "has child elements" (`el.children.length > 0`), not a
2449
+ * dynamic `{expression}` body — that guard is source-side and already enforced
2450
+ * at write time (`editText`'s `res.dynamic`, surfaced as a loud console warning
2451
+ * on refusal). `set` fires the existing undo-tracked write path unchanged.
2452
+ */
2453
+ readonly text: TextProvider = {
2454
+ get: (id) => {
2455
+ // D3.R1 — a node already refused once as a dynamic-body text edit
2456
+ // (`markGuarded(id, TEXT_PATH)`, set from `editText`'s `res.dynamic`
2457
+ // refusal below) reports null from here on this session, mirroring
2458
+ // U4's style/prop after-touch marker — so a SECOND double-click reports
2459
+ // the refusal immediately instead of re-opening the textarea only to
2460
+ // have the source refuse it again.
2461
+ if (this.guardedPaths.has(`${id}|${TEXT_PATH}`)) return null;
2462
+ const n = this.snapshot().nodes.get(id);
2463
+ if (!n || n.el.children.length > 0) return null;
2464
+ const raw = n.el.textContent;
2465
+ if (raw == null) return null;
2466
+ const trimmed = raw.trim();
2467
+ return trimmed ? trimmed : null;
2468
+ },
2469
+ set: (id, text) => {
2470
+ void this.editText(id, text);
2471
+ },
2472
+ };
2473
+
2474
+ readonly inspector: InspectorProvider = {
2475
+ properties: (id: string): PropertyDescriptor[] => {
2476
+ const portableHierarchyNode = this.portableHierarchyNode(id);
2477
+ if (portableHierarchyNode) {
2478
+ return portableHierarchyNode.secondaryLabel
2479
+ ? [
2480
+ {
2481
+ path: 'document.path',
2482
+ label: 'Source',
2483
+ type: 'string',
2484
+ readonly: true,
2485
+ group: 'Document',
2486
+ },
2487
+ ]
2488
+ : [];
2489
+ }
2490
+ const portableDocument = this.portableDocuments.find((document) => document.id === id);
2491
+ if (portableDocument) {
2492
+ return portableDocument.path
2493
+ ? [
2494
+ {
2495
+ path: 'document.path',
2496
+ label: 'Source',
2497
+ type: 'string',
2498
+ readonly: true,
2499
+ group: 'Document',
2500
+ },
2501
+ ]
2502
+ : [];
2503
+ }
2504
+ if (this.portableStories.some((story) => story.id === id))
2505
+ return this.portableStoryArgDescriptors(id);
2506
+ const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
2507
+ path: `${STYLE_PATH_PREFIX}${prop}`,
2508
+ label,
2509
+ type,
2510
+ group,
2511
+ ...(options ? { options } : {}),
2512
+ }));
2513
+ // Cap 4 (React visual-edit parity): append a "Props" section for this node's component
2514
+ // call site — its editable primitive props, read live off the React fiber
2515
+ // (`getComponentProps`). A write is guarded server-side (a dynamic prop is refused),
2516
+ // so surfacing every primitive prop here is safe. Cap 7 appends a read-only "Tokens"
2517
+ // section — the document's design tokens (`:root` custom properties).
2518
+ const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
2519
+ // U4 — a path already refused once by a source GUARD (`markGuarded`, set
2520
+ // from `writeStyleEntry`/`writePropEdit`'s refusal) reports
2521
+ // `readonly: true` from here on, carrying that guard's OWN sentence, so
2522
+ // the widget disables itself instead of re-offering an edit the source
2523
+ // will refuse again. See `guardedPaths`'s doc comment for why this is
2524
+ // after-touch, not a pre-touch predictor.
2525
+ if (this.guardedPaths.size === 0) return all;
2526
+ return all.map((p) => {
2527
+ const reason = this.guardedPaths.get(`${id}|${p.path}`);
2528
+ return reason ? { ...p, readonly: true, readonlyReason: reason } : p;
2529
+ });
2530
+ },
2531
+ get: (id, path) => {
2532
+ const portableHierarchyNode = this.portableHierarchyNode(id);
2533
+ if (portableHierarchyNode) {
2534
+ return path === 'document.path' ? portableHierarchyNode.secondaryLabel : undefined;
2535
+ }
2536
+ const portableDocument = this.portableDocuments.find((document) => document.id === id);
2537
+ if (portableDocument) {
2538
+ return path === 'document.path' ? portableDocument.path : undefined;
2539
+ }
2540
+ if (this.portableStories.some((story) => story.id === id)) {
2541
+ if (path === 'story.active') return id === this.activeStoryId;
2542
+ if (path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) {
2543
+ return this.portableStoryArgs.get(id)?.[
2544
+ path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length)
2545
+ ];
2546
+ }
2547
+ return undefined;
2548
+ }
2549
+ // A4 — read THROUGH the optimistic echo: a value just set (style/prop) wins over the
2550
+ // live-DOM walk until the source-write + HMR land, so the field never snaps back.
2551
+ const echoed = this.readEcho(id, path);
2552
+ if (echoed.hit) return echoed.value;
2553
+ // D4 (spec27 §6 D4, layer-tree visibility/lock) — the two reserved
2554
+ // paths GameHierarchy's row reads generically off ANY adapter's
2555
+ // `inspector`. `locked` is session-local (see `lockedIds`'s doc
2556
+ // comment — no source-backed equivalent). `visible` IS source-backed:
2557
+ // sugar for the `style.visibility` prop (read THROUGH that same prop's
2558
+ // own optimistic echo, so the eye icon never snap-backs while the
2559
+ // async write is in flight — same A4 discipline every other style
2560
+ // path gets).
2561
+ if (path === 'locked') return this.lockedIds.has(id);
2562
+ if (path === 'visible') {
2563
+ const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
2564
+ if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
2565
+ const n = this.snapshot().nodes.get(id);
2566
+ if (!n) return undefined;
2567
+ const raw = getComputedStyleValue(n.el, 'visibility', this.computedStyle);
2568
+ return raw !== 'hidden';
2569
+ }
2570
+ if (path.startsWith(TOKEN_PATH_PREFIX)) {
2571
+ const name = path.slice(TOKEN_PATH_PREFIX.length);
2572
+ return this.designTokenValue(name);
2573
+ }
2574
+ if (path.startsWith(PROP_PATH_PREFIX)) {
2575
+ const n = this.snapshot().nodes.get(id);
2576
+ const cp = n ? getComponentProps(n.el) : null;
2577
+ return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
2578
+ }
2579
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
2580
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
2581
+ const n = this.snapshot().nodes.get(id);
2582
+ if (!n) return undefined;
2583
+ // Cap 1 (React visual-edit parity): read the COMPUTED value via the resolver, so a
2584
+ // property styled through a className utility (F5's class routing) OR a CSS file
2585
+ // resolves too — fixing the "class-styled props show blank" read-back gap. Under
2586
+ // vitest's `node` env the default resolver falls back to the element's inline
2587
+ // `.style`, so headless fixtures keep working unchanged.
2588
+ // THE AUTHORED VALUE WINS THE FIELD when the element carries one inline:
2589
+ // an author who typed 257 into W and reads back 247.262 — the computed
2590
+ // width of an inline element that ignores `width`, or of a flex item
2591
+ // its parent shrank — concludes the edit "reverted" (runhuman pass 137,
2592
+ // twice). The field shows what the source says; the computed value
2593
+ // remains the fallback for class- and stylesheet-styled props, which is
2594
+ // what the resolver read below exists for.
2595
+ const authored = styleProp(n.el.style, prop);
2596
+ const raw =
2597
+ typeof authored === 'string' && authored !== ''
2598
+ ? authored
2599
+ : typeof authored === 'number'
2600
+ ? String(authored)
2601
+ : getComputedStyleValue(n.el, prop, this.computedStyle);
2602
+ const declaredType = STYLE_PROPERTY_TYPE.get(prop);
2603
+ // A `type: 'number'` descriptor (fontSize/width/height/padding/margin/
2604
+ // borderRadius/gap) needs an actual number for the generic numeric
2605
+ // input to render/edit correctly — a REAL `CSSStyleDeclaration` always
2606
+ // hands these back as a unit-suffixed STRING (e.g. `"16px"`, never a
2607
+ // bare `16`), which `Inspector.tsx`'s `typeof v === 'number'` check
2608
+ // would otherwise silently read as `0` (this repo's own
2609
+ // `react-world-authoring-adapter.test.ts` fixtures never caught this —
2610
+ // a hand-built plain-object `style: {}` can hold a bare JS number
2611
+ // directly, which a real DOM element's `style` never does). A
2612
+ // `type: 'color'` descriptor needs `#rrggbb` for the same reason —
2613
+ // see `cssColorToHex`'s doc comment.
2614
+ if (declaredType === 'number') return numericStyleValue(raw);
2615
+ if (declaredType === 'color') return cssColorToHex(raw);
2616
+ return raw || undefined; // an unset computed value reads '' — surface as blank
2617
+ },
2618
+ set: (id, path, value) => {
2619
+ if (this.portableHierarchyNode(id)) return;
2620
+ if (this.portableDocuments.some((document) => document.id === id)) return;
2621
+ if (this.portableStories.some((story) => story.id === id)) {
2622
+ if (!path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) return;
2623
+ const name = path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length);
2624
+ const current = this.portableStoryArgs.get(id);
2625
+ if (!current || !(name in current)) return;
2626
+ const next = { ...current, [name]: value };
2627
+ this.portableStoryArgs.set(id, next);
2628
+ this.onPortableStoryArgsChanged?.(id, { ...next });
2629
+ this.store.notifyIngestEdit();
2630
+ return;
2631
+ }
2632
+ // D4 — see the matching `get` branch's doc comment.
2633
+ if (path === 'locked') {
2634
+ if (value) this.lockedIds.add(id);
2635
+ else this.lockedIds.delete(id);
2636
+ this.store.notifyIngestEdit();
2637
+ return;
2638
+ }
2639
+ if (path === 'visible') {
2640
+ const cssValue = value ? 'visible' : 'hidden';
2641
+ this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
2642
+ return this.pipedSourceWrite(() => this.writeStyle(id, 'visibility', cssValue));
2643
+ }
2644
+ if (path.startsWith(PROP_PATH_PREFIX)) {
2645
+ // A4 — echo BEFORE the async write so `inspector.get` shows the new value at once.
2646
+ this.setEcho(id, path, value);
2647
+ return this.pipedSourceWrite(() =>
2648
+ this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value)),
2649
+ );
2650
+ }
2651
+ if (path.startsWith(TOKEN_PATH_PREFIX)) {
2652
+ // Tokens-as-noun: a token declared by a project stylesheet is EDITED
2653
+ // AT ITS DECLARATION (`writeCss` on the traced rule) — the one write
2654
+ // that moves every binding at once. Undeclared tokens (script-set)
2655
+ // refuse with the pointer; their descriptor is read-only anyway.
2656
+ this.setEcho(id, path, value);
2657
+ return this.pipedSourceWrite(() =>
2658
+ this.writeTokenValue(id, path.slice(TOKEN_PATH_PREFIX.length), String(value)),
2659
+ );
2660
+ }
2661
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
2662
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
2663
+ // A4 — echo BEFORE the async write (the desync fix): the field reflects the commit
2664
+ // within one frame; the write path clears this echo if the write is refused, and HMR
2665
+ // clears it once the re-render lands.
2666
+ this.setEcho(id, path, value);
2667
+ return this.pipedSourceWrite(() => this.writeStyle(id, prop, value as string | number));
2668
+ },
2669
+ remove: (id, path) => {
2670
+ // U2 — remove a stale CSS longhand override so its shorthand actually
2671
+ // wins (a uniform border/radius edit clears the per-side/per-corner
2672
+ // longhands a prior non-uniform edit wrote; without this the longhand
2673
+ // silently overrides the shorthand on reload — the D1 defect). Style
2674
+ // paths only; a `prop.`/`token.` path has no removable-override
2675
+ // meaning here (no-op). `removeStyle` is itself a source no-op when the
2676
+ // longhand isn't authored, so calling this unconditionally for all four
2677
+ // corners / twelve side-props is safe and never pollutes clean source.
2678
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
2679
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
2680
+ // Point the removed longhand's echo at the value it actually renders at
2681
+ // once the override is gone — the shorthand's current (just-committed)
2682
+ // value — so `inspector.get(longhand)` reports the resolved corner value,
2683
+ // not a stale override or an empty computed read, and stays consistent
2684
+ // after HMR clears the echo (the shorthand then cascades to it).
2685
+ const shorthand = LONGHAND_TO_SHORTHAND[prop];
2686
+ if (shorthand)
2687
+ this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
2688
+ else this.clearEcho(id, path);
2689
+ // Through the SAME pipe `set` uses, so a removal answers for itself with
2690
+ // an awaited per-edit ack instead of being fired into the void. It is the
2691
+ // only door that can express byte-ABSENCE — `set` writes a value, and a
2692
+ // longhand set back to the shorthand's value is still a longhand in the
2693
+ // file.
2694
+ return this.pipedSourceWrite(() => this.removeStyleProp(id, prop));
2695
+ },
2696
+ };
2697
+
2698
+ readonly assetSubject: AssetSubjectProvider = {
2699
+ get: (id) => this.assetSubjectForNode(this.snapshot(), id),
2700
+ entries: () => {
2701
+ const tree = walkOidTree(this.assetRoot);
2702
+ return [...tree.nodes.keys()].flatMap((id) => {
2703
+ const subject = this.assetSubjectForNode(tree, id);
2704
+ return subject ? [{ id, subject }] : [];
2705
+ });
2706
+ },
2707
+ };
2708
+
2709
+ private assetSubjectForNode(tree: OidTree, id: string): AuthoringAssetSubject | null {
2710
+ const node = tree.nodes.get(id);
2711
+ if (!node || node.tag !== 'svg' || !node.el.outerHTML) return null;
2712
+ const source = this.oidIndex.get(node.oid);
2713
+ return {
2714
+ kind: 'image',
2715
+ name: this.componentName(node) ?? this.elementLabel(node, tree),
2716
+ mediaType: 'image/svg+xml',
2717
+ text: node.el.outerHTML,
2718
+ ...(source?.file ? { sourcePath: projectSourcePath(source.file) } : {}),
2719
+ };
2720
+ }
2721
+
2722
+ /** Storybook Controls descriptors for an ordinary CSF story node. Functions
2723
+ * and other non-serializable implementation values stay out of the form;
2724
+ * they remain present in the complete args object passed to the composed
2725
+ * story. The rule itself lives in `stories/story-arg-descriptors.ts` — the
2726
+ * three-story gallery's per-story document describes its args through the
2727
+ * same function. */
2728
+ private portableStoryArgDescriptors(storyId: string): PropertyDescriptor[] {
2729
+ return storyArgPropertyDescriptors(
2730
+ this.portableStoryArgs.get(storyId) ?? {},
2731
+ PORTABLE_STORY_ARG_PATH_PREFIX,
2732
+ );
2733
+ }
2734
+
2735
+ /** Current complete args for a portable story, including callbacks omitted
2736
+ * from the visual Controls list. Returned as a copy so inspector UI cannot
2737
+ * mutate adapter state behind the provider contract. */
2738
+ portableArgs(storyId: string): Record<string, unknown> | null {
2739
+ const args = this.portableStoryArgs.get(storyId);
2740
+ return args ? { ...args } : null;
2741
+ }
2742
+
2743
+ /**
2744
+ * The CSF WRITE half (design ledger: stories were composed and framed
2745
+ * everywhere, written nowhere). Three verbs over the story's OWN module
2746
+ * through the csf-story door; every planner refusal surfaces as the
2747
+ * returned error string, loudly, never a silent no-op. `saveStoryAs`
2748
+ * writes the story's CURRENT SESSION ARGS — the "save what I am looking
2749
+ * at" gesture the board's arg controls set up.
2750
+ */
2751
+ /**
2752
+ * The element's classes as NAMED-STYLE facts (design ledger: named styles,
2753
+ * Webflow prior art): each class token the element wears, how many elements
2754
+ * in its own game-CSS scope share it (Webflow's "N elements share this
2755
+ * class" feedback loop), and — when a first-party stylesheet declares a
2756
+ * single-class rule for it — the stylesheet it lives in (which is what makes
2757
+ * it an editable named style rather than a utility/generated class).
2758
+ */
2759
+ elementClasses(id: string): { name: string; count: number; styledIn: string | null }[] {
2760
+ const n = this.snapshot().nodes.get(id);
2761
+ const el = n?.el as unknown as Element | undefined;
2762
+ if (!el || typeof el.getAttribute !== 'function') return [];
2763
+ const tokens = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
2764
+ // Shared-count scope: the element's own game-CSS scope root — the same
2765
+ // boundary the scoped stylesheet paints — falling back to the document
2766
+ // for a fixture element outside any scope.
2767
+ const scope =
2768
+ (typeof el.closest === 'function' ? el.closest('[data-vgai-game-styles]') : null) ??
2769
+ el.ownerDocument;
2770
+ return tokens.map((name) => {
2771
+ let count = 0;
2772
+ try {
2773
+ count = scope?.querySelectorAll?.(`.${CSS.escape(name)}`).length ?? 0;
2774
+ } catch {
2775
+ // A fixture scope without querySelectorAll — count stays 0.
2776
+ }
2777
+ return { name, count, styledIn: findClassRuleSource(name)?.sourceFile ?? null };
2778
+ });
2779
+ }
2780
+
2781
+ /**
2782
+ * The named-style write: APPLY when any first-party stylesheet already
2783
+ * declares `.className` (the class is an existing style — adding the token
2784
+ * is the whole gesture), else CREATE — mint the rule in the element's own
2785
+ * stylesheet (the last matched first-party rule's file, else the first
2786
+ * first-party stylesheet in the document), moving the element's literal
2787
+ * inline declarations into it. `remove` strips the token. Refusals are
2788
+ * returned AND logged, so the calling UI can show the sentence.
2789
+ */
2790
+ async writeNamedStyle(
2791
+ id: string,
2792
+ op: { kind: 'set' | 'remove'; className: string },
2793
+ ): Promise<{ changed: boolean; created?: boolean; error?: string }> {
2794
+ const n = this.snapshot().nodes.get(id);
2795
+ if (!n) return { changed: false, error: `"${id}" no longer resolves in this root` };
2796
+ const refuse = (error: string): { changed: false; error: string } => {
2797
+ console.warn(`[ReactRootAuthoringAdapter] named-style ${op.kind} refused: ${error}`);
2798
+ return { changed: false, error };
2799
+ };
2800
+ if (op.kind === 'remove') {
2801
+ const res = await writeNamedStyle({ op: 'remove', oid: n.oid, className: op.className });
2802
+ if (!res.changed) return refuse(res.error ?? 'no change');
2803
+ this.dirty = true;
2804
+ this.store.notifyIngestEdit();
2805
+ return { changed: true };
2806
+ }
2807
+ const existing = findClassRuleSource(op.className);
2808
+ if (existing) {
2809
+ const res = await writeNamedStyle({ op: 'apply', oid: n.oid, className: op.className });
2810
+ if (!res.changed) return refuse(res.error ?? 'the element already wears this class');
2811
+ this.dirty = true;
2812
+ this.store.notifyIngestEdit();
2813
+ return { changed: true };
2814
+ }
2815
+ // The rule's home: the last matched first-party rule's own file, else any
2816
+ // first-party stylesheet in the document — and with NEITHER, omit `file`
2817
+ // and the server MINTS a stylesheet beside the element's module (adding
2818
+ // the bare css import), so the first named style in a css-less project
2819
+ // works instead of refusing.
2820
+ // Candidate homes are the GAME's stylesheets. The dev-id enumeration also
2821
+ // sees the editor's own vite-served CSS, and offering one of those as the
2822
+ // home would (rightly) bounce off the server's scope guard — so mirror the
2823
+ // guard's exclusions here and let a css-less project fall through to the
2824
+ // server's minting path instead.
2825
+ const gameCss = (f: string | undefined): boolean =>
2826
+ f !== undefined &&
2827
+ !f.includes('/packages/editor/') &&
2828
+ !f.includes('/node_modules/') &&
2829
+ !f.includes('/vendor/');
2830
+ const matched = this.matchedCssRules(n.el).filter((rule) => gameCss(rule.sourceFile));
2831
+ const cssFile =
2832
+ matched[matched.length - 1]?.sourceFile ?? firstPartyStylesheetFiles().find(gameCss);
2833
+ const res = await writeNamedStyle({
2834
+ op: 'create',
2835
+ oid: n.oid,
2836
+ className: op.className,
2837
+ ...(cssFile !== undefined ? { file: cssFile } : {}),
2838
+ });
2839
+ if (!res.changed) return refuse(res.error ?? 'no change');
2840
+ this.dirty = true;
2841
+ this.store.notifyIngestEdit();
2842
+ return { changed: true, created: true };
2843
+ }
2844
+
2845
+ async writeStory(
2846
+ storyId: string,
2847
+ op:
2848
+ | { kind: 'save-as'; name: string }
2849
+ | { kind: 'rename'; newName: string }
2850
+ | { kind: 'delete' },
2851
+ ): Promise<{ changed: boolean; error?: string }> {
2852
+ const story = this.portableStories.find((candidate) => candidate.id === storyId);
2853
+ if (!story) return { changed: false, error: `unknown portable story '${storyId}'` };
2854
+ if (!story.name || !story.modulePath) {
2855
+ return {
2856
+ changed: false,
2857
+ error: `story '${story.label}' carries no source address (name/modulePath) — it is not writable from this session`,
2858
+ };
2859
+ }
2860
+ const request =
2861
+ op.kind === 'save-as'
2862
+ ? {
2863
+ op: 'save' as const,
2864
+ file: story.modulePath,
2865
+ name: op.name,
2866
+ args: this.portableStoryArgs.get(storyId) ?? {},
2867
+ }
2868
+ : op.kind === 'rename'
2869
+ ? { op: 'rename' as const, file: story.modulePath, name: story.name, newName: op.newName }
2870
+ : { op: 'delete' as const, file: story.modulePath, name: story.name };
2871
+ const result = await writeCsfStory(request);
2872
+ if (!result.changed) {
2873
+ console.warn(
2874
+ `[ReactRootAuthoringAdapter] csf-story ${op.kind} refused for '${story.label}': ` +
2875
+ `${result.error ?? 'no change'}`,
2876
+ );
2877
+ return { changed: false, ...(result.error !== undefined ? { error: result.error } : {}) };
2878
+ }
2879
+ // Keep the IN-MEMORY story list truthful about the file it just rewrote
2880
+ // (blind-walk finding: rename left the old identity live, so the very
2881
+ // next delete refused with "no story export named '<old>'" until a full
2882
+ // page reload). Rename updates the entry it addressed; delete drops it.
2883
+ // Save-as visibility still needs the discovery refresh — recorded, not
2884
+ // silently claimed.
2885
+ if (op.kind === 'rename') {
2886
+ story.name = op.newName;
2887
+ story.label = op.newName.replace(/([a-z])([A-Z])/g, '$1 $2');
2888
+ } else if (op.kind === 'delete') {
2889
+ const index = this.portableStories.indexOf(story);
2890
+ if (index >= 0) this.portableStories.splice(index, 1);
2891
+ if (this.activeStoryId === storyId) this.activeStoryId = null;
2892
+ }
2893
+ this.dirty = true;
2894
+ this.store.notifyIngestEdit();
2895
+ return { changed: true };
2896
+ }
2897
+
2898
+ /** Restore the selected story's composed CSF defaults. */
2899
+ resetPortableArgs(storyId: string): void {
2900
+ const story = this.portableStories.find((candidate) => candidate.id === storyId);
2901
+ if (!story) return;
2902
+ const next = { ...(story.args ?? {}) };
2903
+ this.portableStoryArgs.set(storyId, next);
2904
+ this.onPortableStoryArgsChanged?.(storyId, { ...next });
2905
+ this.store.notifyIngestEdit();
2906
+ }
2907
+
2908
+ /** Cap 4: PropertyDescriptors for a node's editable component props (read off the fiber). */
2909
+ private propDescriptors(id: string): PropertyDescriptor[] {
2910
+ const n = this.snapshot().nodes.get(id);
2911
+ const cp = n ? getComponentProps(n.el) : null;
2912
+ if (!cp) return [];
2913
+ return Object.keys(cp.props).map((name) => ({
2914
+ path: `${PROP_PATH_PREFIX}${name}`,
2915
+ label: name,
2916
+ type: 'string' as const,
2917
+ group: 'Props',
2918
+ }));
2919
+ }
2920
+
2921
+ /** Cap 7: read-only PropertyDescriptors for the document's design tokens (`:root` custom
2922
+ * properties, incl. Tailwind v4 @theme/oklch), shown under a "Tokens" section. */
2923
+ private tokenDescriptors(): PropertyDescriptor[] {
2924
+ // Tokens-as-noun: a token whose `:root` declaration traces to a project
2925
+ // stylesheet is WRITABLE (the edit lands on that declaration through the
2926
+ // css door and every binding moves at once); one set by script — a theme
2927
+ // data module mirroring itself onto `:root`, or inherited host style —
2928
+ // stays read-only, because its truth lives in that source, not in a rule
2929
+ // the css writer can reach.
2930
+ return this.designTokens().map((t) => ({
2931
+ path: `${TOKEN_PATH_PREFIX}${t.name}`,
2932
+ label: t.name,
2933
+ type: 'string' as const,
2934
+ group: 'Tokens',
2935
+ readonly: !t.declaredIn,
2936
+ }));
2937
+ }
2938
+
2939
+ /**
2940
+ * The token-edit write: `writeCss` on the token's TRACED `:root`
2941
+ * declaration. Refusals are loud and name the remedy — an untraced token's
2942
+ * edit door is its declaring source (plain data), not this path.
2943
+ */
2944
+ private async writeTokenValue(id: string, name: string, value: string): Promise<boolean> {
2945
+ const echoPath = `${TOKEN_PATH_PREFIX}${name}`;
2946
+ const token = this.designTokens().find((t) => t.name === name);
2947
+ if (!token) {
2948
+ this.clearEcho(id, echoPath);
2949
+ console.warn(`[ReactRootAuthoringAdapter] unknown design token "${name}".`);
2950
+ return false;
2951
+ }
2952
+ if (!token.declaredIn) {
2953
+ this.clearEcho(id, echoPath);
2954
+ console.warn(
2955
+ `[ReactRootAuthoringAdapter] token "${name}" has no stylesheet declaration to edit — ` +
2956
+ 'it is set by script (a theme data module mirroring itself onto :root, or host ' +
2957
+ 'style). Edit that source; it is plain data.',
2958
+ );
2959
+ return false;
2960
+ }
2961
+ const backend = this.writeBackend;
2962
+ if (!backend?.writeCss) {
2963
+ this.clearEcho(id, echoPath);
2964
+ console.warn(
2965
+ `[ReactRootAuthoringAdapter] cannot write token "${name}": no css-capable ` +
2966
+ 'source-write backend in this session (hosted/no dev server).',
2967
+ );
2968
+ return false;
2969
+ }
2970
+ const res = await backend.writeCss(
2971
+ token.declaredIn.sourceFile,
2972
+ token.declaredIn.selector,
2973
+ name,
2974
+ value,
2975
+ );
2976
+ if (!res.changed) {
2977
+ this.clearEcho(id, echoPath);
2978
+ console.warn(
2979
+ `[ReactRootAuthoringAdapter] token write refused/no-op for "${name}" at ` +
2980
+ `${token.declaredIn.sourceFile} (${token.declaredIn.selector}): ${res.error ?? 'no change'}`,
2981
+ );
2982
+ return false;
2983
+ }
2984
+ this.dirty = true;
2985
+ this.store.notifyIngestEdit();
2986
+ return true;
2987
+ }
2988
+
2989
+ /**
2990
+ * Portable Storybook CSF stories. `active`/`apply` are world-level because
2991
+ * applying a story remounts the whole React root. `storiesFor` narrows
2992
+ * presentation to the stories of the node's own component
2993
+ * (`portableStoriesForNode`) — but the world question, `WORLD_SCOPE_NODE_ID`,
2994
+ * is still answered with the whole list, because that is the id the shell
2995
+ * probes this provider's SCOPE with and the scope is world-level.
2996
+ * `apply` validates the id, updates `activeStoryId`, asks the design-time
2997
+ * layer to render that composed CSF story, and notifies the shell.
2998
+ */
2999
+ readonly stories: StoriesProvider = {
3000
+ storiesFor: (nodeId): StoryRef[] =>
3001
+ this.portableStories.length > 0 ? this.portableStoriesForNode(nodeId) : [],
3002
+ active: (_nodeId) => this.activeStoryId,
3003
+ apply: (_nodeId, storyId) => {
3004
+ if (storyId !== null && !this.portableStories.some((story) => story.id === storyId)) {
3005
+ console.warn(
3006
+ `[ReactRootAuthoringAdapter] stories.apply: unknown portable CSF story id "${storyId}" — ignoring.`,
3007
+ );
3008
+ return;
3009
+ }
3010
+ // Portable CSF always renders a design state. Clearing the picker means
3011
+ // "restore the document's default", not "orphan the live DOM roots
3012
+ // outside every story row".
3013
+ this.activeStoryId = storyId ?? this.portableDefaultStoryId;
3014
+ this.onPortableStoryApplied?.(storyId);
3015
+ this.store.notifyIngestEdit();
3016
+ },
3017
+ // This provider's states ARE the project's portable CSF, so it is the one
3018
+ // that can say whether discovery ran at all. Without this the section that
3019
+ // draws the empty list had to reach into the CSF registry itself, which is
3020
+ // one particular provider's private business.
3021
+ unavailable: () => storyDiscoveryUnavailable(),
3022
+ };
3023
+
3024
+ readonly structure: StructureProvider = {
3025
+ // Every structural op journals complete next-file text. Undo never needs to
3026
+ // address a moved or deleted element by an OID that may no longer exist.
3027
+ // The new element's OID is minted by the server on the next index, so the
3028
+ // id half is '' and the ack half is the write itself (`StructuralIdWrite`).
3029
+ create: (kind, parentId) => ({
3030
+ id: '',
3031
+ ack: this.structOp(parentId ?? '', 'create', { wrapperTag: kind }),
3032
+ }),
3033
+ // Returns `removeElement`'s own `Promise<void>` (not `void this....`) so
3034
+ // `deleteSelection` (`editor-hotkeys.ts`) can `await` each id's write
3035
+ // before firing the next — see the CORRECTNESS INVARIANT comment below
3036
+ // for why that serialization (plus deletion ORDER) is what makes the
3037
+ // per-id fallback loop sound for an adapter WITHOUT `removeMany` (any
3038
+ // adapter override lacking `structure.removeMany` — `deleteSelection`
3039
+ // always prefers a batched `removeMany` when present, see below).
3040
+ remove: (id) => this.removeElement(id),
3041
+ // delete-order-residual fix (bug-panel follow-up to the multi-delete
3042
+ // corruption fix, 50f90a6d) — SOUND batched delete, ONE undo entry for
3043
+ // the whole selection. D4.R2 originally investigated and REJECTED
3044
+ // `removeMany` here as unsound because the OBVIOUS implementation is N
3045
+ // separate `structOp`-style calls, each targeting its OID's `{file,
3046
+ // line, col}` from the SERVER's `OidStore.index`
3047
+ // (`vite-plugin-ui-oid.ts`'s `handleStruct`) — exactly the per-id
3048
+ // `remove` loop's own stale-offset hazard (see the CORRECTNESS INVARIANT
3049
+ // comment below), just without the ordering discipline that loop needs
3050
+ // to stay sound. This implementation is NOT that: `removeManyElements`
3051
+ // posts every id's raw oid in ONE request to `/__ui-source/struct-many`
3052
+ // (`handleStructMany`, `vite-plugin-ui-oid.ts`), which resolves every
3053
+ // oid's offset against a SINGLE shared `readFileSync` snapshot (never a
3054
+ // per-id re-read, so no write-to-write staleness is even possible) and
3055
+ // applies them highest-offset-first in one pass, one write. That is also
3056
+ // why this is the fix for the DOM-reordered-vs-source residual the
3057
+ // per-id fallback below still carries: this batch never consults
3058
+ // `collectAllNodeIds`/hierarchy-walk order at all, so it is correct
3059
+ // regardless of whether the live DOM's child order matches the .tsx
3060
+ // source order. (Explicitly NOT a live re-transform/re-scan per id
3061
+ // either — see `deleteElements`'s doc comment in `writer.ts` for why
3062
+ // that would reopen a DIFFERENT unsoundness: occurrence-index-based oid
3063
+ // identity churns when same-tag siblings are removed mid-batch.)
3064
+ // Returns `removeManyElements`'s own `Promise<void>` (not `void
3065
+ // this....`) so `deleteSelection` can `await` the whole batch's write
3066
+ // landing before it returns, same reason `remove` above does.
3067
+ removeMany: (ids) => this.removeManyElements(ids),
3068
+ //
3069
+ // CORRECTNESS INVARIANT for the per-id `remove` loop (bug-panel reopen,
3070
+ // post-D4.R2) — `deleteSelection` (`editor-hotkeys.ts`) falls back to
3071
+ // this loop only when `removeMany` is ABSENT (a different adapter
3072
+ // override); for THIS adapter `removeMany` above is always preferred, so
3073
+ // this loop is dead code for react-world today, kept sound and
3074
+ // documented for any future override without a batched delete. It is
3075
+ // safe ONLY under TWO conditions `deleteSelection` enforces together —
3076
+ // neither held before the 50f90a6d fix (an unsorted, unawaited loop was
3077
+ // a real source-corruption hazard, reachable by an ordinary top-first
3078
+ // marquee/multi-select delete) — AND both assume the caller's walk order
3079
+ // (`collectAllNodeIds`, DOM order for this adapter) matches true SOURCE
3080
+ // order (the DOM-reordered-vs-source residual this file's `removeMany`
3081
+ // fixes for THIS adapter specifically):
3082
+ // 1. REVERSE (walk-)DOCUMENT ORDER — delete the bottom-most element
3083
+ // first, per the caller's own hierarchy walk. Every element's
3084
+ // `{line, col}` in `OidStore.index` is a snapshot from the LAST
3085
+ // real parse and is never refreshed between same-file writes (only
3086
+ // by a real Vite `transform()` re-run — the async HMR round trip
3087
+ // `pendingSourceReconcile` already tracks). Deleting bottom-up
3088
+ // means every write only ever removes source that sits BELOW every
3089
+ // id still queued — nothing ABOVE a queued id's own offset ever
3090
+ // shifts, so that offset stays valid no matter how many of its
3091
+ // later-walked siblings/descendants have already been removed. A
3092
+ // descendant is later in DFS pre-order than its ancestor, so this
3093
+ // same reverse-pre-order rule also deletes a selected descendant
3094
+ // before a selected ancestor — required, since deleting the
3095
+ // ancestor first would remove the descendant's own source out from
3096
+ // under it before its turn. This is sound ONLY when walk order ==
3097
+ // source order (true for ordinary JSX; false when a component's
3098
+ // live DOM child order is a runtime permutation of its JSX source,
3099
+ // e.g. `{[...els].reverse()}` over an array of DISTINCT element
3100
+ // values).
3101
+ // 2. SERIALIZED WRITES — `deleteSelection` `await`s each `remove(id)`
3102
+ // (this method returns `removeElement`'s promise instead of firing
3103
+ // it `void`) before starting the next. `writeStruct` is a real
3104
+ // network POST in production (`source-write-backend.ts`); two
3105
+ // in-flight, un-awaited requests for the SAME file can each read it
3106
+ // BEFORE either has written back, so whichever write lands second
3107
+ // silently clobbers (loses) the first — a lost-update race
3108
+ // independent of ordering. Awaiting each id in turn guarantees
3109
+ // request N only starts once request N-1's write has landed.
3110
+ // Together, every write in the sequence reads a file that already
3111
+ // reflects every prior delete in the sequence, and targets an offset
3112
+ // still valid against that file — the "safe" claim this comment used to
3113
+ // make unconditionally, which was FALSE for the raw (unsorted, top-first)
3114
+ // selection order `deleteSelection` used to iterate in
3115
+ // (the structural-undo cases' own "offset-staleness
3116
+ // hazard" case, reachable through the real `deleteSelection` path, not
3117
+ // just a hand-picked unsafe direct-adapter call).
3118
+ // Hands the write's promise back for the same reason `remove` does (the
3119
+ // CORRECTNESS INVARIANT above): `duplicateSelection` awaits each id so two
3120
+ // same-file writes can never be in flight together.
3121
+ duplicate: (id) => ({ id, ack: this.structOp(id, 'duplicate') }),
3122
+ reparent: (id, newParentId) => {
3123
+ const parentOid = newParentId ? this.oidOf(newParentId) : undefined;
3124
+ if (!parentOid) {
3125
+ return this.structRefusal(
3126
+ `reparent refused: "${newParentId ?? 'the document root'}" has no authored source element to move into.`,
3127
+ );
3128
+ }
3129
+ return this.structOp(id, 'reparent', { parentOid });
3130
+ },
3131
+ // T0 (spec 27 §2): bring the pre-existing off-contract `reorder(id, beforeId,
3132
+ // parentId)` method (below) onto the contract. D2.b (spec 27 §6) fix: a
3133
+ // `null` `beforeSiblingId` means "move to the end of id's OWN current
3134
+ // parent" (this contract method's own doc comment) — that needs a REAL
3135
+ // `parentOid` to target (`reorder`'s own `parentStart != null` end-of-list
3136
+ // branch), so resolve `id`'s CURRENT parent from the live snapshot for
3137
+ // that case. A non-null `beforeSiblingId` already fully determines the
3138
+ // target position via `targetOid` alone, so `parentId` stays `null` there
3139
+ // (matching this method's pre-D2 behavior — no other caller of this
3140
+ // contract method existed before D2.b's canvas drag-to-reorder, which is
3141
+ // the first to actually exercise the null/"move to end" case).
3142
+ reorder: (id, beforeSiblingId) => {
3143
+ const parentId =
3144
+ beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
3145
+ return this.reorder(id, beforeSiblingId, parentId);
3146
+ },
3147
+ // D3.e (spec 27 §2 T0 leftover) — the generic HTML kinds `create`
3148
+ // genuinely supports (see `CREATABLE_KINDS`'s doc comment below).
3149
+ // `parentId` is ignored: every OID node accepts any of these as a plain
3150
+ // child, mirroring `ui-authoring-adapter.ts`'s identically-parentId-
3151
+ // agnostic `creatableKinds`.
3152
+ creatableKinds: () => [...CREATABLE_KINDS],
3153
+ // D3.a (spec 27 §6) — bring the pre-existing off-contract `wrap`/`unwrap`
3154
+ // methods (below) onto the contract so the canvas context menu can reach
3155
+ // them through the adapter interface alone (rule zero).
3156
+ wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
3157
+ unwrap: (id) => this.unwrap(id),
3158
+ };
3159
+
3160
+ readonly assetDrop: AssetDropProvider = {
3161
+ accepts: (nodeId, assetPath, context) => this.assetDropPlan(nodeId, assetPath, context).ok,
3162
+ drop: (nodeId, assetPath, context) => {
3163
+ const plan = this.assetDropPlan(nodeId, assetPath, context);
3164
+ if (!plan.ok) return this.structRefusal(`asset drop refused — ${plan.reason}`);
3165
+ return this.structOp(plan.parentId, 'create', {
3166
+ snippet: plan.snippet,
3167
+ ...(plan.ensureImport ? { ensureImport: plan.ensureImport } : {}),
3168
+ });
3169
+ },
3170
+ };
3171
+
3172
+ private assetDropPlan(
3173
+ nodeId: string,
3174
+ assetPath: string,
3175
+ context?: AssetDropContext,
3176
+ ): DomAssetDropPlan {
3177
+ if (!this.writeBackend) {
3178
+ return { ok: false, reason: 'This session has no source writer, so nothing can be added.' };
3179
+ }
3180
+ const node = this.snapshot().nodes.get(nodeId);
3181
+ if (!node) return { ok: false, reason: 'That row has no authored JSX element.' };
3182
+ if (!canContainDomChildren(node.el, node.tag)) {
3183
+ return { ok: false, reason: `<${node.tag}> cannot contain child elements.` };
3184
+ }
3185
+ const component = context?.item?.kind === 'component' ? context.item : null;
3186
+ if (component) {
3187
+ if (component.surface !== 'dom') {
3188
+ return {
3189
+ ok: false,
3190
+ reason: `${component.name} is a ${component.surface} component, not a React UI component.`,
3191
+ };
3192
+ }
3193
+ const from = this.sourceLocation(nodeId)?.file;
3194
+ if (!from) {
3195
+ return { ok: false, reason: 'The target source location has not loaded yet.' };
3196
+ }
3197
+ return {
3198
+ ok: true,
3199
+ parentId: nodeId,
3200
+ snippet: `<${component.name} />`,
3201
+ ensureImport: {
3202
+ name: component.name,
3203
+ module: this.relativeProjectModule(from, component.sourcePath),
3204
+ kind: component.exportKind,
3205
+ },
3206
+ };
3207
+ }
3208
+ if (!IMAGE_ASSET_RE.test(assetPath)) {
3209
+ return {
3210
+ ok: false,
3211
+ reason: `${assetPath} is not a browser image asset and has no DOM element to become.`,
3212
+ };
3213
+ }
3214
+ return {
3215
+ ok: true,
3216
+ parentId: nodeId,
3217
+ snippet: `<img src=${JSON.stringify(assetPath)} alt="" />`,
3218
+ };
3219
+ }
3220
+
3221
+ private relativeProjectModule(fromFile: string, targetFile: string): string {
3222
+ return relativeImportSpecifier(projectSourcePath(fromFile), projectSourcePath(targetFile));
3223
+ }
3224
+ /** Wrap the element in a new container (Cap 5). */
3225
+ wrap(id: string, wrapperTag = 'div'): Promise<WriteAck> {
3226
+ return this.structOp(id, 'wrap', { wrapperTag });
3227
+ }
3228
+
3229
+ /** Replace the element with its children (Cap 5). */
3230
+ unwrap(id: string): Promise<WriteAck> {
3231
+ return this.structOp(id, 'unwrap');
3232
+ }
3233
+
3234
+ /** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
3235
+ * `beforeId` is null (Cap 5, layer-tree drag-to-reorder). */
3236
+ reorder(id: string, beforeId: string | null, parentId: string | null): Promise<WriteAck> {
3237
+ const opts: { targetOid?: string; parentOid?: string } = {};
3238
+ const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
3239
+ const parentOid = parentId ? this.oidOf(parentId) : undefined;
3240
+ if (targetOid) opts.targetOid = targetOid;
3241
+ if (parentOid) opts.parentOid = parentOid;
3242
+ return this.structOp(id, 'reorder', opts);
3243
+ }
3244
+
3245
+ /** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
3246
+ private oidOf(id: string): string | undefined {
3247
+ return this.snapshot().nodes.get(id)?.oid;
3248
+ }
3249
+
3250
+ /**
3251
+ * C4 (spec §9): map an entity id to its component/source location, when available —
3252
+ * the read side of `oidIndex` (populated from `SourceWriteBackend.index()`, same as
3253
+ * `toEditorNode`'s label lookup). Returns `undefined` when the id has no OID (e.g. a
3254
+ * catalog node) or the index hasn't resolved (no dev-server backend, or the index
3255
+ * fetch hasn't landed yet) — an honest "unavailable", never a guess. The editor UI
3256
+ * (`@volter/editor-game/react/react-inspector-section.tsx`'s `LaneDisclosure`) uses this to offer copy-path /
3257
+ * go-to-line without ever exposing a write surface.
3258
+ */
3259
+ sourceLocation(id: string): OidEntry | undefined {
3260
+ const oid = this.oidOf(id);
3261
+ if (!oid) return undefined;
3262
+ return this.oidIndex.get(oid);
3263
+ }
3264
+
3265
+ /** Run a structural op through the persistence pipe (loud degradation without
3266
+ * a writer). */
3267
+ private structOp(id: string, op: string, opts?: StructOpOptions): Promise<WriteAck> {
3268
+ return this.structPipe.structOp(this.oidOf(id), id, op, opts, JSX_SOURCE_DESTINATION);
3269
+ }
3270
+
3271
+ /**
3272
+ * T0 (spec 27 §4, B1) — the write body extracted from {@link writeStyle}, returning
3273
+ * whether source changed so a multi-property box gesture can reconcile once.
3274
+ * `priorInlineOverride`, when given, is used as the captured
3275
+ * PRE-gesture inline value instead of re-reading `n.el.style` — needed because
3276
+ * `boxEdit.apply` already mutated the live inline style for live preview before
3277
+ * `end` gets here, so a fresh DOM read would see the LAST previewed value, not the
3278
+ * true original (see {@link boxEditSession}'s doc comment). Unused by the plain CSS
3279
+ * cascade branch below (`pickCssRuleTarget`'s own `prevValue` comes from the matched
3280
+ * rule's text, not inline style, and is unaffected either way).
3281
+ */
3282
+ /** The last style write this adapter refused, for the gesture that ends
3283
+ * with nothing written to say WHY where the author is looking. */
3284
+ private lastStyleWriteRefusal: string | null = null;
3285
+ private refuseStyleWrite(message: string): void {
3286
+ this.lastStyleWriteRefusal = message;
3287
+ console.warn(message);
3288
+ }
3289
+
3290
+ private async writeStyleEntry(
3291
+ id: string,
3292
+ prop: string,
3293
+ value: string | number,
3294
+ priorInlineOverride?: string,
3295
+ backend: SourceWriteBackend | undefined = this.writeBackend,
3296
+ ): Promise<boolean> {
3297
+ // A4 — the echo key `inspector.set` populated for this write; cleared on any failure
3298
+ // return below so a refused/unbacked write can't leave the field stuck on a value the
3299
+ // source never took.
3300
+ const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
3301
+ const n = this.snapshot().nodes.get(id);
3302
+ if (!n) {
3303
+ this.clearEcho(id, echoPath);
3304
+ // THE ONE SILENT LOSS on this path, and the one the write-race report is
3305
+ // made of: the pipe turns a `false` here into an ack of
3306
+ // `live-only (not saved)` / `persisted: false`, which reads exactly like
3307
+ // the honest live-only floor while in fact NOTHING was attempted. Every
3308
+ // other refusal below names its gate; this one must too.
3309
+ this.refuseStyleWrite(
3310
+ `[ReactRootAuthoringAdapter] cannot write "${prop}": "${id}" no longer resolves in this ` +
3311
+ 'root (the tree was re-projected — an HMR remount — between the selection and the ' +
3312
+ 'write). Nothing was written. Re-select and retry.',
3313
+ );
3314
+ return false;
3315
+ }
3316
+ if (!backend) {
3317
+ this.clearEcho(id, echoPath);
3318
+ this.refuseStyleWrite(
3319
+ `[ReactRootAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
3320
+ 'backend in this session (hosted/no dev server) — selection/inspection still work.',
3321
+ );
3322
+ return false;
3323
+ }
3324
+ // A per-side longhand cannot share a style object with the shorthand that
3325
+ // owns it — React raises its own conflicting-property error and the winner
3326
+ // becomes key-order dependent. Expand the shorthand first, so the element
3327
+ // is authored in ONE vocabulary. A source no-op on a clean element.
3328
+ await this.expandShorthandsFor(id, prop, backend);
3329
+ // Cap 2 (React visual-edit parity): if a first-party CSS RULE declares this property,
3330
+ // edit that CSS FILE (cascade-correct: the last matched rule wins) instead of writing
3331
+ // inline/class. A `generated: true` response means the selector isn't in source
3332
+ // (Tailwind/styled-components) — fall through to the inline/class path below.
3333
+ // BREAKPOINT MODE: with an active breakpoint the edit lands in
3334
+ // `@media <bp>` for the element's NAMED STYLE — the only honest carrier
3335
+ // (an inline style cannot be responsive). No class, no css door ⇒ a
3336
+ // guarded refusal naming the remedy, never a base write that
3337
+ // misrepresents the gesture.
3338
+ const breakpoint = activeBreakpoint();
3339
+ if (breakpoint !== null) {
3340
+ const named = namedStyleRuleFor(this.matchedCssRules(n.el), classTokensOf(n.el));
3341
+ const guard = !backend.writeCss
3342
+ ? 'breakpoint edits need the dev-server css door (a hosted session cannot write @media).'
3343
+ : named
3344
+ ? null
3345
+ : 'no style class carries this element — create one in the Style class row first ' +
3346
+ '(inline styles cannot be responsive, so a breakpoint edit needs a class rule).';
3347
+ if (guard !== null || !named || !backend.writeCss) {
3348
+ this.clearEcho(id, echoPath);
3349
+ if (guard) {
3350
+ this.markGuarded(id, echoPath, guard);
3351
+ // The refusal must be SEEN where the gesture happened (blind-walk
3352
+ // finding: the guarded-readonly state alone read as a silent no-op
3353
+ // — the value just snapped back with no visible reason).
3354
+ showTransientHint(guard);
3355
+ }
3356
+ this.store.notifyIngestEdit();
3357
+ this.refuseStyleWrite(`[ReactRootAuthoringAdapter] breakpoint edit refused: ${guard}`);
3358
+ return false;
3359
+ }
3360
+ const res = await backend.writeCss(
3361
+ named.sourceFile,
3362
+ named.selectorText,
3363
+ prop,
3364
+ cssTextForStyleValue(prop, value),
3365
+ breakpoint,
3366
+ );
3367
+ if (!res.changed) {
3368
+ this.clearEcho(id, echoPath);
3369
+ this.refuseStyleWrite(
3370
+ `[ReactRootAuthoringAdapter] breakpoint css write refused/no-op for ` +
3371
+ `"${named.selectorText}" ${prop} at ${breakpoint}: ${res.error ?? 'no change'}`,
3372
+ );
3373
+ return false;
3374
+ }
3375
+ this.dirty = true;
3376
+ this.store.notifyIngestEdit();
3377
+ return true;
3378
+ }
3379
+ if (backend.writeCss) {
3380
+ const matchedRules = this.matchedCssRules(n.el);
3381
+ const cssTarget = pickCssRuleTarget(matchedRules, prop);
3382
+ // Named-style routing (design ledger: named styles, Webflow prior art).
3383
+ // No matched rule declares this property yet — but the element WEARS a
3384
+ // named style (a first-party single-class rule), and it has no live
3385
+ // inline declaration of the property (inline would beat the class in
3386
+ // cascade, making the write a dead declaration). Land the NEW
3387
+ // declaration in the class rule (`surgicalCssEdit` appends absent
3388
+ // properties), so the named style stays the element's one style home
3389
+ // instead of every later edit forking back to inline.
3390
+ const namedTarget =
3391
+ !cssTarget && !liveInlineDeclares(n.el, prop)
3392
+ ? namedStyleRuleFor(matchedRules, classTokensOf(n.el))
3393
+ : null;
3394
+ const routedRule = cssTarget?.rule ?? namedTarget;
3395
+ if (routedRule) {
3396
+ const cssRoutedTarget = { rule: routedRule };
3397
+ const { rule } = cssRoutedTarget;
3398
+ // A CSS FILE declaration is CSS TEXT, so a numeric value needs its unit
3399
+ // spelled here (`300` -> `300px`) — unlike the JSX object below, where
3400
+ // React does the px-ifying. `css-numeric-style.ts` owns that rule once.
3401
+ const res = await backend.writeCss(
3402
+ rule.sourceFile,
3403
+ rule.selectorText,
3404
+ prop,
3405
+ cssTextForStyleValue(prop, value),
3406
+ );
3407
+ if (res.changed) {
3408
+ this.dirty = true;
3409
+ this.store.notifyIngestEdit();
3410
+ return true;
3411
+ }
3412
+ if (!res.generated) {
3413
+ this.clearEcho(id, echoPath);
3414
+ this.refuseStyleWrite(
3415
+ `[ReactRootAuthoringAdapter] CSS write refused/no-op for selector ` +
3416
+ `"${rule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
3417
+ );
3418
+ return false;
3419
+ }
3420
+ // res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
3421
+ }
3422
+ }
3423
+ value = preserveNumericStyleUnit(value, styleProp(n.el.style, prop));
3424
+ // NOT `String(value)`. A pixel-valued numeric descriptor must reach the writer as a
3425
+ // NUMBER so the JSX carries a bare `300`, which React px-ifies at render;
3426
+ // the quoted `'300'` this used to write is passed through verbatim by React
3427
+ // and REJECTED by the CSSOM, which keeps the previous value — a source
3428
+ // write with no rendered effect, acked `persisted: true`.
3429
+ const res = await backend.writeStyle(n.oid, prop, value);
3430
+ if (!res.changed) {
3431
+ this.clearEcho(id, echoPath);
3432
+ // Two guards, two sentences. A dynamic `{expression}` and a
3433
+ // `var(--token)` reference are both literals the writer will not
3434
+ // overwrite, and completely different facts to the author — so the
3435
+ // refusal that reaches the field carries the guard's own words.
3436
+ const guard = res.tokenRef
3437
+ ? tokenReferenceGuardText(prop, res.tokenRef)
3438
+ : res.dynamic
3439
+ ? DYNAMIC_EXPRESSION_GUARD
3440
+ : null;
3441
+ if (guard) this.markGuarded(id, echoPath, guard);
3442
+ // D2 — notify so the refusal actually RE-RENDERS: the cleared echo (field
3443
+ // snaps back off the stale value) and, for a guarded refusal, the now
3444
+ // `readonly: true` descriptor only reach the UI on a store tick. Without
3445
+ // this the widget keeps showing the refused value, enabled, until some
3446
+ // unrelated event happens to re-render.
3447
+ this.store.notifyIngestEdit();
3448
+ this.refuseStyleWrite(
3449
+ `[ReactRootAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
3450
+ `prop "${prop}": ${guard ?? res.error ?? 'no change'}`,
3451
+ );
3452
+ return false;
3453
+ }
3454
+ this.dirty = true;
3455
+ // D-A4: if this write took the writer's APPEND branch (no prior
3456
+ // literal for `prop`, e.g. spread-derived `style={{ ...vars }}`), undo must
3457
+ // REMOVE the appended prop rather than replay the write with `prev` — replaying
3458
+ // would find the NOW-appended literal and REPLACE it with a hardcoded runtime
3459
+ // value, baking a literal into a spot the source never had one. Redo is a plain
3460
+ // write either way (a re-append is just a write). B1-parity live preview for
3461
+ // single-prop inspector writes. The `boxEdit` gesture patches `n.el.style` live
3462
+ // during the drag (see `boxEdit.apply`), but a plain single-prop write (a color
3463
+ // / any inspector field) only wrote SOURCE — so the live element didn't repaint
3464
+ // until HMR/reload, which never lands in a headless harness (a color edit's
3465
+ // element stays the old color forever). Optimistically apply the
3466
+ // committed value to the live inline style here — mirroring
3467
+ // `dom-authoring-adapter`'s own `applyStyleToElement` — so the edit is
3468
+ // visible immediately; HMR then converges on the same value from source and the
3469
+ // A4 echo re-syncs. Skipped for the box-edit caller (`priorInlineOverride`
3470
+ // set), which already applied its own live preview and whose committed `value`
3471
+ // can differ from the inline CSS (e.g. a unitless length vs the `px` string it
3472
+ // painted). INLINE branch only — the CSS-cascade branch above returns early;
3473
+ // patching inline there would shadow the rule and stick past later edits.
3474
+ // The inline patch is CSS TEXT (a live `CSSStyleDeclaration`), so the unit
3475
+ // is spelled HERE while the source above keeps the bare number: assigning
3476
+ // `'300'` to `el.style.width` is rejected by the CSSOM exactly the way the
3477
+ // source string was, and the screen would not move until HMR landed.
3478
+ if (priorInlineOverride === undefined && n.el.style) {
3479
+ (n.el.style as Record<string, unknown>)[prop] = cssTextForStyleValue(prop, value);
3480
+ }
3481
+ this.store.notifyIngestEdit();
3482
+ return true;
3483
+ }
3484
+
3485
+ private async writeStyle(id: string, prop: string, value: string | number): Promise<boolean> {
3486
+ return this.writeStyleEntry(id, prop, value);
3487
+ }
3488
+
3489
+ /**
3490
+ * Make room for a per-side write by EXPANDING every shorthand that owns it.
3491
+ *
3492
+ * `SemanticSpacing` offers eight per-side rows on an element authored
3493
+ * `padding: 16`, and writing one of them used to leave BOTH in the style
3494
+ * object — React's own "don't mix shorthand and non-shorthand properties for
3495
+ * the same value" error, with an order-dependent result.
3496
+ *
3497
+ * The direction the combo widgets already handle is the mirror of this one:
3498
+ * going UNIFORM, `RadiusRow`/`ComboRow` remove the longhands so the shorthand
3499
+ * wins cleanly (`inspector.remove`). Going PER-SIDE, this removes the
3500
+ * shorthand and writes its resolved value into each sibling longhand first,
3501
+ * so nothing moves on screen and the element ends up authored in exactly one
3502
+ * vocabulary. Refusing instead was the alternative and it is strictly worse:
3503
+ * the per-side rows are offered, so refusing them would make the widget lie.
3504
+ *
3505
+ * Outermost shorthand first, because `border` owns `borderWidth` which owns
3506
+ * `borderTopWidth` — expanding one level authors the next, which the next
3507
+ * pass then expands. `removeStyleProp` is a source no-op when the shorthand
3508
+ * is not authored on this element, which is what gates the whole thing: a
3509
+ * clean element pays one no-op removal and keeps its source untouched.
3510
+ *
3511
+ * Values come from the element's COMPUTED style, the same read
3512
+ * `inspector.get` uses — the resolved value is what the author sees, and the
3513
+ * siblings are written to exactly what they already render at.
3514
+ */
3515
+ private async expandShorthandsFor(
3516
+ id: string,
3517
+ prop: string,
3518
+ backend: SourceWriteBackend | undefined,
3519
+ ): Promise<void> {
3520
+ const chain = shorthandChain(prop);
3521
+ if (chain.length === 0) return;
3522
+ const n = this.snapshot().nodes.get(id);
3523
+ if (!n || !backend) return;
3524
+ for (const shorthand of chain) {
3525
+ const longhands = SHORTHAND_LONGHANDS[shorthand] ?? [];
3526
+ // Read BEFORE the removal — the removal drops the live inline override,
3527
+ // so a read afterwards would see the cascade without it.
3528
+ const resolved = longhands.map((longhand) =>
3529
+ getComputedStyleValue(n.el, longhand, this.computedStyle),
3530
+ );
3531
+ // The SAME backend the caller writes through — a box-edit commit hands
3532
+ // in a scoped writer for atomicity, and an expansion that reached around
3533
+ // it would split one gesture across two transactions.
3534
+ if (!(await this.removeStyleProp(id, shorthand, backend))) continue; // not authored here
3535
+ for (const [index, longhand] of longhands.entries()) {
3536
+ if (longhand === prop) continue; // the caller writes this one itself
3537
+ const value = resolved[index];
3538
+ if (!value) continue;
3539
+ await this.writeStyleEntry(id, longhand, value, undefined, backend);
3540
+ }
3541
+ }
3542
+ }
3543
+
3544
+ /**
3545
+ * U2 (spec 27 §5 C2) — surgically REMOVE a style property's source override
3546
+ * (`inspector.remove`'s style path). Captures the prior source literal first
3547
+ * so the removal is undoable (undo re-writes it, redo re-removes); a source
3548
+ * no-op (`changed: false`, e.g. the prop wasn't authored) pushes NO undo
3549
+ * entry and touches nothing — which is what makes it safe to call for every
3550
+ * corner/side unconditionally on a uniform edit. Also drops the live inline
3551
+ * override so the element re-cascades to the shorthand immediately (mirrors
3552
+ * `writeStyleEntry`'s inline live-preview, inverse direction).
3553
+ */
3554
+ private async removeStyleProp(
3555
+ id: string,
3556
+ prop: string,
3557
+ backend: SourceWriteBackend | undefined = this.writeBackend,
3558
+ ): Promise<boolean> {
3559
+ const n = this.snapshot().nodes.get(id);
3560
+ if (!n || !backend) return false;
3561
+ const res = await backend.removeStyle(n.oid, prop);
3562
+ if (!res.changed) return false; // longhand wasn't authored — nothing removed, no undo
3563
+ this.dirty = true;
3564
+ if (n.el.style) delete (n.el.style as Record<string, unknown>)[prop];
3565
+ this.store.notifyIngestEdit();
3566
+ return true;
3567
+ }
3568
+
3569
+ /**
3570
+ * Cap 3 (React visual-edit parity): replace a leaf element's pure-text content in source
3571
+ * (double-click-to-edit). Refused (guarded) when the body has an expression or child
3572
+ * elements. Undoable: the prior text (returned by the backend) is written back on undo.
3573
+ */
3574
+ async editText(id: string, newText: string): Promise<void> {
3575
+ const n = this.snapshot().nodes.get(id);
3576
+ if (!n) return;
3577
+ if (!this.writeBackend?.writeText) {
3578
+ console.warn(
3579
+ `[ReactRootAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
3580
+ 'with text support in this session (hosted/no dev server).',
3581
+ );
3582
+ return;
3583
+ }
3584
+ const res = await this.writeBackend.writeText(n.oid, newText);
3585
+ if (!res.changed) {
3586
+ // D3.R1 (reopen fix) — a dynamic-body refusal marks this id readonly for
3587
+ // text edits (U4's session-scoped `guardedPaths`, read by `text.get`
3588
+ // above) and — mirroring D2's style/prop precedent — notifies the store
3589
+ // UNCONDITIONALLY so the refusal (and the overlay's refused indicator,
3590
+ // which polls `text.get` off this same notify) renders at refusal time
3591
+ // instead of silently vanishing until an unrelated event ticks the store.
3592
+ if (res.dynamic) {
3593
+ this.markGuarded(
3594
+ id,
3595
+ TEXT_PATH,
3596
+ 'The body has an expression or child elements, so replacing it with text is guarded.',
3597
+ );
3598
+ }
3599
+ this.store.notifyIngestEdit();
3600
+ console.warn(
3601
+ `[ReactRootAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
3602
+ `${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
3603
+ );
3604
+ return;
3605
+ }
3606
+ this.dirty = true;
3607
+ // D3.R5 (reopen fix) — a successful text write flips `hasText`, itself a
3608
+ // `findEmptyContainers` hint-eligibility criterion (`ui-source/inspect.ts`), yet
3609
+ // populates no `valueEcho` (echo is only ever set by the inspector style/prop
3610
+ // paths) — same pre-HMR-notify timing gap as a structural op (see
3611
+ // `pendingSourceReconcile`'s doc comment): queue a pending reconcile.
3612
+ this.pendingSourceReconcile++;
3613
+ this.store.notifyIngestEdit();
3614
+ }
3615
+
3616
+ /**
3617
+ * Cap 4 (React visual-edit parity): write a component prop at its CALL SITE (the
3618
+ * `<Component …>` tag), resolved from the live fiber (`getComponentProps` → callSiteOid).
3619
+ * Refused (guarded) when the prop is a dynamic expression. Undoable via the prior fiber
3620
+ * value.
3621
+ */
3622
+ private async writePropEdit(id: string, prop: string, value: string): Promise<boolean> {
3623
+ // A4 — the echo key `inspector.set` populated for this prop write; cleared on any
3624
+ // failure return so a refused write can't leave the field stuck.
3625
+ const echoPath = `${PROP_PATH_PREFIX}${prop}`;
3626
+ const n = this.snapshot().nodes.get(id);
3627
+ if (!n) {
3628
+ this.clearEcho(id, echoPath);
3629
+ return false;
3630
+ }
3631
+ if (!this.writeBackend?.writeProp) {
3632
+ this.clearEcho(id, echoPath);
3633
+ console.warn(
3634
+ `[ReactRootAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
3635
+ 'backend with prop support in this session (hosted/no dev server).',
3636
+ );
3637
+ return false;
3638
+ }
3639
+ const cp = getComponentProps(n.el);
3640
+ if (!cp) {
3641
+ this.clearEcho(id, echoPath);
3642
+ console.warn(
3643
+ `[ReactRootAuthoringAdapter] "${id}" is not a component call site with props — ` +
3644
+ `cannot write prop "${prop}".`,
3645
+ );
3646
+ return false;
3647
+ }
3648
+ const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
3649
+ if (!res.changed) {
3650
+ this.clearEcho(id, echoPath);
3651
+ if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
3652
+ // D2 — notify so the cleared echo + (dynamic) new `readonly: true`
3653
+ // descriptor render at refusal time, not on the next unrelated event.
3654
+ this.store.notifyIngestEdit();
3655
+ console.warn(
3656
+ `[ReactRootAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
3657
+ `prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
3658
+ );
3659
+ return false;
3660
+ }
3661
+ this.dirty = true;
3662
+ this.store.notifyIngestEdit();
3663
+ return true;
3664
+ }
3665
+
3666
+ private removeElement(id: string): Promise<WriteAck> {
3667
+ const n = this.snapshot().nodes.get(id);
3668
+ if (!n) return this.structRefusal(`cannot delete "${id}": no such node in this world.`);
3669
+ // Whole-file source history restores deletion without fabricating an inverse
3670
+ // element insertion from an OID that no longer exists.
3671
+ return this.structPipe.structOp(n.oid, id, 'delete', undefined, JSX_SOURCE_DESTINATION);
3672
+ }
3673
+
3674
+ /**
3675
+ * delete-order-residual fix — the `structure.removeMany` backing. Resolves every
3676
+ * `id` to its raw (non-disambiguated) oid, dedupes (a repeated-OID `.map` list's
3677
+ * `#n` siblings share ONE raw oid — deleting it once is correct, per
3678
+ * `walkOidTree`'s own disambiguation comment), and posts them ALL in ONE
3679
+ * `writeStructMany` call — see that method's doc comment on `SourceWriteBackend`
3680
+ * (`source-write-backend.ts`) and `handleStructMany`'s (`vite-plugin-ui-oid.ts`)
3681
+ * for the soundness argument (one shared file snapshot, highest-offset-first,
3682
+ * caller-order-independent). Degrades to a loud no-op — never a silent partial
3683
+ * delete — when this session's backend hasn't implemented `writeStructMany` (a
3684
+ * hosted/no-dev-server session, or an incomplete backend): `deleteSelection`
3685
+ * (`editor-hotkeys.ts`) only reaches this method because `structure.removeMany`
3686
+ * is present at all, so there is no further per-id fallback to drop into here.
3687
+ */
3688
+ private removeManyElements(ids: readonly string[]): Promise<WriteAck> {
3689
+ const snapshot = this.snapshot();
3690
+ const oids = [
3691
+ ...new Set(
3692
+ ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
3693
+ ),
3694
+ ];
3695
+ if (oids.length === 0) {
3696
+ return this.structRefusal('batch delete reached no source-addressable element.');
3697
+ }
3698
+ // One prepared batch source write becomes one history transaction.
3699
+ return this.structPipe.structMany(oids, 'delete', undefined, JSX_SOURCE_DESTINATION);
3700
+ }
3701
+
3702
+ subscribe(listener: () => void): () => void {
3703
+ return this.store.subscribe(listener);
3704
+ }
3705
+
3706
+ // A class GETTER, not a field initializer — see `ui-authoring-adapter.ts`/
3707
+ // `vgai-scene-authoring-adapter.ts` for why (field initializers run before the
3708
+ // constructor body assigns `this.writeBackend`).
3709
+ get persistence(): PersistenceProvider {
3710
+ const backend = this.writeBackend;
3711
+ return {
3712
+ isDirty: () => this.dirty,
3713
+ save: async () => {
3714
+ // Immediate-write architecture (same as UIAuthoringAdapter/
3715
+ // SourceWriteBackend's doc comment) — every edit already landed on disk
3716
+ // the instant it was made; nothing is pending to flush.
3717
+ },
3718
+ destination: backend ? JSX_SOURCE_DESTINATION : NO_BACKEND_DESTINATION,
3719
+ };
3720
+ }
3721
+
3722
+ /**
3723
+ * EVERY SOURCE WRITE this adapter performs, through the persistence pipe.
3724
+ *
3725
+ * One dialect here (the JSX/CSS source writer behind `/__ui-source`), so the
3726
+ * resolution is one question — is a writer bound in this session — and the
3727
+ * writer's own per-edit refusals (a dynamic expression, an unauthored prop, a
3728
+ * generated selector) come back as `false` with their reason on the console.
3729
+ * That is why they are a WRITE outcome rather than a resolution: the echo is
3730
+ * cleared and the field re-reads source, so the honest ack is the live-only
3731
+ * floor and the pipe supplies it.
3732
+ *
3733
+ * `record` does nothing because the source-write backend already carries the
3734
+ * whole-file history transaction; journaling here would double the undo.
3735
+ */
3736
+ private pipedSourceWrite(write: () => Promise<boolean>): Promise<WriteAck> {
3737
+ return runWritePipe({
3738
+ resolve: (): WriteResolution =>
3739
+ this.writeBackend
3740
+ ? {
3741
+ reaches: 'writer',
3742
+ anchorKind: 'source-prop',
3743
+ destination: JSX_SOURCE_DESTINATION,
3744
+ write,
3745
+ }
3746
+ : resolvesLiveOnly(NO_BACKEND_DESTINATION),
3747
+ record: () => undefined,
3748
+ // A session with no writer degrades LOUDLY — the same warning the write
3749
+ // helpers raised when they were the ones discovering it. Resolution moved
3750
+ // that discovery earlier; the sentence has to move with it or the lane
3751
+ // goes quiet for a whole class of edits.
3752
+ report: (reason) =>
3753
+ console.warn(`[ReactRootAuthoringAdapter] this edit stays live-only — ${reason}.`),
3754
+ });
3755
+ }
3756
+
3757
+ /**
3758
+ * EVERY STRUCTURAL SOURCE WRITE this adapter performs, through the pipe.
3759
+ *
3760
+ * A second plug point beside {@link pipedSourceWrite} rather than a reuse of
3761
+ * it, because a structural edit resolves on a DIFFERENT DOOR: `writeStruct` /
3762
+ * `writeStructMany`, not `writeStyle`/`writeProp`. That is why it acks
3763
+ * `source-structure` and not the value lane's `source-prop` — resolving a
3764
+ * delete on the attribute writer's presence, or naming the attribute lane in
3765
+ * its ack, is the classifier/writer split the pipe exists to make impossible.
3766
+ *
3767
+ * `record` does nothing: the backend is wrapped in `withProjectSourceHistory`
3768
+ * (`:957`), so the sha-guarded whole-file transaction that carries the bytes
3769
+ * IS the undo entry.
3770
+ */
3771
+ /**
3772
+ * THE STRUCT DIALECT, one producer (`struct-write-pipe.ts`) shared with the
3773
+ * r3f and canvas source lanes. This lane's on-changed hook carries the
3774
+ * D3.R4 reconcile: the immediate notify's `storeVersion` still reflects the
3775
+ * PRE-HMR DOM (see `pendingSourceReconcile`'s doc comment), so a pending
3776
+ * reconcile is queued for the upcoming `vite:afterUpdate` to notify AGAIN
3777
+ * once the remount has actually happened.
3778
+ */
3779
+ private readonly structPipe = createStructWritePipe({
3780
+ // biome-ignore lint/suspicious/noConsole: a refused source write must be visible
3781
+ report: (message) => console.warn(`[ReactRootAuthoringAdapter] ${message}`),
3782
+ noWriterReason: NO_BACKEND_DESTINATION,
3783
+ backend: () => this.writeBackend,
3784
+ onChanged: () => {
3785
+ this.dirty = true;
3786
+ this.pendingSourceReconcile++;
3787
+ this.store.notifyIngestEdit();
3788
+ },
3789
+ });
3790
+
3791
+ /** The shared pipe's live-only floor, under this lane's own label. */
3792
+ private structRefusal(reason: string): Promise<WriteAck> {
3793
+ return this.structPipe.structRefusal(reason);
3794
+ }
3795
+ }