@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,764 @@
1
+ /**
2
+ * DomAuthoringAdapter — the editor's {@link AuthoringAdapter} for an
3
+ * INGESTED (unmodified) native-React world.
4
+ *
5
+ * Unlike {@link ReactRootAuthoringAdapter} (the first-party react-WORLD
6
+ * adapter, T6.2 slice 2), an ingested react game's JSX is NEVER `data-oid`-
7
+ * stamped (three independent guards — see the design doc's §0) and is never
8
+ * written back to (the never-modify-game-source rule, `CLAUDE.md`). So this
9
+ * adapter cannot reuse `walkOidTree` (it would see an EMPTY tree — nothing
10
+ * is stamped) and cannot reuse the JSX write-back seam at all. It is
11
+ * structurally independent:
12
+ *
13
+ * - Hierarchy: a derived view over the shared DOM projector
14
+ * (`../projection/dom.ts`) under the structural-path identity. The
15
+ * projector walks every ELEMENT under a live DOM root (snapshot per
16
+ * call — the DOM is live and may re-render, exactly like the OID
17
+ * scheme's "no cached/mirrored state" contract). Identity is
18
+ * `'rdom:' + '/'-joined <tag>[k]` segments — the DOM analog of the
19
+ * Object3D structural-path scheme. The root itself is not a node —
20
+ * its element children are the hierarchy roots.
21
+ * - Labels derive from real DOM only — tag, plus `#id` or the first CSS
22
+ * class when present — never a fabricated/authored name (anti-shim).
23
+ * - Persistence is LIVE-ONLY: edits write the live element's inline `style`
24
+ * DIRECTLY and record the same write into an in-memory
25
+ * {@link DomStyleEdits} document that is never saved anywhere. (Until
26
+ * 2026-08-02 that document was flushed to a per-world JSON sidecar under
27
+ * the project's `.vgai/`; the owner deleted that system outright — no
28
+ * compat read, no replacement.) There is NO `SourceWriteBackend` import
29
+ * anywhere in this file and NO `/__ui-source` traffic of any kind, ever —
30
+ * this is the one hard invariant the design doc's §4 NOTs pin (AC-K7).
31
+ * - `object3D()`/`idForObject3D()` are ABSENT — these entities are DOM, not
32
+ * `Object3D` (no 3D gizmo binding, mirroring `ReactRootAuthoringAdapter`).
33
+ *
34
+ * Inspector reads/writes reuse `ReactRootAuthoringAdapter`'s pure style
35
+ * helpers (`styleProp`/`numericStyleValue`/`cssColorToHex` — exported for
36
+ * this purpose, D-K5) but declare their OWN `STYLE_PROPERTIES` list: this
37
+ * adapter's writes are not routed through the JSX writer's supported-property
38
+ * ceiling, so its list is free to diverge (kept identical today for
39
+ * predictability — nothing stops a later slice from growing it).
40
+ *
41
+ * The edit document is one session-scoped JSON history resource. Exact
42
+ * before/after documents are journaled by HistoryService and expire when this
43
+ * mount ends — which is also when the edits themselves expire.
44
+ */
45
+
46
+ import { numericStyleValue } from '@volter/editor-core/authoring/css-numeric-style';
47
+ import { createEphemeralPersistence } from '../host/authoring/ephemeral-persistence';
48
+ import { LIVE_ONLY_DESTINATION } from '@volter/editor-core/authoring/write-pipe';
49
+ import type { EditorShellStore } from '@volter/editor-core/editor-shell-store';
50
+ import { type JournalSubject, JsonHistoryResource } from '../host/history/json-history-resource';
51
+ import { DomProjector, structuralDomIdentity } from '../host/projection/dom';
52
+ import { browserOrInlineResolver, getComputedStyleValue } from '@volter/editor-core/ui-source/inspect';
53
+ import type {
54
+ AssetSubjectProvider,
55
+ AuthoringAdapter,
56
+ AuthoringAssetSubject,
57
+ AuthoringCapabilities,
58
+ AuthoringProvenance,
59
+ BoxEditProvider,
60
+ ColorSampleProvider,
61
+ DOMRectLike,
62
+ EditorNode,
63
+ HierarchyProvider,
64
+ InspectorProvider,
65
+ PersistenceProvider,
66
+ PickProvider,
67
+ PropertyDescriptor,
68
+ RectProvider,
69
+ SelectionProvider,
70
+ } from '@volter/editor-project/adapter';
71
+ import { cssColorToHex, mapBoxEditPatchKey, styleProp } from './react-world-authoring-adapter';
72
+
73
+ /**
74
+ * The minimal structural shape this adapter needs from a live DOM element —
75
+ * deliberately NOT `HTMLElement` (same testability reasoning as
76
+ * `OidElementLike` in `react-world-authoring-adapter.ts`: this repo's vitest
77
+ * environment for THIS adapter's own unit test is `node`, no real DOM). A
78
+ * real `Element`/`HTMLElement` satisfies this structurally (`style` is
79
+ * optional here for the same reason it is on `OidElementLike` — a plain
80
+ * `Element` has no `style` getter, only `HTMLElement`/`SVGElement` do, but
81
+ * TS treats an optional field as satisfied by its absence).
82
+ */
83
+ export interface DomElementLike {
84
+ readonly tagName: string;
85
+ readonly children: ArrayLike<DomElementLike>;
86
+ readonly id: string;
87
+ /** HTML exposes a string; SVG exposes SVGAnimatedString. */
88
+ readonly className: string | { readonly baseVal: string };
89
+ readonly style?: unknown;
90
+ readonly outerHTML?: string;
91
+ /**
92
+ * T0 (spec 27 §2) — the element's live viewport rect, for `rects.rect`'s
93
+ * host-relative geometry below (see `OidElementLike.getBoundingClientRect`'s
94
+ * identical doc comment in `react-world-authoring-adapter.ts`). Optional so
95
+ * existing plain-object test fixtures keep type-checking unchanged — a node
96
+ * with no `getBoundingClientRect` simply has no rect (never a fabricated 0×0).
97
+ */
98
+ getBoundingClientRect?(): {
99
+ left: number;
100
+ top: number;
101
+ right: number;
102
+ bottom: number;
103
+ width: number;
104
+ height: number;
105
+ };
106
+ }
107
+
108
+ /** One structural-path node resolved from a live-DOM walk. */
109
+ interface DomNode {
110
+ /** `'rdom:' + '/'-joined <tag>[k] segments` — see the class doc comment. */
111
+ id: string;
112
+ tag: string;
113
+ el: DomElementLike;
114
+ parentId: string | null;
115
+ childIds: string[];
116
+ }
117
+
118
+ interface DomTree {
119
+ nodes: Map<string, DomNode>;
120
+ rootIds: string[];
121
+ }
122
+
123
+ /** First whitespace-separated CSS class across HTML and SVG DOM elements. */
124
+ function firstClassName(
125
+ className: DomElementLike['className'] | null | undefined,
126
+ ): string | undefined {
127
+ const value = typeof className === 'string' ? className : className?.baseVal;
128
+ const trimmed = value?.trim() ?? '';
129
+ if (!trimmed) return undefined;
130
+ return trimmed.split(/\s+/)[0];
131
+ }
132
+
133
+ /** Real-DOM-only label (anti-shim): tag, `#id` when present, else `.firstClass` when present. */
134
+ function labelFor(el: DomElementLike, tag: string): string {
135
+ if (el.id) return `${tag}#${el.id}`;
136
+ const cls = firstClassName(el.className ?? '');
137
+ if (cls) return `${tag}.${cls}`;
138
+ return tag;
139
+ }
140
+
141
+ function viewFromProjection(projector: DomProjector<DomElementLike>): DomTree {
142
+ const nodes = new Map<string, DomNode>();
143
+ for (const [id, node] of projector.nodes) {
144
+ nodes.set(id, {
145
+ id,
146
+ tag: node.object.tagName.toLowerCase(),
147
+ el: node.object,
148
+ parentId: node.parentId,
149
+ childIds: [...node.childIds],
150
+ });
151
+ }
152
+ return { nodes, rootIds: [...projector.rootIds] };
153
+ }
154
+
155
+ /** A reasonably useful, curated inline-style property list — this adapter's
156
+ * OWN list (D-K2: not shared with `ReactRootAuthoringAdapter`'s, though
157
+ * kept identical in shape today — this one is not bounded by a JSX writer's
158
+ * supported-property ceiling). */
159
+ const STYLE_PROPERTIES: ReadonlyArray<{
160
+ prop: string;
161
+ label: string;
162
+ type: PropertyDescriptor['type'];
163
+ options?: string[];
164
+ }> = [
165
+ { prop: 'color', label: 'Color', type: 'color' },
166
+ { prop: 'backgroundColor', label: 'Background', type: 'color' },
167
+ { prop: 'borderColor', label: 'Border Color', type: 'color' },
168
+ { prop: 'fontSize', label: 'Font Size', type: 'number' },
169
+ { prop: 'width', label: 'Width', type: 'number' },
170
+ { prop: 'height', label: 'Height', type: 'number' },
171
+ { prop: 'padding', label: 'Padding', type: 'number' },
172
+ { prop: 'margin', label: 'Margin', type: 'number' },
173
+ { prop: 'borderRadius', label: 'Border Radius', type: 'number' },
174
+ { prop: 'gap', label: 'Gap', type: 'number' },
175
+ { prop: 'display', label: 'Display', type: 'enum', options: ['flex', 'block', 'none', 'grid'] },
176
+ { prop: 'textAlign', label: 'Text Align', type: 'enum', options: ['left', 'center', 'right'] },
177
+ { prop: 'flexDirection', label: 'Flex Direction', type: 'enum', options: ['row', 'column'] },
178
+ ];
179
+ const STYLE_PATH_PREFIX = 'style.';
180
+
181
+ /** `prop -> declared type` — see `ReactRootAuthoringAdapter`'s identical map
182
+ * for why `inspector.get` needs this (a real CSSOM value is always a
183
+ * unit-suffixed string / an `rgb(...)` string, never a bare number/hex). */
184
+ const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
185
+ STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
186
+ );
187
+
188
+ /** structural id -> cssProp -> value (D-K3). Values are always strings — the
189
+ * same literal an inline style write uses. In-memory for this session only. */
190
+ export type DomStyleEdits = Record<string, Record<string, string>>;
191
+
192
+ export interface DomAuthoringOptions {
193
+ /**
194
+ * The UNDO axis — whose journal this mount's live edits belong to. See the
195
+ * ownership block in `history/json-history-resource.ts`: a held DOM surface's
196
+ * subject is its world, which outlives the mount.
197
+ */
198
+ readonly journal: JournalSubject;
199
+ }
200
+
201
+ interface DomHistoryState {
202
+ edits: DomStyleEdits;
203
+ styles: Record<string, Record<string, string>>;
204
+ }
205
+
206
+ export class DomAuthoringAdapter implements AuthoringAdapter {
207
+ // THE ABSENT COLUMN, stated so a reader never has to guess whether a missing
208
+ // provider is a decision or an oversight. Facts about THIS ADAPTER CLASS —
209
+ // true for every live DOM it is pointed at, never per-game:
210
+ //
211
+ // - `transforms` — a DOM node has no 3D
212
+ // pose for the gizmo to drive; its spatial seam is `boxEdit`/`rects` below.
213
+ // - `structure` / `text` / `assetDrop` — this adapter's edit document is
214
+ // `DomStyleEdits`: structural id -> cssProp -> value, and nothing else.
215
+ // It has no vocabulary for creating, removing or re-texting an element, so
216
+ // every one of these would write into a live document it cannot record, undo
217
+ // or reconcile — and its ids are structural paths, so an insert or a removal
218
+ // re-indexes the siblings every existing edit is keyed by. A live-insert /
219
+ // live-write seam for a foreign DOM is a HOST work order, not a per-mount
220
+ // shim; until it exists, absence is the honest report.
221
+ // - `stories` — a story is declared in a PROJECT's own portable CSF source
222
+ // ("Association is derived from portable CSF source, never an editor-private
223
+ // label or registry", ARCHITECTURE-CORE §Roots). This adapter projects a
224
+ // game's live DOM, which is not owned source.
225
+ // - `creationSite` — no index is built here. Unlike the four above this is not
226
+ // an impossibility: a same-realm game's elements ARE appended by readable
227
+ // source, so an index is buildable and its absence is a standing host gap.
228
+ readonly capabilities: AuthoringCapabilities = {
229
+ transform: false, // no 3D gizmo — DOM has no Object3D pose
230
+ inspectorFields: true,
231
+ persist: false, // live-only: this game has no source path the editor may write
232
+ };
233
+
234
+ readonly provenance: AuthoringProvenance = {
235
+ source: 'foreign',
236
+ label: 'live-only',
237
+ detail:
238
+ 'Unmodified ingested React game — style edits apply for this session only and are never saved.',
239
+ };
240
+
241
+ private readonly doc: DomStyleEdits = {};
242
+ private readonly historyResource: JsonHistoryResource<DomHistoryState> | null;
243
+ /** The shared DOM projector under the structural-path identity. */
244
+ private readonly projector = new DomProjector(structuralDomIdentity<DomElementLike>());
245
+ /** Synchronous-burst cache for `snapshot()` — see that method's doc. */
246
+ private snapCache: DomTree | null = null;
247
+ private snapInvalidationScheduled = false;
248
+ /**
249
+ * T0 (spec 27 §4, B1) — the currently-open `boxEdit` begin/apply×N/end gesture, or
250
+ * `null` between gestures. Mirrors `ReactRootAuthoringAdapter.boxEditSession` (see
251
+ * its doc comment for why `priorInline` exists) — `touched` here maps prop → the
252
+ * FINAL CSS-STRING value to write (this adapter always stores CSS strings,
253
+ * never bare numbers, unlike the react-world source-write route).
254
+ */
255
+ private boxEditSession: {
256
+ id: string;
257
+ touched: Map<string, string>;
258
+ priorInline: Map<string, string>;
259
+ } | null = null;
260
+ /** D4 (spec27 §6 D4, layer-tree "lock" toggle) — kept out of the edit
261
+ * document entirely: an ingested game's DOM has no "locked" concept (the
262
+ * document only ever records style overrides). See
263
+ * `ReactRootAuthoringAdapter.lockedIds`'s doc comment for the same
264
+ * reasoning, INCLUDING D4.R1 (now read by `pickable.pick` below and by
265
+ * the selection overlay's marquee candidate pool; NOT by `hierarchy`/`selection` —
266
+ * a locked node stays selectable/unlockable from the layer tree). */
267
+ private readonly lockedIds = new Set<string>();
268
+
269
+ constructor(
270
+ private readonly root: DomElementLike,
271
+ private readonly store: EditorShellStore,
272
+ opts: DomAuthoringOptions,
273
+ ) {
274
+ const history = store.projectHistory;
275
+ this.historyResource = history
276
+ ? new JsonHistoryResource({
277
+ history,
278
+ kind: 'session-state',
279
+ scope: 'session',
280
+ // The SUBJECT's session, not this mount's — see the ownership block
281
+ // in `history/json-history-resource.ts`.
282
+ subject: { ...opts.journal, id: `${opts.journal.id}/react-dom-edits` },
283
+ displayName: 'React DOM edits',
284
+ capture: () => this.captureHistoryState(),
285
+ restore: async (state) => {
286
+ this.restoreHistoryState(state);
287
+ },
288
+ })
289
+ : null;
290
+ }
291
+
292
+ /**
293
+ * A live-DOM walk, memoized for the current SYNCHRONOUS burst only.
294
+ *
295
+ * `hierarchy.node(id)` is called once per node by the shell's row flattener
296
+ * (`flattenHierarchyRows`), and a large live react game (react-rpg's dungeon
297
+ * is ~1900 walked rows over a several-thousand-element DOM) turned that into
298
+ * an O(N²) re-walk-per-node — a ~9s synchronous block that hung the editor
299
+ * on selection (the hierarchy re-renders synchronously inside the click's
300
+ * discrete-event flush). JS is single-threaded, so the DOM cannot change
301
+ * mid-render: caching the walk for the current synchronous stack and
302
+ * invalidating it on the next microtask keeps the "the DOM is live, never
303
+ * stale across renders" contract (a subsequent render, after the game may
304
+ * have re-rendered, re-walks) while collapsing one render's N node() calls
305
+ * to ONE walk. Style READS stay live regardless — `inspector.get` reads
306
+ * `n.el.style` off the cached element ref, not a cached value. Structural
307
+ * mutations (none in v1 — this adapter has no `structure` provider) would
308
+ * land between ticks (the game's own re-render), which the microtask
309
+ * invalidation already covers.
310
+ */
311
+ private snapshot(): DomTree {
312
+ if (this.snapCache) return this.snapCache;
313
+ this.projector.project(this.root);
314
+ const tree = viewFromProjection(this.projector);
315
+ this.snapCache = tree;
316
+ if (!this.snapInvalidationScheduled) {
317
+ this.snapInvalidationScheduled = true;
318
+ queueMicrotask(() => {
319
+ this.snapCache = null;
320
+ this.snapInvalidationScheduled = false;
321
+ });
322
+ }
323
+ return tree;
324
+ }
325
+
326
+ /**
327
+ * D-L1 — total ELEMENT nodes currently walked under the
328
+ * root: a fresh snapshot every call (same "the DOM is live and may
329
+ * re-render, never cached" discipline as
330
+ * `hierarchy.roots()`/`hierarchy.node()` above), not a count taken once at
331
+ * construction. Backs `__vgaiIngestReact.domEvidence().hierarchyNodeCount`
332
+ * — one of the four legs `vgai doctor`'s react-ingest MOUNTED bar requires
333
+ * (`clearsReactIngestMountedBar`, `packages/editor/src/doctor/report.ts`).
334
+ */
335
+ nodeCount(): number {
336
+ return this.snapshot().nodes.size;
337
+ }
338
+
339
+ private toEditorNode(n: DomNode): EditorNode {
340
+ return {
341
+ id: n.id,
342
+ label: labelFor(n.el, n.tag),
343
+ role: 'element',
344
+ kind: n.tag,
345
+ parentId: n.parentId,
346
+ childIds: n.childIds,
347
+ };
348
+ }
349
+
350
+ private applyStyleToElement(el: DomElementLike, prop: string, value: string): void {
351
+ if (!el.style) return;
352
+ (el.style as Record<string, unknown>)[prop] = value;
353
+ }
354
+
355
+ private recordStyleEdit(id: string, prop: string, value: string): void {
356
+ const entry = this.doc[id] ?? {};
357
+ entry[prop] = value;
358
+ this.doc[id] = entry;
359
+ }
360
+
361
+ readonly hierarchy: HierarchyProvider = {
362
+ roots: () => {
363
+ const { nodes, rootIds } = this.snapshot();
364
+ return rootIds.map((id) => this.toEditorNode(nodes.get(id)!));
365
+ },
366
+ node: (id) => {
367
+ const n = this.snapshot().nodes.get(id);
368
+ return n ? this.toEditorNode(n) : null;
369
+ },
370
+ // No `object3D`/`idForObject3D`: DOM, not Object3D — the concept does not
371
+ // apply here (no 3D gizmo binding).
372
+ };
373
+
374
+ readonly selection: SelectionProvider = {
375
+ get: () => [...this.store.selectedEntityIds],
376
+ set: (ids) => this.store.selectMultiple(ids),
377
+ };
378
+
379
+ /**
380
+ * A2 gap close-out (spec 27 §2, spec:237-254/spec:251-254) — GEOMETRIC rect
381
+ * hit-test over the live DOM tree, symmetric with
382
+ * `ReactRootAuthoringAdapter.pickable` (see that provider's doc comment
383
+ * for the full rationale, identical here): NOT `document.elementFromPoint`
384
+ * (which skips a `pointer-events:none` design-time wrapper), smallest-area
385
+ * element containing the point wins (the deepest/most-specific node), ties
386
+ * broken by later tree-walk order (the topmost). Without this, an ingested
387
+ * react world's `rdom:`-id nodes are never `pickable`, so the shell's
388
+ * `pickTopmost` (`layered-pick.ts`) always falls through to `null` for this
389
+ * adapter — click-to-select through the viewport never resolves any node,
390
+ * even though `hierarchy`/`selection` already work from the panel.
391
+ *
392
+ * The winning id is the SAME structural `rdom:<tag>[k]` id
393
+ * `hierarchy`/`selection` already use — a hit routes straight into
394
+ * `composite.selection.set([id])` with no translation.
395
+ */
396
+ readonly pickable: PickProvider = {
397
+ pick: (clientX, clientY) => {
398
+ const { nodes } = this.snapshot();
399
+ let bestId: string | null = null;
400
+ let bestArea = Number.POSITIVE_INFINITY;
401
+ let bestOrder = -1;
402
+ let order = -1;
403
+ for (const node of nodes.values()) {
404
+ order++;
405
+ // D4.R1 — a locked node is SKIPPED, not returned (falls through to
406
+ // whatever unlocked node is behind/around it) — mirrors
407
+ // `ReactRootAuthoringAdapter.pickable`'s identical fix and the
408
+ // first-party `viewport-raycast.ts` locked-skip.
409
+ if (this.lockedIds.has(node.id)) continue;
410
+ // The host layer is pointer-events:none at rest, so computed style
411
+ // would make every descendant appear non-interactive. Honor only the
412
+ // node's own explicit declaration: a transparent full-size wrapper
413
+ // must not swallow picks intended for a world below it.
414
+ if ((node.el.style as { pointerEvents?: unknown } | undefined)?.pointerEvents === 'none') {
415
+ continue;
416
+ }
417
+ const rect = node.el.getBoundingClientRect?.();
418
+ if (!rect) continue; // no live rect (test fixture, or unmounted) — never a candidate
419
+ if (
420
+ clientX < rect.left ||
421
+ clientX > rect.right ||
422
+ clientY < rect.top ||
423
+ clientY > rect.bottom
424
+ ) {
425
+ continue;
426
+ }
427
+ const area = rect.width * rect.height;
428
+ // smallest-area (deepest) wins; ties broken by later tree-order (topmost)
429
+ if (area < bestArea || (area === bestArea && order > bestOrder)) {
430
+ bestId = node.id;
431
+ bestArea = area;
432
+ bestOrder = order;
433
+ }
434
+ }
435
+ return bestId;
436
+ },
437
+ };
438
+
439
+ /** Host rect to subtract for {@link rects}' HOST-RELATIVE geometry — same
440
+ * reasoning as `ReactRootAuthoringAdapter.hostRect` (this adapter's own
441
+ * `this.root` is symmetrically the ingested world's mounted DOM layer). */
442
+ private hostRect(): { left: number; top: number } {
443
+ const r = this.root.getBoundingClientRect?.();
444
+ return { left: r?.left ?? 0, top: r?.top ?? 0 };
445
+ }
446
+
447
+ private toHostRelative(r: {
448
+ left: number;
449
+ top: number;
450
+ width: number;
451
+ height: number;
452
+ }): DOMRectLike {
453
+ const host = this.hostRect();
454
+ return { x: r.left - host.left, y: r.top - host.top, width: r.width, height: r.height };
455
+ }
456
+
457
+ /**
458
+ * T0 (spec 27 §2) — per-node screen geometry, HOST-RELATIVE (see
459
+ * {@link hostRect}'s doc comment), symmetric with
460
+ * `ReactRootAuthoringAdapter.rects` so the overlay this feeds (Phase A3)
461
+ * treats both react adapters identically.
462
+ */
463
+ readonly rects: RectProvider = {
464
+ rect: (id) => {
465
+ const n = this.snapshot().nodes.get(id);
466
+ const r = n?.el.getBoundingClientRect?.();
467
+ return r ? this.toHostRelative(r) : null;
468
+ },
469
+ contextRects: (id) => {
470
+ const { nodes } = this.snapshot();
471
+ const n = nodes.get(id);
472
+ if (!n) return {};
473
+ const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
474
+ const parentRect = parentNode?.el.getBoundingClientRect?.();
475
+ const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
476
+ (sid) => sid !== id,
477
+ );
478
+ const siblings = siblingIds
479
+ .map((sid) => nodes.get(sid)?.el.getBoundingClientRect?.())
480
+ .filter((r): r is NonNullable<typeof r> => r != null)
481
+ .map((r) => this.toHostRelative(r));
482
+ const el = n.el;
483
+ const rect = el.getBoundingClientRect?.();
484
+ let paddingBox: DOMRectLike | undefined;
485
+ if (rect) {
486
+ const bt =
487
+ numericStyleValue(getComputedStyleValue(el, 'borderTopWidth', browserOrInlineResolver)) ??
488
+ 0;
489
+ const br =
490
+ numericStyleValue(
491
+ getComputedStyleValue(el, 'borderRightWidth', browserOrInlineResolver),
492
+ ) ?? 0;
493
+ const bb =
494
+ numericStyleValue(
495
+ getComputedStyleValue(el, 'borderBottomWidth', browserOrInlineResolver),
496
+ ) ?? 0;
497
+ const bl =
498
+ numericStyleValue(
499
+ getComputedStyleValue(el, 'borderLeftWidth', browserOrInlineResolver),
500
+ ) ?? 0;
501
+ paddingBox = this.toHostRelative({
502
+ left: rect.left + bl,
503
+ top: rect.top + bt,
504
+ width: Math.max(0, rect.width - bl - br),
505
+ height: Math.max(0, rect.height - bt - bb),
506
+ });
507
+ }
508
+ return {
509
+ ...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
510
+ ...(siblings.length ? { siblings } : {}),
511
+ ...(paddingBox ? { paddingBox } : {}),
512
+ };
513
+ },
514
+ };
515
+
516
+ /**
517
+ * D3.d (spec 27 §6) — eyedropper FALLBACK color-sample path, symmetric with
518
+ * `ReactRootAuthoringAdapter.colorSample` (see that provider's doc comment
519
+ * for the full rationale — raw/un-normalized values, hit-element first).
520
+ */
521
+ readonly colorSample: ColorSampleProvider = {
522
+ backgroundChainAt: (clientX, clientY) => {
523
+ const hitId = this.pickable.pick(clientX, clientY);
524
+ if (!hitId) return null;
525
+ const { nodes } = this.snapshot();
526
+ const chain: string[] = [];
527
+ let cur: DomNode | undefined = nodes.get(hitId);
528
+ while (cur) {
529
+ chain.push(getComputedStyleValue(cur.el, 'backgroundColor', browserOrInlineResolver));
530
+ cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
531
+ }
532
+ return chain;
533
+ },
534
+ };
535
+
536
+ /**
537
+ * T0 (spec 27 §4, B1) — same interface as `ReactRootAuthoringAdapter.boxEdit`,
538
+ * routed to the live element + this session's in-memory edit document instead of
539
+ * source (spec:305 — "Ingested adapter routes ... instead of source, same
540
+ * interface"). `apply` is LIVE PREVIEW ONLY — it mutates the live element's inline
541
+ * style via `applyStyleToElement` WITHOUT touching `this.doc`. `end` commits
542
+ * every touched prop ONCE through {@link writeStyleEntry} (preserving the
543
+ * restore-BOTH-halves undo invariant, D-K2), composed into exactly ONE undo entry
544
+ * for the whole gesture (acceptance:310) even across multiple touched props.
545
+ */
546
+ readonly boxEdit: BoxEditProvider = {
547
+ begin: (id) => {
548
+ this.historyResource?.assertCanMutate();
549
+ this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
550
+ },
551
+ apply: (id, patch) => {
552
+ this.historyResource?.assertCanMutate();
553
+ const session = this.boxEditSession;
554
+ if (!session || session.id !== id) return; // no open gesture for this id
555
+ const n = this.snapshot().nodes.get(id);
556
+ if (!n) return; // unresolved id — no-op (per contract)
557
+ const pos = getComputedStyleValue(n.el, 'position', browserOrInlineResolver);
558
+ const isPositioned = pos === 'absolute' || pos === 'fixed';
559
+ for (const [key, v] of Object.entries(patch)) {
560
+ const mapped = mapBoxEditPatchKey(key, isPositioned);
561
+ if (!mapped) {
562
+ // biome-ignore lint/suspicious/noConsole: a dropped edit must not be silent
563
+ console.warn(
564
+ `[DomAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
565
+ (key === 'x' || key === 'y'
566
+ ? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
567
+ 'no left/top to move'
568
+ : 'unrecognized box-edit patch key'),
569
+ );
570
+ continue;
571
+ }
572
+ // Lazily snapshot the TRUE pre-gesture inline value the first time THIS
573
+ // prop is touched in this gesture — before mutating it (see
574
+ // `boxEditSession`'s doc comment, mirroring the react-world adapter).
575
+ if (!session.priorInline.has(mapped.prop)) {
576
+ const prevRaw = styleProp(n.el.style, mapped.prop);
577
+ session.priorInline.set(mapped.prop, prevRaw == null ? '' : String(prevRaw));
578
+ }
579
+ const cssValue = mapped.cssValue(v);
580
+ this.applyStyleToElement(n.el, mapped.prop, cssValue);
581
+ session.touched.set(mapped.prop, cssValue); // this route always commits CSS strings
582
+ }
583
+ },
584
+ end: (id) => {
585
+ const session = this.boxEditSession;
586
+ this.boxEditSession = null;
587
+ if (!session || session.id !== id || session.touched.size === 0) return;
588
+ this.commitBoxEdit(id, session.touched, session.priorInline);
589
+ },
590
+ };
591
+
592
+ /**
593
+ * T0 (spec 27 §4, B1) — commit every prop touched by one `boxEdit` gesture, each
594
+ * through {@link writeStyleEntry} (preserving the restore-BOTH-halves undo
595
+ * invariant per prop), composed into exactly ONE undo entry (acceptance:310) whose
596
+ * inverse runs in REVERSE order and whose redo runs in gesture order.
597
+ */
598
+ private commitBoxEdit(
599
+ id: string,
600
+ touched: Map<string, string>,
601
+ priorInline: Map<string, string>,
602
+ ): void {
603
+ this.historyResource?.assertCanMutate();
604
+ const before = this.captureHistoryState();
605
+ const beforeStyles = before.styles[id] ?? {};
606
+ before.styles[id] = beforeStyles;
607
+ for (const [prop, value] of priorInline) beforeStyles[prop] = value;
608
+ let changed = false;
609
+ for (const [prop, value] of touched) {
610
+ changed = this.writeStyleEntry(id, prop, value) || changed;
611
+ }
612
+ if (!changed) return;
613
+ if (this.historyResource) {
614
+ void this.historyResource
615
+ .record(`Edit ${touched.size} Styles`, before, this.captureHistoryState())
616
+ .catch(() => {});
617
+ }
618
+ }
619
+
620
+ readonly inspector: InspectorProvider = {
621
+ properties: (): PropertyDescriptor[] =>
622
+ STYLE_PROPERTIES.map(({ prop, label, type, options }) => ({
623
+ path: `${STYLE_PATH_PREFIX}${prop}`,
624
+ label,
625
+ type,
626
+ ...(options ? { options } : {}),
627
+ })),
628
+ get: (id, path) => {
629
+ // D4 (spec27 §6 D4, layer-tree visibility/lock) — see
630
+ // `ReactRootAuthoringAdapter`'s matching branch for the reasoning
631
+ // (`locked` session-local; `visible` sugar for the `visibility` inline
632
+ // style — this adapter writes synchronously, so no echo is needed).
633
+ if (path === 'locked') return this.lockedIds.has(id);
634
+ if (path === 'visible') {
635
+ const n = this.snapshot().nodes.get(id);
636
+ if (!n) return undefined;
637
+ return styleProp(n.el.style, 'visibility') !== 'hidden';
638
+ }
639
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
640
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
641
+ const n = this.snapshot().nodes.get(id);
642
+ if (!n) return undefined;
643
+ const raw = styleProp(n.el.style, prop);
644
+ const declaredType = STYLE_PROPERTY_TYPE.get(prop);
645
+ if (declaredType === 'number') return numericStyleValue(raw);
646
+ if (declaredType === 'color') return cssColorToHex(raw);
647
+ return raw;
648
+ },
649
+ set: (id, path, value) => {
650
+ if (path === 'locked') {
651
+ if (value) this.lockedIds.add(id);
652
+ else this.lockedIds.delete(id);
653
+ this.store.notifyIngestEdit();
654
+ return;
655
+ }
656
+ if (path === 'visible') {
657
+ this.writeStyle(id, 'visibility', value ? 'visible' : 'hidden');
658
+ return;
659
+ }
660
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
661
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
662
+ this.writeStyle(id, prop, String(value));
663
+ },
664
+ };
665
+
666
+ readonly assetSubject: AssetSubjectProvider = {
667
+ get: (id) => this.assetSubjectForNode(this.snapshot(), id),
668
+ entries: () => {
669
+ const tree = this.snapshot();
670
+ return [...tree.nodes.keys()].flatMap((id) => {
671
+ const subject = this.assetSubjectForNode(tree, id);
672
+ return subject ? [{ id, subject }] : [];
673
+ });
674
+ },
675
+ };
676
+
677
+ private assetSubjectForNode(tree: DomTree, id: string): AuthoringAssetSubject | null {
678
+ const node = tree.nodes.get(id);
679
+ if (!node || node.tag !== 'svg' || !node.el.outerHTML) return null;
680
+ return {
681
+ kind: 'image',
682
+ name: labelFor(node.el, node.tag),
683
+ mediaType: 'image/svg+xml',
684
+ text: node.el.outerHTML,
685
+ };
686
+ }
687
+
688
+ /**
689
+ * T0 (spec 27 §4, B1) — the write body extracted from {@link writeStyle}, returning
690
+ * whether the edit document changed so a multi-property box gesture can record once.
691
+ * `priorInlineOverride`, when given, is used as the captured PRE-gesture inline value
692
+ * instead of re-reading `n.el.style` — needed because `boxEdit.apply` already mutated
693
+ * the live inline style for preview before `end` gets here (see `boxEditSession`'s
694
+ * doc comment). D-K2's restore-BOTH-halves invariant (inline AND document) is
695
+ * unchanged — only WHERE `prevInline` comes from differs when an override is given.
696
+ */
697
+ private writeStyleEntry(id: string, prop: string, value: string): boolean {
698
+ const n = this.snapshot().nodes.get(id);
699
+ if (!n) return false;
700
+
701
+ this.applyStyleToElement(n.el, prop, value);
702
+ this.recordStyleEdit(id, prop, value);
703
+ this.store.notifyIngestEdit();
704
+
705
+ return true;
706
+ }
707
+
708
+ /**
709
+ * D-K2: mutate the live element's inline style AND the in-memory edit
710
+ * document, then journal the complete state as one session transaction.
711
+ */
712
+ private writeStyle(id: string, prop: string, value: string): void {
713
+ this.historyResource?.assertCanMutate();
714
+ const before = this.captureHistoryState();
715
+ if (!this.writeStyleEntry(id, prop, value)) return;
716
+ if (this.historyResource) {
717
+ void this.historyResource
718
+ .record(`Set ${prop}`, before, this.captureHistoryState())
719
+ .catch(() => {});
720
+ }
721
+ }
722
+
723
+ dispose(): void {
724
+ this.historyResource?.dispose();
725
+ }
726
+
727
+ private captureHistoryState(): DomHistoryState {
728
+ const styles: DomHistoryState['styles'] = {};
729
+ for (const [id, node] of this.snapshot().nodes) {
730
+ const values: Record<string, string> = {};
731
+ for (const { prop } of STYLE_PROPERTIES) {
732
+ const value = styleProp(node.el.style, prop);
733
+ if (value !== '') values[prop] = String(value);
734
+ }
735
+ styles[id] = values;
736
+ }
737
+ return { edits: structuredClone(this.doc), styles };
738
+ }
739
+
740
+ private restoreHistoryState(state: DomHistoryState): void {
741
+ for (const key of Object.keys(this.doc)) delete this.doc[key];
742
+ Object.assign(this.doc, structuredClone(state.edits));
743
+ const { nodes } = this.snapshot();
744
+ for (const [id, node] of nodes) {
745
+ const values = state.styles[id] ?? {};
746
+ for (const { prop } of STYLE_PROPERTIES) {
747
+ this.applyStyleToElement(node.el, prop, values[prop] ?? '');
748
+ }
749
+ }
750
+ this.store.notifyIngestEdit();
751
+ }
752
+
753
+ subscribe(listener: () => void): () => void {
754
+ return this.store.subscribe(listener);
755
+ }
756
+
757
+ /**
758
+ * Live-only: an unmodified React game has no source path this editor may
759
+ * write, so this reports the ephemeral provider honestly rather than a file.
760
+ */
761
+ get persistence(): PersistenceProvider {
762
+ return { ...createEphemeralPersistence(), destination: LIVE_ONLY_DESTINATION };
763
+ }
764
+ }