@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,782 @@
1
+ /**
2
+ * THE DOM MEDIUM'S DESIGN-TIME MOUNT (`@editor/authoring/design-time-mount-registry`).
3
+ * A dom world's design-time surface is its project's portable-CSF BOARD: every
4
+ * composed story of the project laid out as an isolated, labeled frame, with
5
+ * the root entry's own story (derived, never labeled — the story whose CSF
6
+ * `meta.component` is the entry's default export) as the frame it opens on.
7
+ * A project with no stories, and the pasteboard document's `entryOnly`
8
+ * descriptor, fall back to mounting the entry once against an inert Game.
9
+ *
10
+ * ## Why this is a package and not the host's
11
+ *
12
+ * It was the `mountReactLayer` half of `@editor/authoring/design-time-layers.ts`,
13
+ * chosen by one literal in one expression — `candidate.kind === 'canvas' ?
14
+ * mountCanvasLayer(…) : mountReactLayer(…)` — against a `LayerMountResult`
15
+ * contract whose own docblock already said the failure/teardown/play lifecycle
16
+ * is "the SAME for both media". Behind that literal the host knew what
17
+ * portable CSF is, imported the story registry and Storybook's preview API
18
+ * with it, and hard-coded this medium's staleness rule besides. The host keeps
19
+ * the layer STACK (epochs, z-order, the eye, the pan transform, the play
20
+ * handoff and Stop's rebuild, the Boundary disclosure a failed mount degrades
21
+ * to) and asks the registry how a medium mounts (WORK.md §The open-source
22
+ * launch item 12, §Stories leave the host edge 9).
23
+ *
24
+ * ## The seam, which is not a new one for this package
25
+ *
26
+ * `@volter/editor-game` already contributes the React/DOM Inspector through the host's
27
+ * own inspector-section registry; this is the second contribution and the
28
+ * fifth registry of the same family (see the registry module's own note for
29
+ * the four it transcribes). The package REGISTERS; the host names no medium.
30
+ *
31
+ * ## What has NOT moved with it, and why — each blocked by a HOST importer
32
+ *
33
+ * - `react-world-authoring-adapter.ts` (the adapter this mint returns) DID
34
+ * move, in the same unit: once this mount left, its only host importer
35
+ * was `dom-authoring-adapter.ts:70`
36
+ * (`cssColorToHex`/`mapBoxEditPatchKey`/`styleProp`), which had no host
37
+ * importer of its own, so the pair moved together. `@volter/editor-game` reaches
38
+ * them as `@volter/editor-game/react/…` and declares the dependency.
39
+ * - `@editor/authoring/react-story-board.ts` (the board geometry) —
40
+ * `@editor/components/RootSelectionOverlay.tsx:98`,
41
+ * `@editor/components/ReactCanvasControls.tsx:15`,
42
+ * `@editor/pasteboard-materialize.ts:20` and
43
+ * `@editor/canvas-board/CanvasBoardDocument.tsx:57` read its frame
44
+ * placements. Host chrome drawn OVER the board, which is a different
45
+ * question from how the board mounts.
46
+ * - `rememberedPortableStory` / `projectStoryBoardDescriptor` — still the
47
+ * host's, because `@editor/components/world-documents.tsx:542,570` reads
48
+ * both, and the host may not import a package. The write half is a door
49
+ * (`rememberPortableStory`).
50
+ * - the story-registry modules under `@editor/stories/` — as of 2026-09-19
51
+ * the only host files left importing them are the THREE-surface board
52
+ * (`three-board/board-scene.ts`, `three-board/ThreeBoardDocument.tsx`) and
53
+ * the canvas board (`canvas-board/canvas-board-model.ts`,
54
+ * `canvas-board/CanvasBoardDocument.tsx`). Neither is this lane's, and
55
+ * neither is the host's either, which is what the measurement says about
56
+ * unit 11's unmet half: it waits on those two surfaces, not on this one.
57
+ */
58
+
59
+ import type {
60
+ DesignTimeRootDescriptor,
61
+ LayerMountResult,
62
+ } from '@volter/editor-core/authoring/design-time-layers';
63
+ import {
64
+ rememberedPortableStory,
65
+ rememberPortableStory,
66
+ } from '@volter/editor-core/authoring/design-time-layers';
67
+ import type { DesignTimeMountContext } from '@volter/editor-core/authoring/design-time-mount-registry';
68
+ import { formatMountFailureMessage } from '@volter/editor-core/authoring/mount-failure-report';
69
+ import type { ReactStoryBoardSelectionIntent } from '@volter/editor-core/authoring/react-story-board';
70
+ import { createReactStoryBoard } from '@volter/editor-core/authoring/react-story-board';
71
+ import {
72
+ getRootCanvasViewport,
73
+ setRootCanvasViewport,
74
+ } from '@volter/editor-core/authoring/world-canvas-viewport-state';
75
+ import { recordAuthoringConsumerUse } from '@volter/editor-core/coverage/authoring-seam-evidence';
76
+ import { CrashNullBoundary } from '@volter/editor-core/crash-null-boundary';
77
+ import { readProjectTextFile } from '@volter/editor-core/editor-api';
78
+ import { editorConsole } from '@volter/editor-core/editor-console';
79
+ import {
80
+ resolveReactRootMountRuntime,
81
+ resolveWorldProviderForProject,
82
+ } from '../host/react-mount-runtime';
83
+ import { activeRealmServices } from '../host/realm-services';
84
+ import { resolveReactAdapterRootComponent } from '../host/roots/react-root';
85
+ import { scopedGameStylesState } from '@volter/editor-core/scoped-game-css';
86
+ import { componentIdentityName } from '@volter/editor-core/stories/compose-project-stories';
87
+ import { mountIsolatedStory } from '@volter/editor-core/stories/StoryPreviewMount';
88
+ import {
89
+ createStoryPresentationIndex,
90
+ storyBoardPresentation,
91
+ } from '@volter/editor-core/stories/story-presentation';
92
+ import {
93
+ getComponentPreviewStory,
94
+ getProjectPreviewStories,
95
+ getProjectStoryModules,
96
+ type ProjectPreviewStory,
97
+ projectStoriesReady,
98
+ refreshProjectStories,
99
+ subscribeProjectStoryModules,
100
+ } from '@volter/editor-core/stories/story-registry';
101
+ import { domStoryBoardMembers } from '@volter/editor-core/stories/three-story-model';
102
+ import { getDesignTokens } from '@volter/editor-core/ui-source/inspect';
103
+ import { tierSourceWriteBackend } from '@volter/editor-core/ui-source/tier-source-write-backend';
104
+ import { recordViewportFirstFrame } from '@volter/editor-core/viewport-activation-timings';
105
+ import { createAssetCache } from '@volter/threejs-runtime/assets';
106
+ import { createGameLoop } from '@volter/game-runtime/core/game-loop';
107
+ import type { ResolvedAdapterRoot } from '@volter/editor-project/manifest/load';
108
+ import { createGame, type GameInternal } from '@volter/game-runtime/runtime/game';
109
+ import { beginProjectMountEpoch } from '@volter/editor-sdk/session/project-module-url';
110
+ import type { ComponentType } from 'react';
111
+ import { type OidElementLike, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
112
+ import { paintedContentBounds } from './story-paint-bounds';
113
+
114
+ /** Set by the DOM re-projection right before it remounts a
115
+ * DOM candidate: the registry just published, so the mount reads it as is. */
116
+ let skipStoryRefreshForRemount = false;
117
+
118
+ /** Board chrome is also document navigation: apply the story and make its
119
+ * structural row the store selection so global editor surfaces route back to
120
+ * this React adapter rather than the previously selected world. */
121
+ function selectPortableStory(adapter: ReactRootAuthoringAdapter | null, storyId: string): void {
122
+ if (!adapter) return;
123
+ recordAuthoringConsumerUse({
124
+ adapter,
125
+ seam: 'editor.stories.apply',
126
+ stage: 'effect',
127
+ detail: `the design board applied portable story ${storyId}`,
128
+ run: () => adapter.stories.apply(storyId, storyId),
129
+ });
130
+ const selection = adapter.selection;
131
+ if (selection) {
132
+ recordAuthoringConsumerUse({
133
+ adapter,
134
+ seam: 'editor.selection.set',
135
+ stage: 'effect',
136
+ detail: `the design board selected portable story ${storyId}`,
137
+ run: () => selection.set([storyId]),
138
+ });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Seed the session's dom-story mount box from the project's DECLARED
144
+ * resolution, once — the same rung the story CAPTURE lane reads: the screen a
145
+ * UI story's anchors resolve against is a manifest declaration, not a magic
146
+ * "Fill". Seeded only while the session viewport is still untouched, so a
147
+ * size (or Fill) the user picked afterwards always stands, and a project
148
+ * declaring nothing keeps Fill.
149
+ */
150
+ let rootViewportSeeded = false;
151
+ async function seedRootViewportFromManifest(): Promise<void> {
152
+ if (rootViewportSeeded) return;
153
+ rootViewportSeeded = true;
154
+ const current = getRootCanvasViewport();
155
+ if (current.width !== null || current.height !== null) return;
156
+ try {
157
+ const text = await readProjectTextFile('vgai.project.json');
158
+ if (!text) return;
159
+ const manifest = JSON.parse(text) as {
160
+ resolution?: { width?: unknown; height?: unknown };
161
+ };
162
+ const width = manifest.resolution?.width;
163
+ const height = manifest.resolution?.height;
164
+ if (typeof width !== 'number' || typeof height !== 'number') return;
165
+ if (width < 64 || height < 64 || width > 4096 || height > 4096) return;
166
+ setRootCanvasViewport(width, height);
167
+ } catch {
168
+ // An unreadable manifest declares nothing; Fill stands.
169
+ }
170
+ }
171
+ /**
172
+ * The inert design-time Game stand-in (B1's one genuine design
173
+ * decision). A REAL `Game` — never a hand-typed duck-typed literal, mirroring
174
+ * this repo's own test convention (`packages/editor/test/adapter-conformance-kit.ts`'s
175
+ * `headlessGame()`) — built with a loop that is constructed but NEVER
176
+ * started/ticked, and with NO roots ever registered. This satisfies
177
+ * `useGame`/`useWorldState` (`@volter/game-runtime/react/world-state`, and any project's own
178
+ * re-export of it) without throwing, genuinely inertly:
179
+ * - `state.subscribe` never fires — nothing ever calls
180
+ * `GameStateBridgeInternal.bump()` (only `GameInternal.runFrame` does, and
181
+ * this Game's loop never runs a frame);
182
+ * - `state.frameVersion` stays `0` forever;
183
+ * - `queryByComponent`/`world()`/`roots` are empty/`null` because `roots`
184
+ * stays `[]` (nothing ever calls `registerRoot`).
185
+ * `defaultRoot`/`components`/`input`/`audio` throw descriptively if touched
186
+ * (same as any fresh `createGame()` before a world registers) — a HUD that
187
+ * reaches for those is out of scope for a design-time stand-in and SHOULD
188
+ * throw loudly, caught by the crash-null boundary below, degrading to the
189
+ * #18 error node rather than silently faking a value.
190
+ *
191
+ * B2 swaps this stand-in's DATA for story-sourced data (a `data`/services
192
+ * seam) — this function is the seam B2 replaces/extends, not a permanent
193
+ * fixture; it never constructs a live Game/sockets.
194
+ */
195
+ function createDesignTimeGame(): GameInternal {
196
+ const loop = createGameLoop({ fixedTimestep: 1 / 60, maxSubSteps: 8, update: () => {} });
197
+ return createGame({ loop, assets: createAssetCache() });
198
+ }
199
+
200
+ /**
201
+ * A minimal `ResolvedAdapterRoot` satisfying `resolveReactAdapterRootComponent`
202
+ * (`../binding-resolver.ts`, which reads only `id`/`kind`/`entry` off
203
+ * it) — the fields below it (`adapter`/`capabilities`/`loop`/`description`)
204
+ * are unused by that function but required by the type; filled with the
205
+ * honest "this is a native default-react world" values.
206
+ *
207
+ * `loop: 'gated'` here is the MANIFEST's declared-intent vocabulary
208
+ * (`ResolvedAdapterRoot.loop`, `@volter/editor-project/manifest/load`), and specifically the
209
+ * schema's own `.default('gated')` — not a verdict about anything. It is safe as
210
+ * a declaration precisely because nothing projects it: the only consumer of this
211
+ * stub is `resolveEntryComponent` below, the stub never reaches
212
+ * `resolveAllRoots` (so it mints no `RootMountSpec`), and the measured loop
213
+ * vocabulary that status and coverage speak is a different type entirely
214
+ * (`ingest/same-realm-loop-gate.ts`'s `MeasuredLoop`, which cannot be spelled
215
+ * without a probe's evidence).
216
+ */
217
+ function buildResolvedAdapterRootStub(worldId: string, entry: string): ResolvedAdapterRoot {
218
+ return {
219
+ id: worldId,
220
+ surface: 'dom',
221
+ description: undefined,
222
+ adapter: { type: 'builtin', identity: 'dom', surface: 'dom' },
223
+ entry,
224
+ zOrder: 0,
225
+ pausable: true,
226
+ loop: 'gated',
227
+ };
228
+ }
229
+
230
+ type EntryResolution = { ok: true; Entry: ComponentType } | { ok: false; error: unknown };
231
+
232
+ /**
233
+ * Load a React root's entry component ONCE per layer mount, without throwing.
234
+ * Both of this layer's paths need it: the derived default story is joined
235
+ * against this component's identity, and the inert-Game fallback renders it.
236
+ * A failure is carried rather than thrown so the story board can still open
237
+ * over a project whose entry is broken; only the fallback (which has nothing
238
+ * else to render) rethrows it.
239
+ */
240
+ async function resolveEntryComponent(
241
+ worldStub: ResolvedAdapterRoot,
242
+ projectRoot: string,
243
+ ): Promise<EntryResolution> {
244
+ try {
245
+ // Design-time preview layer: its own mount, therefore its own epoch —
246
+ // and therefore its own realm value over that epoch.
247
+ const realm = await activeRealmServices(projectRoot, beginProjectMountEpoch());
248
+ const Entry = await resolveReactAdapterRootComponent(worldStub, realm);
249
+ return { ok: true, Entry };
250
+ } catch (error) {
251
+ return { ok: false, error };
252
+ }
253
+ }
254
+
255
+ /**
256
+ * React design-time mount — the entry
257
+ * via the EXISTING #31 loader (`resolveReactAdapterRootComponent`, reused
258
+ * verbatim), wrapped in the canonical engine `WorldProvider`
259
+ * (`resolveWorldProviderForProject`) around the inert
260
+ * `createDesignTimeGame()` stand-in and a React error boundary.
261
+ * `flushSync` forces the initial render/commit/error-boundary-recovery to
262
+ * finish before this call returns (mirrors `ingest-siblings.ts`'s
263
+ * `mountDefaultReactSibling`), so a throwing entry is caught HERE —
264
+ * synchronously — rather than discovered later on react's own schedule.
265
+ *
266
+ * Portable CSF stories take the board path above. This branch is only the
267
+ * explicit no-story fallback and mounts the entry once against the inert Game.
268
+ * On success, it mints the world's live `ReactRootAuthoringAdapter` over this
269
+ * layer's now-mounted DOM — the adapter reflects
270
+ * an already-mounted root, it never mounts one itself; this function is the
271
+ * design-time mount and the adapter class itself is reused.
272
+ *
273
+ * MEASURED while moving it (the brief asked): BOTH `ReactRootAuthoringAdapter`
274
+ * constructions in this file are ADOPTIONS, not mounts. The board branch's
275
+ * (`focusedDomRoot`) reflects a board whose frames `mountIsolatedStory`
276
+ * already mounted, through a facade over the ACTIVE frame's live children;
277
+ * the fallback branch's reflects the layer `root.render`/`flushSync` has
278
+ * already committed into. Neither constructor mounts anything — which is why
279
+ * the adapter class is shared with Play's React branch and the ingest DOM
280
+ * surface, and why the mount is what moved here rather than the adapter.
281
+ */
282
+ export async function mountReactDesignLayer(
283
+ candidate: DesignTimeRootDescriptor,
284
+ layer: HTMLElement,
285
+ context: DesignTimeMountContext,
286
+ ): Promise<LayerMountResult> {
287
+ const { projectRootPath: projectRoot, store, activationDocumentId } = context;
288
+ // NOTE the ORDER: the entry is not required to reach the story board, so the
289
+ // "no entry" refusal below happens AFTER discovery, not before it. A board
290
+ // descriptor may have no entry by construction (the rootless UI board). A
291
+ // board's whole subject is its assigned project stories — an entry only ever
292
+ // picks which UI frame opens first. Refusing up front is what made a board
293
+ // impossible without a root, which is precisely what board presence no
294
+ // longer keys on.
295
+
296
+ // D20 — portable Storybook CSF is React's canonical design-time state.
297
+ // Discovery feeds the first-class hierarchy documents below this world.
298
+ // Edit mode lays EVERY composed story of the project out as an isolated,
299
+ // labeled frame on one editor-only board — board membership is
300
+ // project-global, the project's own component gallery. Play still tears
301
+ // this entire surface down and mounts only the manifest entry against the
302
+ // real Game.
303
+ //
304
+ // A remount that the registry's own publish requested already has fresh
305
+ // stories: refreshing again would reload every story module in parallel —
306
+ // the storm the content-write refresh was built to avoid.
307
+ if (skipStoryRefreshForRemount) skipStoryRefreshForRemount = false;
308
+ else await refreshProjectStories({ rootPath: projectRoot });
309
+ // …every story DECLARED as DOM. The registry is project-global and
310
+ // deliberately unfiltered, so THIS board filters its own membership the
311
+ // way the `3D` board filters its own — otherwise a
312
+ // three story is handed to `react-dom`, which renders `<group>`/`<primitive>`
313
+ // as unknown tags into an empty frame.
314
+ const previewStories = getProjectPreviewStories();
315
+ const portableStories = domStoryBoardMembers(previewStories);
316
+
317
+ // The root's composed default preview is DERIVED, never labeled: it is the
318
+ // story whose CSF `meta.component` is this root entry's default-exported
319
+ // component. Resolve that component first (the same loader the inert-Game
320
+ // fallback below reuses), then ask the registry for its story.
321
+ //
322
+ // The join is by component NAME, not function identity, and that is
323
+ // measured rather than assumed: a story module is imported at its own
324
+ // `/@fs/<path>?t=<now>` url (`story-discovery.ts`) while a root entry is
325
+ // imported at the mount epoch's `?vgai-mount=<n>` url
326
+ // (`project-module-url.ts`), and browser ES-module identity is per-url — so
327
+ // the component object a story's meta holds is never the object the entry
328
+ // loader returns. `pickComponentPreviewStory` (`story-registry.ts`) is the
329
+ // join this repo already ships for `three` prefab thumbnails: component
330
+ // name, preferring a story module in the component's own source directory.
331
+ const entryPath = candidate.path;
332
+ const entry: EntryResolution = entryPath
333
+ ? await resolveEntryComponent(
334
+ buildResolvedAdapterRootStub(candidate.worldId, entryPath),
335
+ projectRoot,
336
+ )
337
+ : {
338
+ ok: false,
339
+ error: new Error(
340
+ `design-time react layer "${candidate.worldId}": no \`entry\` declared — nothing to mount.`,
341
+ ),
342
+ };
343
+ const entryComponentName = entry.ok ? componentIdentityName(entry.Entry) : undefined;
344
+ const derivedDefaultPortableStory = entryComponentName
345
+ ? getComponentPreviewStory(entryComponentName, candidate.path)
346
+ : null;
347
+ const defaultPortableStory =
348
+ portableStories.find((story) => story.id === derivedDefaultPortableStory?.id) ?? null;
349
+ // The board exists because the PROJECT has stories, never because the
350
+ // derivation matched: an entry that fails to import, or a project whose
351
+ // stories name other components, still gets its whole gallery (the entry
352
+ // is only what picks the INITIAL frame). Without this the derivation would
353
+ // silently gate the entire design surface — a broken entry would take every
354
+ // story down with it.
355
+ if (portableStories.length > 0 && !candidate.entryOnly) {
356
+ const rememberedStoryId = rememberedPortableStory(projectRoot, candidate.worldId);
357
+ // Remembered choice, else the derived default, else discovery order —
358
+ // the same "no explicit default falls back to first" rule the registry
359
+ // has always used.
360
+ const initialPortableStory =
361
+ portableStories.find((story) => story.id === rememberedStoryId) ??
362
+ defaultPortableStory ??
363
+ portableStories[0]!;
364
+ // Only a board that HAS an entry can be missing a story for it. A rootless
365
+ // board (`projectStoryBoardDescriptor`) has no `path` BY CONSTRUCTION — the
366
+ // project declares no `dom` root, so there is no entry component and
367
+ // "give a *.stories.tsx a `meta.component` of this root's entry component"
368
+ // names an action that cannot be taken. Opening on the first story is that
369
+ // board's documented behaviour, not a degraded one. Measured on the
370
+ // repo-vendored `racing-game` ingest, whose manifest declares one ingest
371
+ // root and no dom root: a permanent unresolvable warning on every `vgai`
372
+ // command, for a board that was working exactly as designed.
373
+ if (candidate.path && !defaultPortableStory) {
374
+ editorConsole.warn(
375
+ `[design-time-layers] React root "${candidate.worldId}" has no story naming its entry ` +
376
+ `component${entryComponentName ? ` "${entryComponentName}"` : ''} — the board opens on ` +
377
+ `"${initialPortableStory.label}". Give a *.stories.tsx a \`meta.component\` of this ` +
378
+ "root's entry component to choose its default preview.",
379
+ 'authoring',
380
+ );
381
+ }
382
+ let disposed = false;
383
+ let adapter: ReactRootAuthoringAdapter | null = null;
384
+ // A story apply can originate from either hierarchy navigation or board
385
+ // interaction. Hierarchy has no pending board intent and therefore
386
+ // recenters; board clicks explicitly choose highlight-only or zoom.
387
+ let pendingBoardSelectionIntent: ReactStoryBoardSelectionIntent | null = null;
388
+ const mountedByStory = new Map<
389
+ string,
390
+ NonNullable<Awaited<ReturnType<typeof mountIsolatedStory>>>
391
+ >();
392
+ const storyPresentationIndex = createStoryPresentationIndex(portableStories);
393
+ await seedRootViewportFromManifest();
394
+ const board = createReactStoryBoard(
395
+ layer,
396
+ portableStories.map((story) => ({
397
+ id: story.id,
398
+ label: story.label,
399
+ modulePath: story.modulePath,
400
+ ...(story.title ? { title: story.title } : {}),
401
+ parameters: story.parameters,
402
+ ...(story.globals ? { globals: story.globals } : {}),
403
+ ...(story.viewportLocked !== undefined ? { viewportLocked: story.viewportLocked } : {}),
404
+ })),
405
+ initialPortableStory.id,
406
+ (storyId, intent) => {
407
+ pendingBoardSelectionIntent = intent;
408
+ selectPortableStory(adapter, storyId);
409
+ },
410
+ // The board never reaches for the CSF implementation itself
411
+ // (`@editor/authoring/story-board-presentation.ts`): this lane hands it
412
+ // one, reusing the index the React adapter is also constructed against.
413
+ storyBoardPresentation(portableStories, storyPresentationIndex),
414
+ );
415
+
416
+ /**
417
+ * Frame the subject: after a story commits, measure its painted union and
418
+ * hand the board the content rectangle to present as the cell — the same
419
+ * decision the canvas board makes from `stage.getBounds()`, made from the
420
+ * DOM. A `fullscreen` story presents its whole declared viewport, and so
421
+ * does a story whose painted union effectively spans it (a full-bleed
422
+ * screen needs no crop even when it forgot to declare). Measured twice —
423
+ * one settle turn after commit, and once more a second later — because a
424
+ * lazy()/Suspense screen commits its wrapper before its chunk lands.
425
+ */
426
+ const measureContent = (story: ProjectPreviewStory): void => {
427
+ if (disposed) return;
428
+ const frame = board.frames.get(story.id);
429
+ if (!frame) return;
430
+ const layout = (story.parameters as { layout?: unknown } | undefined)?.layout;
431
+ if (layout === 'fullscreen') {
432
+ board.setMeasuredFrame(story.id, null);
433
+ return;
434
+ }
435
+ const mount = { width: frame.content.offsetWidth, height: frame.content.offsetHeight };
436
+ const bounds = mount.width > 0 ? paintedContentBounds(frame.content) : null;
437
+ if (!bounds) {
438
+ board.setMeasuredFrame(story.id, null);
439
+ return;
440
+ }
441
+ const PAD = 8;
442
+ const MIN = 48;
443
+ let x0 = Math.max(0, bounds.x - PAD);
444
+ let y0 = Math.max(0, bounds.y - PAD);
445
+ let x1 = Math.min(mount.width, bounds.x + bounds.width + PAD);
446
+ let y1 = Math.min(mount.height, bounds.y + bounds.height + PAD);
447
+ if (x1 - x0 < MIN) {
448
+ const grow = (MIN - (x1 - x0)) / 2;
449
+ x0 = Math.max(0, x0 - grow);
450
+ x1 = Math.min(mount.width, x0 + MIN);
451
+ }
452
+ if (y1 - y0 < MIN) {
453
+ const grow = (MIN - (y1 - y0)) / 2;
454
+ y0 = Math.max(0, y0 - grow);
455
+ y1 = Math.min(mount.height, y0 + MIN);
456
+ }
457
+ if (x1 - x0 >= mount.width * 0.9 && y1 - y0 >= mount.height * 0.9) {
458
+ board.setMeasuredFrame(story.id, null);
459
+ return;
460
+ }
461
+ board.setMeasuredFrame(story.id, {
462
+ frame: { width: Math.round(x1 - x0), height: Math.round(y1 - y0) },
463
+ mount,
464
+ offset: { x: Math.round(x0), y: Math.round(y0) },
465
+ });
466
+ };
467
+ const scheduleContentMeasure = (story: ProjectPreviewStory): void => {
468
+ setTimeout(() => measureContent(story), 0);
469
+ setTimeout(() => measureContent(story), 1_000);
470
+ };
471
+
472
+ const mountPortable = async (
473
+ story: ProjectPreviewStory,
474
+ args: Record<string, unknown> = story.args,
475
+ ): Promise<void> => {
476
+ const content = board.frames.get(story.id)?.content;
477
+ if (!content) return;
478
+ try {
479
+ const next = await mountIsolatedStory(
480
+ content,
481
+ story.modulePath,
482
+ story.name,
483
+ story.Component,
484
+ args,
485
+ );
486
+ // A newer args update may claim this story's frame while its loader is
487
+ // still in flight. The portable mount reports that supersession as
488
+ // null; keep the newer handle intact.
489
+ if (!next) return;
490
+ if (disposed) {
491
+ next.unmount();
492
+ return;
493
+ }
494
+ mountedByStory.set(story.id, next);
495
+ board.clearError(story.id);
496
+ scheduleContentMeasure(story);
497
+ } catch (error) {
498
+ const message = formatMountFailureMessage(error);
499
+ board.setError(story.id, message);
500
+ editorConsole.error(
501
+ `[design-time-layers] React root "${candidate.worldId}" failed mounting portable ` +
502
+ `story "${story.name}": ${message}`,
503
+ 'authoring',
504
+ );
505
+ }
506
+ store.notifyIngestEdit();
507
+ };
508
+
509
+ // Each story owns its own React root and failure boundary. One broken
510
+ // loader therefore marks only its frame instead of blanking the board.
511
+ await Promise.all(portableStories.map((story) => mountPortable(story)));
512
+ // …and the board says so when the project's game CSS could not be applied.
513
+ // `mountIsolatedStory` has already resolved that state by now (it awaits
514
+ // the same per-project install before its first render), so this reads it
515
+ // rather than racing it. A first-party project reports nothing: `note` is
516
+ // non-null only where the host has EVIDENCE of missing page-level styling
517
+ // (`scoped-game-css.ts`).
518
+ board.setNote(scopedGameStylesState().note);
519
+ // A root document opened because the user clicked a hierarchy story must
520
+ // arrive focused on that remembered frame. A first visit with no prior
521
+ // choice keeps the board's fit-all overview.
522
+ if (rememberedStoryId) board.activate(initialPortableStory.id, true);
523
+
524
+ const byId = new Map(portableStories.map((story) => [story.id, story]));
525
+ // The adapter deliberately walks only the focused story's live DOM. All
526
+ // frames stay rendered, but Inspector/OID selection has one unambiguous
527
+ // source tree—the same source-global editing semantics as before.
528
+ const focusedDomRoot: OidElementLike = {
529
+ tagName: 'DIV',
530
+ get children() {
531
+ return board.activeContent().children as unknown as ArrayLike<OidElementLike>;
532
+ },
533
+ getAttribute: (name) => layer.getAttribute(name),
534
+ getBoundingClientRect: () => layer.getBoundingClientRect(),
535
+ get style() {
536
+ return layer.style;
537
+ },
538
+ get textContent() {
539
+ return board.activeContent().textContent;
540
+ },
541
+ };
542
+ // The recorder is decided by the ROUTE this host serves, never by how the
543
+ // shell bundle was built — see `tier-source-write-backend.ts` for the
544
+ // packaged-editor bug `import.meta.env.DEV` carried here.
545
+ const boardWriteBackend = await tierSourceWriteBackend();
546
+ adapter = new ReactRootAuthoringAdapter(focusedDomRoot, store, {
547
+ ...(boardWriteBackend ? { writeBackend: boardWriteBackend } : {}),
548
+ // The facade root above is a plain object, so the adapter's own
549
+ // scope-root token lookup cannot run — resolve tokens from the ACTIVE
550
+ // frame's real element instead (its game-CSS scope root carries the
551
+ // project's custom properties; the editor page root never does).
552
+ designTokens: () => {
553
+ const content = board.activeContent();
554
+ const scopeElement =
555
+ content.closest?.('[data-vgai-game-styles]') ??
556
+ content.querySelector?.('[data-vgai-game-styles]') ??
557
+ content;
558
+ return getDesignTokens(
559
+ getComputedStyle(scopeElement) as unknown as Parameters<typeof getDesignTokens>[0],
560
+ );
561
+ },
562
+ assetRoot: layer as unknown as OidElementLike,
563
+ portableStories: portableStories.map((story) => ({
564
+ id: story.id,
565
+ label: story.label,
566
+ args: story.args,
567
+ name: story.name,
568
+ modulePath: story.modulePath,
569
+ })),
570
+ portableHierarchy: storyPresentationIndex,
571
+ activePortableStoryId: initialPortableStory.id,
572
+ onPortableStoryApplied: (storyId) => {
573
+ const story = (storyId ? byId.get(storyId) : null) ?? initialPortableStory;
574
+ rememberPortableStory(projectRoot, candidate.worldId, story.id);
575
+ const intent = pendingBoardSelectionIntent;
576
+ pendingBoardSelectionIntent = null;
577
+ // Direct provider calls come from hierarchy navigation and retain
578
+ // auto-center. A board single-click only changes active/selection;
579
+ // its double-click is the explicit zoom gesture.
580
+ board.activate(story.id, intent === null || intent === 'zoom');
581
+ },
582
+ onPortableStoryArgsChanged: (storyId, args) => {
583
+ const story = byId.get(storyId);
584
+ if (!story) return;
585
+ void mountPortable(story, args).catch((error: unknown) => {
586
+ editorConsole.error(
587
+ `[design-time-layers] React root "${candidate.worldId}" failed updating portable ` +
588
+ `story "${story.name}" args: ${formatMountFailureMessage(error)}`,
589
+ 'authoring',
590
+ );
591
+ });
592
+ },
593
+ });
594
+ if (activationDocumentId) {
595
+ requestAnimationFrame(() => recordViewportFirstFrame(activationDocumentId));
596
+ }
597
+
598
+ return {
599
+ adapter,
600
+ dispose: () => {
601
+ disposed = true;
602
+ adapter.disposeReactRootAdapter();
603
+ for (const mounted of mountedByStory.values()) mounted.unmount();
604
+ mountedByStory.clear();
605
+ board.dispose();
606
+ },
607
+ };
608
+ }
609
+
610
+ // This board has no assigned stories, so an ordinary UI root's entry is the
611
+ // only thing left to show and a failure to load it is this layer's failure
612
+ // (the caller degrades it to a #18 disclosure node).
613
+ if (!entry.ok) throw entry.error instanceof Error ? entry.error : new Error(String(entry.error));
614
+
615
+ // An entryOnly mount (the pasteboard document) ASKED for exactly this
616
+ // branch — the file is the canvas and its default export the one subject —
617
+ // so "no CSF preview, falling back" would be noise about working-as-designed.
618
+ if (!candidate.entryOnly) {
619
+ editorConsole.warn(
620
+ `[design-time-layers] React root "${candidate.worldId}" has no portable CSF preview — the ` +
621
+ 'project declares no UI stories. Add a *.stories.tsx; give it a `meta.component` of ' +
622
+ `this root's entry component${entryComponentName ? ` (${entryComponentName}, ${candidate.path})` : ''} ` +
623
+ 'to make it this root’s default preview. Falling back to the inert-Game preview.',
624
+ 'authoring',
625
+ );
626
+ }
627
+
628
+ const Entry = entry.Entry;
629
+ const WorldProvider = await resolveWorldProviderForProject();
630
+ // C1 (phase-b/c1-react-mount): this layer's own react-world mount is a
631
+ // SECOND call site with the identical packaged-runtime dual-React-instance
632
+ // exposure `binding-resolver.ts`'s `ReactRootAdapter.mount()` has — under
633
+ // the packaged runtime, `createElement`/`createRoot`/`flushSync` must come
634
+ // from the PROJECT's own react, not this module's (removed) static
635
+ // imports. See `resolveReactRootMountRuntime`'s doc comment.
636
+ const { createElement, createRoot, flushSync } = await resolveReactRootMountRuntime();
637
+ const game = createDesignTimeGame();
638
+ const root = createRoot(layer);
639
+ const render = (onCaught: (error: unknown) => void): void => {
640
+ root.render(
641
+ createElement(CrashNullBoundary, {
642
+ key: '__entry__',
643
+ onCaught,
644
+ children: createElement(WorldProvider, { game }, createElement(Entry)),
645
+ }),
646
+ );
647
+ };
648
+
649
+ let caught: unknown = null;
650
+ flushSync(() => {
651
+ render((error: unknown) => {
652
+ caught = error;
653
+ });
654
+ });
655
+ if (caught !== null) {
656
+ root.unmount();
657
+ throw caught instanceof Error ? caught : new Error(String(caught));
658
+ }
659
+
660
+ // Same route-decided recorder as the board branch above.
661
+ const writeBackend = await tierSourceWriteBackend();
662
+ const adapter = new ReactRootAuthoringAdapter(layer, store, {
663
+ ...(writeBackend ? { writeBackend } : {}),
664
+ });
665
+ if (activationDocumentId) {
666
+ requestAnimationFrame(() => recordViewportFirstFrame(activationDocumentId));
667
+ }
668
+ return {
669
+ adapter,
670
+ dispose: () => {
671
+ // A4 (spec 27 §3) — release the adapter's `vite:afterUpdate` echo-reconcile
672
+ // subscription before dropping its DOM, so listeners don't accumulate across
673
+ // layer remounts (a leaked one is harmless — it early-returns on an empty echo —
674
+ // but this keeps teardown clean).
675
+ adapter.disposeReactRootAdapter();
676
+ root.unmount();
677
+ game.dispose();
678
+ },
679
+ };
680
+ }
681
+
682
+ /** A cheap identity for the project's current story MEMBERSHIP — which
683
+ * stories exist, not their content. An unreadable module counts as its own
684
+ * member, so a `.stories.tsx` that starts and stops parsing still moves the
685
+ * signature. */
686
+ function storyMembershipSignature(): string {
687
+ return getProjectStoryModules()
688
+ .map((module_) =>
689
+ module_.ok ? module_.stories.map((story) => story.id).join(',') : `!${module_.modulePath}`,
690
+ )
691
+ .join('|');
692
+ }
693
+
694
+ /**
695
+ * THIS MEDIUM'S STALENESS RULE (`DesignTimeMount.reprojectWhen`), moved here
696
+ * with the mount it is about.
697
+ *
698
+ * Story frames re-render through react-refresh; a host with no HMR channel at
699
+ * all kept showing the module the board mounted with after a source write — a dragged
700
+ * crosshair stayed where the live preview left it, and an UNDO (source back
701
+ * to `left: '50%'`) changed nothing on screen until F5. "Ctrl+Z just blinked"
702
+ * for every Windows tester and, measured on the instrument, for macOS too
703
+ * (runhuman passes 135-142; screen read 2026-09-03).
704
+ *
705
+ * The registry's publish after a content write is the moment the fresh module
706
+ * EXISTS, so that is the signal. The module set is compared because the
707
+ * registry notifies at the START of a refresh too (not ready, old modules):
708
+ * acting on the start notification remounted the board with the stale module
709
+ * and then dropped the real publish as "the mount's own" — the crosshair
710
+ * snapped back to its pre-drag spot with the source already at the new one
711
+ * (preview build 68, screen read).
712
+ *
713
+ * RE-PROJECT rather than remount: the host keeps the old layer up until the
714
+ * new one settles, because an empty board on top of the old one IS the flash
715
+ * (runhuman pass 146).
716
+ *
717
+ * ## THE SECOND RULE THIS CARRIES, arrived 2026-09-19 (WORK.md §The
718
+ * open-source launch item 18a)
719
+ *
720
+ * Story MEMBERSHIP — which stories exist, not what is in them — changing
721
+ * while the board is mounted is the other reason its frames go stale: a story
722
+ * saved from the editor's own header, or a hand-edited `.stories.tsx`, was
723
+ * invisible anywhere until a full reload (blind-walk beat 9). That rule lived
724
+ * in `@editor/components/world-documents.tsx`'s `RootDocumentContent`, which
725
+ * mounts the dom board AND the canvas Scene, so a canvas world's whole
726
+ * `<Application>` was being torn down and rebuilt every time an unrelated dom
727
+ * story was saved. It is a statement about a MEDIUM's mounted layers, so it
728
+ * belongs on that medium's mount, which is what `DesignTimeMount.reprojectWhen`
729
+ * is; the stack binds it per medium and applies it only to that medium's
730
+ * candidates (`design-time-layers.ts:717-742`).
731
+ *
732
+ * The two rules differ by TIER and by SIGNAL, which is why one hook carries
733
+ * both rather than two firing on the same publish: the content rule is
734
+ * the MEMBERSHIP of the story set: nothing adds a frame to a mounted board by
735
+ * itself, so a board re-projects when which stories exist changes. A CONTENT
736
+ * change does not re-project — the session's frames re-render through
737
+ * react-refresh.
738
+ */
739
+ export function reprojectWhenStoriesRepublish(invalidate: () => void): () => void {
740
+ // The module set the board was last mounted from, and — separately — the
741
+ // MEMBERSHIP of that set (which stories exist, not their content).
742
+ let mountedModules: readonly unknown[] | null = projectStoriesReady()
743
+ ? getProjectStoryModules()
744
+ : null;
745
+ let mountedMembership = mountedModules === null ? null : storyMembershipSignature();
746
+ let timer: ReturnType<typeof setTimeout> | null = null;
747
+ const stop = subscribeProjectStoryModules(() => {
748
+ if (!projectStoriesReady()) return;
749
+ const modules = getProjectStoryModules();
750
+ const membership = storyMembershipSignature();
751
+ if (mountedModules === null) {
752
+ // The mount's own publish: remember it, re-project nothing.
753
+ mountedModules = modules;
754
+ mountedMembership = membership;
755
+ return;
756
+ }
757
+ const membershipChanged = membership !== mountedMembership;
758
+ mountedModules = modules;
759
+ mountedMembership = membership;
760
+ if (!membershipChanged) return;
761
+ if (timer !== null) clearTimeout(timer);
762
+ timer = setTimeout(() => {
763
+ timer = null;
764
+ // The registry just published, so the mount about to run must not
765
+ // refresh it again: reloading every story module in parallel is the
766
+ // storm the content-write refresh was built to avoid. The host's
767
+ // re-projection runs `mountReactDesignLayer` synchronously enough for
768
+ // the flag to still be set when it reads it (it reads before its first
769
+ // `await`), and the clear is a macrotask behind that.
770
+ skipStoryRefreshForRemount = true;
771
+ invalidate();
772
+ setTimeout(() => {
773
+ skipStoryRefreshForRemount = false;
774
+ }, 0);
775
+ }, 250);
776
+ });
777
+ return () => {
778
+ if (timer !== null) clearTimeout(timer);
779
+ timer = null;
780
+ stop();
781
+ };
782
+ }