@volter/editor-game 0.5.66 → 0.5.67

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 (236) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +1 -1
  11. package/contributions/godot.style.ts +26 -6
  12. package/contributions/godot.view.ts +6 -0
  13. package/contributions/ingest.service.ts +2 -2
  14. package/contributions/instances.command.ts +1 -1
  15. package/contributions/navmesh.menu.ts +1 -1
  16. package/contributions/network-observer.service.ts +14 -0
  17. package/contributions/play.command.ts +10 -4
  18. package/contributions/react/component-board.service.ts +1 -1
  19. package/contributions/react/design-time-mount.service.ts +1 -1
  20. package/contributions/scene-document.service.ts +3 -3
  21. package/contributions/state-watch.menu.ts +1 -1
  22. package/contributions/state-watch.utility.tsx +1 -1
  23. package/contributions/team-playtest.service.ts +4 -4
  24. package/contributions/three/component-board.service.ts +1 -1
  25. package/contributions/three/component-verbs.command.ts +6 -6
  26. package/contributions/three/three-authoring.service.ts +8 -5
  27. package/contributions/three-story-capture.service.ts +12 -0
  28. package/contributions/unity.style.ts +17 -7
  29. package/contributions/unity.view.ts +6 -0
  30. package/contributions/unreal.style.ts +10 -2
  31. package/contributions/unreal.view.ts +5 -0
  32. package/package.json +20 -10
  33. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  34. package/src/asset-budget/asset-budget-model.ts +1 -1
  35. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  36. package/src/bridge/dispatch.ts +14 -14
  37. package/src/bridge/live-frames.ts +1 -1
  38. package/src/bridge/screenshot.ts +3 -3
  39. package/src/build/BuildProfilesPanel.tsx +3 -3
  40. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  41. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  42. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  43. package/src/canvas/canvas-design-mount.ts +524 -0
  44. package/src/canvas/design-time-canvas-mount.ts +79 -0
  45. package/src/coverage/live-authoring-surface.ts +5 -5
  46. package/src/coverage/live-project-verbs.ts +1 -1
  47. package/src/coverage/native-system-coverage.ts +5 -5
  48. package/src/coverage/root-coverage.ts +1 -1
  49. package/src/coverage/session-coverage.ts +3 -3
  50. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  51. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  52. package/src/edit-mode/edit-mode-audio.ts +4 -4
  53. package/src/edit-mode/edit-mode-networking.ts +2 -2
  54. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  55. package/src/game-document/GameDocument.tsx +2 -2
  56. package/src/game-document/GamePanel.tsx +4 -4
  57. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  58. package/src/game-document/crowd-debug.ts +1 -1
  59. package/src/game-document/device-preview.ts +10 -11
  60. package/src/game-document/physics-debug.ts +1 -1
  61. package/src/generation/GenerationActivity.tsx +3 -3
  62. package/src/generation/generation-documents.tsx +5 -5
  63. package/src/generation/generation-jobs.ts +1 -1
  64. package/src/host/adapter-runtime-bindings.ts +96 -5
  65. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  66. package/src/host/authoring/gesture-persist.ts +1 -1
  67. package/src/host/authoring/ingest-data-writer.ts +1 -1
  68. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  69. package/src/host/authoring/mounted-authoring.ts +4 -4
  70. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  71. package/src/host/authoring/pixi-authoring-adapter.ts +40 -15
  72. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  73. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  74. package/src/host/authoring/pixi-source-identity.ts +3 -3
  75. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  76. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  77. package/src/host/authoring/pixi-structure-history.ts +2 -2
  78. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  79. package/src/host/authoring/source-persistence-backend.ts +3 -3
  80. package/src/host/authoring/struct-write-pipe.ts +1 -1
  81. package/src/host/binding-resolver.ts +8 -9
  82. package/src/host/browser-transpile.ts +1 -1
  83. package/src/host/canvas-entry-runtime.ts +58 -47
  84. package/src/host/canvas-preview-frames.ts +482 -0
  85. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  86. package/src/host/components/HeaderTelemetry.tsx +4 -4
  87. package/src/host/components/PixiIsolationSceneContent.tsx +10 -10
  88. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  89. package/src/host/components/frame-debugger-model.ts +2 -2
  90. package/src/host/components/header-telemetry-model.ts +2 -2
  91. package/src/host/components/scene-document.tsx +14 -14
  92. package/src/host/components/utility-view-state.ts +1 -1
  93. package/src/host/components/world-root-stage-binding.tsx +12 -12
  94. package/src/host/components/world-root-stage.ts +70 -49
  95. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  96. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  97. package/src/host/document-preview-three.ts +1 -1
  98. package/src/host/entry-adjudication.ts +6 -6
  99. package/src/host/game-css-scope-transform.ts +4 -0
  100. package/src/host/game-realm-page.ts +1 -1
  101. package/src/host/gameplay-export.ts +25 -14
  102. package/src/host/gameplay-recording.ts +5 -5
  103. package/src/host/gated-globals.ts +2 -2
  104. package/src/host/history/json-history-resource.ts +1 -1
  105. package/src/host/projection/pixi.ts +24 -2
  106. package/src/host/r3f-entry-runtime.ts +65 -34
  107. package/src/host/react-mount-runtime.ts +7 -48
  108. package/src/host/realm-services.ts +1 -1
  109. package/src/host/roots/canvas-root.tsx +361 -0
  110. package/src/host/roots/r3f-root.tsx +473 -0
  111. package/src/host/roots/react-root.ts +9 -43
  112. package/src/host/served-bundle-runtime-modules.ts +1 -19
  113. package/src/host/server-log-bridge.ts +2 -2
  114. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  115. package/src/host/stories/pixi-story-model.ts +30 -0
  116. package/src/host/stories/story-media-captures.ts +46 -0
  117. package/src/host/stories/story-media-presence.ts +3 -3
  118. package/src/host/stories/story-pixi-preview.ts +408 -0
  119. package/src/host/stories/story-three-preview.ts +806 -0
  120. package/src/host/stories/three-story-captures.ts +35 -0
  121. package/src/host/story-three-preview-runtime.ts +56 -0
  122. package/src/host/use-active-performance-source.ts +2 -2
  123. package/src/host/viewport-pose-memory.ts +1 -1
  124. package/src/host/viewport-root-presentation.ts +6 -5
  125. package/src/ingest/active-ingest.ts +1 -1
  126. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  127. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  128. package/src/ingest/deferred-ingest-play.ts +6 -6
  129. package/src/ingest/discovery-public-ingest.ts +2 -2
  130. package/src/ingest/ingest-boot-viewport.ts +2 -2
  131. package/src/ingest/ingest-canvas-scene-document.tsx +8 -8
  132. package/src/ingest/ingest-canvas-scene.ts +3 -3
  133. package/src/ingest/ingest-evidence-hook.ts +1 -1
  134. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  135. package/src/ingest/ingest-play-control.ts +1 -1
  136. package/src/ingest/ingest-render-debug.ts +10 -10
  137. package/src/ingest/ingest-siblings.ts +13 -25
  138. package/src/ingest/module-mode.ts +14 -14
  139. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  140. package/src/ingest/mount-coverage.ts +2 -2
  141. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  142. package/src/ingest/mount-ingest-root.ts +8 -8
  143. package/src/ingest/mount-three-ingest-root.ts +8 -8
  144. package/src/ingest/resolve-canvas.ts +1 -1
  145. package/src/ingest/served-html-boot.ts +1 -1
  146. package/src/ingest/surface-canvas.ts +1 -1
  147. package/src/ingest/unmount-ingest-root.ts +5 -5
  148. package/src/navmesh/navmesh-handler.ts +24 -16
  149. package/src/network/NetworkInspectorPanel.tsx +449 -14
  150. package/src/network/network-inspector-model.ts +14 -2
  151. package/src/play/play-log-events.ts +1 -1
  152. package/src/play/play-mode.ts +76 -133
  153. package/src/play/play-recording.ts +1 -1
  154. package/src/play/react-play-live-authoring.ts +3 -3
  155. package/src/play/run-selection.ts +93 -0
  156. package/src/play-bar/PlayBar.tsx +20 -39
  157. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  158. package/src/profiler/PerformancePanel.tsx +3 -3
  159. package/src/react/design-time-react-mount.ts +23 -65
  160. package/src/react/dom-authoring-adapter.ts +9 -9
  161. package/src/react/react-inspector-section.tsx +5 -5
  162. package/src/react/react-world-authoring-adapter.ts +11 -11
  163. package/src/react/story-documents/story-documents.tsx +9 -9
  164. package/src/react/ui-board-document.tsx +7 -7
  165. package/src/react/ui-component-board.ts +2 -2
  166. package/src/runtime/adapter/audio-meter.ts +21 -0
  167. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  168. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  169. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  170. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  171. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  172. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  173. package/src/runtime/adapter/native-debug-module.ts +498 -0
  174. package/src/runtime/audio/bus-mixer.ts +161 -0
  175. package/src/runtime/audio/pose-guard.ts +80 -0
  176. package/src/runtime/core/frame-pacing.ts +126 -0
  177. package/src/runtime/core/game-loop.ts +225 -0
  178. package/src/runtime/core/game-scoped-slot.ts +28 -0
  179. package/src/runtime/core/seeded-random.ts +162 -0
  180. package/src/runtime/core/sim-clock.ts +391 -0
  181. package/src/runtime/core/system-runner.ts +269 -0
  182. package/src/runtime/core/types.ts +104 -0
  183. package/src/runtime/create-runtime.ts +1128 -0
  184. package/src/runtime/debug-bridge.ts +570 -0
  185. package/src/runtime/debug-registry.ts +899 -0
  186. package/src/runtime/dev/chrome-trace.ts +153 -0
  187. package/src/runtime/dev/instruments.ts +403 -0
  188. package/src/runtime/dev/logger.ts +119 -0
  189. package/src/runtime/dev/performance-profiler.ts +367 -0
  190. package/src/runtime/dev/register-render-vitals.ts +276 -0
  191. package/src/runtime/dev/render-census.ts +354 -0
  192. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  193. package/src/runtime/dev/render-memory.ts +226 -0
  194. package/src/runtime/dev/render-vitals.ts +338 -0
  195. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  196. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  197. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  198. package/src/runtime/dev-build.ts +47 -0
  199. package/src/runtime/game.ts +1636 -0
  200. package/src/runtime/gameplay-rng-trap.ts +135 -0
  201. package/src/runtime/host-context.ts +64 -0
  202. package/src/runtime/input-router.ts +169 -0
  203. package/src/runtime/mount-manifest.ts +480 -0
  204. package/src/runtime/pixi/authoring.ts +706 -0
  205. package/src/runtime/pixi/ingest.ts +116 -0
  206. package/src/runtime/pixi/physics-registry.ts +49 -0
  207. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  208. package/src/runtime/pixi/scene-capture.ts +179 -0
  209. package/src/runtime/pixi/system-adapters.ts +69 -0
  210. package/src/runtime/playtest.ts +22 -0
  211. package/src/runtime/presentation.ts +141 -0
  212. package/src/runtime/render-control.ts +642 -0
  213. package/src/runtime/render-seed.ts +77 -0
  214. package/src/runtime/run-ticks-settled.ts +73 -0
  215. package/src/runtime/setup/setup-audio.ts +72 -0
  216. package/src/services/audio-pose-guard.ts +2 -2
  217. package/src/services/game-audio.ts +152 -0
  218. package/src/services/game-network.ts +657 -0
  219. package/src/services/game-physics.ts +334 -0
  220. package/src/state-watch/StateWatchPanel.tsx +1 -1
  221. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  222. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  223. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  224. package/src/three/authoring/oid-source-persistence.ts +7 -7
  225. package/src/three/authoring/r3f-design-session.ts +58 -54
  226. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  227. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  228. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  229. package/src/three/component-verbs/extract-menu.ts +7 -6
  230. package/src/three/component-verbs/fork-menu.ts +7 -6
  231. package/src/three/component-verbs/internals-menu.ts +2 -2
  232. package/src/three/story-documents/three-story-documents.tsx +13 -13
  233. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  234. package/src/three/three-board/board-scene.ts +6 -6
  235. package/src/three/three-board/three-component-board.ts +2 -2
  236. package/src/services/game-audio-unlock.ts +0 -48
@@ -0,0 +1,676 @@
1
+ /**
2
+ * The 2D COMPONENTS board — every qualifying canvas story laid out as a frame
3
+ * on the project's design canvas, each frame hosting that story's OWN mounted
4
+ * Pixi `Application`, playing its cycle, framed to the component's content.
5
+ *
6
+ * It is a PEER OF THE `UI` BOARD, not of the `3D` one. A canvas story renders a
7
+ * `<canvas>` — it is DOM — so it is an ordinary frame on the shared story-board
8
+ * substrate, and everything a Figma-style board needs is already there and
9
+ * reused verbatim:
10
+ *
11
+ * - frame layout, group clustering and view fitting — `authoring/react-story-board.ts`
12
+ * - frame/group names, zoom-independent and collision-free — `authoring/story-board-chrome-fit.ts`
13
+ * - pan + zoom — the ONE shared singleton `authoring/world-pan-state.ts`,
14
+ * driven by `authoring/react-canvas-navigation.ts`
15
+ * - the dot-grid canvas and the alpha checkerboard — `authoring/react-design-canvas-style.ts`
16
+ * - districts — `stories/story-grouping.ts`
17
+ *
18
+ * What is genuinely this board's own is only what goes IN a frame (an adopted
19
+ * Pixi host instead of a React render) and WHO is on it
20
+ * (`canvas-board-model.ts`).
21
+ *
22
+ * ## True texel scale
23
+ *
24
+ * A frame is the mounted Pixi content's bounds with a small transparent inset.
25
+ * The `<Application width height>` is only a mounting harness; it becomes the
26
+ * frame when the story opts into Storybook's standard `layout: 'fullscreen'`.
27
+ * There is no scale channel between art and frame. Magnification is the
28
+ * board's shared zoom, and while it magnifies, exhibits' canvases are told to
29
+ * magnify TEXELS rather than blur them (see `applyView` below).
30
+ *
31
+ * ## One clock, and one context per exhibit
32
+ *
33
+ * Each exhibit arrives STOPPED (`stories/story-pixi-preview.ts` never lets a
34
+ * design-time story run on wall time) and is driven from THIS document's single
35
+ * rAF loop, which runs only while the document is the active tab. Each exhibit
36
+ * keeps its own renderer, which is what draws its own canvas — so the exhibit
37
+ * count is bounded by the browser's WebGL context budget (order of sixteen per
38
+ * page), not by layout. That ceiling is stated in `MountedStoryPixi.app`.
39
+ *
40
+ * ## Ghost slots open nothing
41
+ *
42
+ * A storyless canvas component gets a reserved, named frame with a dashed edge
43
+ * and NO fabricated render. The board says what is missing where the missing
44
+ * thing would be.
45
+ */
46
+
47
+ import { installReactCanvasNavigation } from '@volter/editor-sdk/kit/authoring/react-canvas-navigation';
48
+ import {
49
+ REACT_DESIGN_CANVAS_COLOR,
50
+ REACT_DESIGN_CANVAS_DOT,
51
+ } from '@volter/editor-sdk/kit/authoring/react-design-canvas-style';
52
+ import {
53
+ createReactStoryBoard,
54
+ type ReactStoryBoard,
55
+ type ReactStoryBoardStory,
56
+ } from '@volter/editor-sdk/kit/authoring/react-story-board';
57
+ import {
58
+ getRootPan,
59
+ panRootBy,
60
+ panTransformValue,
61
+ subscribeRootPan,
62
+ } from '@volter/editor-sdk/kit/world-pan-state';
63
+ import { registerPresentedPixiApps } from '../../host/canvas-preview-frames';
64
+ import { ReactCanvasControls } from '@volter/editor-sdk/kit/components/ReactCanvasControls';
65
+ import { themeVars } from '@volter/editor-sdk/widgets';
66
+ import { listProjectComponents } from '@volter/editor-sdk/kit/api/assets';
67
+ import { editorConsole } from '@volter/editor-sdk/kit/editor-console';
68
+ import { publishDocumentInspectionSubject } from '@volter/editor-sdk/kit/inspection/document-subject';
69
+ import { CANVAS_COMPONENTS_DOCUMENT_ID } from '@volter/editor-sdk/kit/workspace-document-ids';
70
+ import { registerAvailableWorkspaceDocument } from '@volter/editor-sdk/kit/workspace-available-documents';
71
+ import {
72
+ registerWorkspaceDocumentSelection,
73
+ type WorkspaceDocumentContentProps,
74
+ } from '@volter/editor-sdk/kit/workspace-document-registry';
75
+ import { storyBoardPresentation } from '@volter/editor-sdk/kit/stories/story-presentation';
76
+ import { getProjectStoryModules, subscribeProjectStoryModules } from '@volter/editor-sdk/kit/stories/story-registry';
77
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
78
+ import {
79
+ buildCanvasBoard,
80
+ type CanvasBoard,
81
+ disposeCanvasBoard,
82
+ GHOST_DISTRICT,
83
+ GHOST_FRAME_SIZE,
84
+ summarizeCanvasBoardSkips,
85
+ } from './canvas-board-model';
86
+
87
+ /** The board is a project-wide singleton: one generated view of one registry.
88
+ * Symmetric with the other pinned boards — a stable project-level id, so a
89
+ * persisted layout never strands the panel when a file is renamed. */
90
+ export { CANVAS_COMPONENTS_DOCUMENT_ID };
91
+
92
+ /** The tab reads `2D`, the peer of `3D` and `UI` in the center strip. */
93
+ const CANVAS_COMPONENTS_TITLE = '2D';
94
+
95
+ /** The narrow store surface this document needs. */
96
+ type BuildState =
97
+ | { readonly status: 'idle' }
98
+ | { readonly status: 'building' }
99
+ | { readonly status: 'ready'; readonly board: CanvasBoard }
100
+ | { readonly status: 'error'; readonly message: string };
101
+
102
+ /** How far a press may travel and still count as a click rather than a pan. */
103
+ const BOARD_CLICK_SLOP_PX = 4;
104
+
105
+ /** A live presentation of one built board over one container. */
106
+ interface CanvasBoardPresentation {
107
+ /** Drive (or freeze) every exhibit's own clock. */
108
+ setPlaying(playing: boolean): void;
109
+ /** Select the frame under a client point; false when the point hit no
110
+ * inactive frame. The whole frame is the target, not only its label. */
111
+ selectAt(clientX: number, clientY: number): boolean;
112
+ /** Recenter on the frame under a client point. */
113
+ zoomAt(clientX: number, clientY: number): boolean;
114
+ /** The exhibit the board is currently showing as active, for the Inspector. */
115
+ activeStoryId(): string | null;
116
+ dispose(): void;
117
+ }
118
+
119
+ /**
120
+ * The exhibits' rows, then the ghosts'.
121
+ *
122
+ * A row's `title` is the substrate's ORGANIZATION input, and this board's
123
+ * blocks are DISTRICTS rather than components: the board already shows one
124
+ * exhibit per component, so clustering by component would stack a column of
125
+ * one-frame blocks instead of the shelf a museum wants. So each row is titled
126
+ * with its district, which makes the block "Gameplay" and the frames inside it
127
+ * "Hero", "Grunt", "Brute". Ghosts share ONE trailing district, so the
128
+ * storyless components land after every real block — a "still to do" shelf,
129
+ * never interleaved with the museum.
130
+ *
131
+ * An ungrouped exhibit (no district) carries no title and falls back to the
132
+ * substrate's own module-path derivation, exactly like an untitled dom story.
133
+ */
134
+ function boardRows(board: CanvasBoard): ReactStoryBoardStory[] {
135
+ return [
136
+ ...board.exhibits.map((exhibit) => ({
137
+ id: exhibit.id,
138
+ label: exhibit.label,
139
+ modulePath: exhibit.modulePath,
140
+ ...(exhibit.district ? { title: exhibit.district } : {}),
141
+ frameSize: exhibit.frameSize,
142
+ })),
143
+ ...board.ghosts.map((ghost) => ({
144
+ id: ghost.key,
145
+ label: ghost.name,
146
+ modulePath: ghost.path,
147
+ title: GHOST_DISTRICT,
148
+ frameSize: GHOST_FRAME_SIZE,
149
+ })),
150
+ ];
151
+ }
152
+
153
+ /**
154
+ * Mount one built board into `container`: the shared design canvas' own
155
+ * transformed layer, the shared story board over it, and each story's mounted
156
+ * host adopted into its frame.
157
+ *
158
+ * Returns null for a board with nothing on it — the substrate requires at least
159
+ * one frame, and an empty board is a status line rather than a canvas.
160
+ */
161
+ function presentCanvasBoard(
162
+ container: HTMLElement,
163
+ board: CanvasBoard,
164
+ onSelectionChanged?: () => void,
165
+ ): CanvasBoardPresentation | null {
166
+ const rows = boardRows(board);
167
+ const first = rows[0];
168
+ if (!first) return null;
169
+
170
+ // The transformed world layer — the same role `design-time-layers.ts`'s
171
+ // layer plays for the UI board, and the same ONE shared transform value
172
+ // (`panTransformValue(getRootPan())`), so both boards' cameras are literally
173
+ // one camera and neither can drift from the other.
174
+ const layer = document.createElement('div');
175
+ layer.dataset['testid'] = 'canvas-board-layer';
176
+ layer.style.position = 'absolute';
177
+ layer.style.left = '0';
178
+ layer.style.top = '0';
179
+ layer.style.transformOrigin = '0 0';
180
+ container.appendChild(layer);
181
+
182
+ let storyBoard: ReactStoryBoard | null = null;
183
+ let selectedStoryId: string | null = first.id;
184
+ storyBoard = createReactStoryBoard(
185
+ layer,
186
+ rows,
187
+ first.id,
188
+ (storyId, intent) => {
189
+ // No authoring adapter stands behind this board: a frame click
190
+ // highlights, a double-click recenters on it. Both are the substrate's
191
+ // own. What the click DOES carry is identity, which the Inspector can
192
+ // say something honest about — see the document subject published below.
193
+ selectedStoryId = storyId;
194
+ storyBoard?.activate(storyId, intent === 'zoom');
195
+ onSelectionChanged?.();
196
+ },
197
+ // The substrate is host chrome's and does not import portable CSF; the
198
+ // lane that mounts a board supplies how its frames are organized and
199
+ // sized (`@volter/editor-sdk/kit/authoring/story-board-presentation.ts`).
200
+ storyBoardPresentation(rows),
201
+ );
202
+
203
+ const canvases: HTMLCanvasElement[] = [];
204
+ for (const exhibit of board.exhibits) {
205
+ const frame = storyBoard.frames.get(exhibit.id);
206
+ if (!frame) continue;
207
+ // The story's OWN host element, not its canvas: react owns the host's
208
+ // children, and moving a react-rendered node out from under its root makes
209
+ // unmount throw (`MountedStoryPixi.host`).
210
+ const host = exhibit.mounted.host;
211
+ host.setAttribute(
212
+ 'style',
213
+ `position:absolute;left:${-exhibit.crop.x}px;top:${-exhibit.crop.y}px;` +
214
+ `width:${exhibit.mounted.app.screen.width}px;height:${exhibit.mounted.app.screen.height}px;` +
215
+ 'overflow:visible;contain:layout paint;',
216
+ );
217
+ const canvas = exhibit.mounted.app.canvas as HTMLCanvasElement;
218
+ canvas.style.display = 'block';
219
+ // 1 declared px = 1 CSS px. Pixi's own `autoDensity` may have written a
220
+ // device-pixel-ratio size here; the frame is authored space, so it is the
221
+ // declared cell that goes on screen.
222
+ canvas.style.width = `${exhibit.mounted.app.screen.width}px`;
223
+ canvas.style.height = `${exhibit.mounted.app.screen.height}px`;
224
+ frame.content.appendChild(host);
225
+ canvases.push(canvas);
226
+ }
227
+
228
+ for (const ghost of board.ghosts) {
229
+ const frame = storyBoard.frames.get(ghost.key);
230
+ if (!frame) continue;
231
+ frame.element.style.border = `1px dashed ${themeVars.boundary.strong}`;
232
+ const empty = document.createElement('div');
233
+ empty.dataset['testid'] = 'canvas-board-ghost';
234
+ empty.textContent = 'No story yet';
235
+ empty.style.position = 'absolute';
236
+ empty.style.inset = '0';
237
+ empty.style.display = 'flex';
238
+ empty.style.alignItems = 'center';
239
+ empty.style.justifyContent = 'center';
240
+ empty.style.color = themeVars.content.dim;
241
+ empty.style.font = `11px ${themeVars.typography.sans}`;
242
+ empty.style.pointerEvents = 'none';
243
+ frame.content.appendChild(empty);
244
+ }
245
+
246
+ // The board's exhibits are what `capture-active-document` photographs, and a
247
+ // story's own `<Application>` has no `preserveDrawingBuffer` — so the shared
248
+ // composite lane needs Pixi's own extraction for exactly these canvases.
249
+ const unregisterApps = registerPresentedPixiApps(
250
+ board.exhibits.map((exhibit) => exhibit.mounted.app),
251
+ );
252
+
253
+ const applyView = (): void => {
254
+ const view = getRootPan();
255
+ layer.style.transform = panTransformValue(view) ?? '';
256
+ // MAGNIFY TEXELS, NEVER BLUR THEM. The board's zoom is a CSS scale over an
257
+ // already-rendered canvas, and a browser resamples that bilinearly by
258
+ // default — which would smooth away the nearest-neighbour sheet the game
259
+ // actually ships. Below 1× the opposite is true: dropping texels aliases,
260
+ // so the overview keeps the browser's own filtering.
261
+ const rendering = view.zoom >= 1 ? 'pixelated' : 'auto';
262
+ for (const canvas of canvases) canvas.style.imageRendering = rendering;
263
+ };
264
+ const unsubscribePan = subscribeRootPan(applyView);
265
+ applyView();
266
+
267
+ // The board's subject is the whole shelf, so it opens on the overview rather
268
+ // than on one frame (the substrate's own default, which suits a UI board
269
+ // being edited a state at a time). A frame this size at editing zoom would
270
+ // show one sprite and no neighbours to compare it with. After a frame, so it
271
+ // lands on the substrate's own initial fit rather than under it.
272
+ const openingFit = requestAnimationFrame(() => storyBoard?.fitAll());
273
+
274
+ let playing = false;
275
+ let frameHandle = 0;
276
+ let lastFrameMs = 0;
277
+ let elapsedMs = 0;
278
+ const tick = (now: number): void => {
279
+ frameHandle = requestAnimationFrame(tick);
280
+ // Exhibit clocks advance by REAL elapsed time, clamped so a backgrounded
281
+ // tab does not resume with one enormous step through every cycle.
282
+ elapsedMs += lastFrameMs === 0 ? 16 : Math.min(100, now - lastFrameMs);
283
+ lastFrameMs = now;
284
+ for (const exhibit of board.exhibits) {
285
+ try {
286
+ exhibit.mounted.ticker.update(elapsedMs);
287
+ } catch (error) {
288
+ // biome-ignore lint/suspicious/noConsole: a broken exhibit must stay diagnosable without stopping the board
289
+ console.error('[canvas-board] an exhibit failed to tick.', error);
290
+ }
291
+ }
292
+ };
293
+
294
+ return {
295
+ // THE WHOLE FRAME IS THE TARGET, not only its label. The substrate has
296
+ // always exposed this hit test — `selectReactStoryFrameAtPoint`'s own note
297
+ // calls it "the expected Figma-style click target" — but this board never
298
+ // wired it, so a frame could only be selected by clicking its small name
299
+ // (runhuman pass 86: "you have to click it by the name; if you click it by
300
+ // the card, you can't actually select it"). The UI board gets this through
301
+ // `RootSelectionOverlay`, which no adapter-less board mounts.
302
+ selectAt(clientX: number, clientY: number): boolean {
303
+ return storyBoard?.selectAtClientPoint(clientX, clientY) ?? false;
304
+ },
305
+ activeStoryId(): string | null {
306
+ return selectedStoryId;
307
+ },
308
+ zoomAt(clientX: number, clientY: number): boolean {
309
+ return storyBoard?.zoomAtClientPoint(clientX, clientY) ?? false;
310
+ },
311
+ setPlaying(next: boolean): void {
312
+ if (next === playing) return;
313
+ playing = next;
314
+ if (next) {
315
+ lastFrameMs = 0;
316
+ frameHandle = requestAnimationFrame(tick);
317
+ } else {
318
+ cancelAnimationFrame(frameHandle);
319
+ frameHandle = 0;
320
+ }
321
+ },
322
+ dispose(): void {
323
+ cancelAnimationFrame(frameHandle);
324
+ cancelAnimationFrame(openingFit);
325
+ unsubscribePan();
326
+ unregisterApps();
327
+ // Every adopted host goes home BEFORE the board's own DOM does: the host
328
+ // belongs to the story's mount, which is the only thing entitled to free
329
+ // it (the mount's own `dispose`, run by `disposeCanvasBoard`).
330
+ for (const exhibit of board.exhibits) exhibit.mounted.host.remove();
331
+ storyBoard?.dispose();
332
+ storyBoard = null;
333
+ layer.remove();
334
+ },
335
+ };
336
+ }
337
+
338
+ function CanvasBoardContent({ active }: WorkspaceDocumentContentProps) {
339
+ const modules = useSyncExternalStore(subscribeProjectStoryModules, getProjectStoryModules);
340
+ const containerRef = useRef<HTMLDivElement>(null);
341
+ const presentationRef = useRef<CanvasBoardPresentation | null>(null);
342
+ /** Where the current press started, so a pan is never read as a click. */
343
+ const pressRef = useRef<{ x: number; y: number } | null>(null);
344
+ /** The previous pointer position while a drag-pan is running. */
345
+ const dragRef = useRef<{ x: number; y: number } | null>(null);
346
+ const [state, setState] = useState<BuildState>({ status: 'idle' });
347
+
348
+ // The board is a STANDING tab whose panel Dockview mounts whether or not it
349
+ // is the active one, so mounting is not the trigger — FIRST ACTIVATION is.
350
+ // Building mounts every story off-screen, one at a time, and a tab nobody
351
+ // has looked at must cost nothing at boot. It latches: once shown, the board
352
+ // survives tab switches rather than being rebuilt on every return.
353
+ const [revealed, setRevealed] = useState(active);
354
+ useEffect(() => {
355
+ if (active) setRevealed(true);
356
+ }, [active]);
357
+
358
+ useEffect(() => {
359
+ const container = containerRef.current;
360
+ if (!container) return;
361
+ return installReactCanvasNavigation(container);
362
+ }, []);
363
+
364
+ // Rebuild on reveal and on every story-registry change; the previous board's
365
+ // ONE teardown runs here and nowhere else.
366
+ useEffect(() => {
367
+ if (!revealed) return;
368
+ const controller = new AbortController();
369
+ let built: CanvasBoard | null = null;
370
+ setState({ status: 'building' });
371
+ // The component index rides along so storyless canvas components get their
372
+ // ghost frames — the Content gallery's own discovery, never a second scan.
373
+ // An index failure degrades to "no ghosts", not to a failed board.
374
+ void listProjectComponents()
375
+ .then((listing) => {
376
+ // Still "no ghosts, not a failed board" — but SAID OUT LOUD, so a
377
+ // failed index does not look like a project with no components.
378
+ if (!listing.ok) {
379
+ editorConsole.warn(
380
+ `Component index unavailable, so this board shows no ghost frames: ${listing.reason}`,
381
+ 'canvas-board',
382
+ );
383
+ }
384
+ return buildCanvasBoard(modules, listing.ok ? listing.entries : [], controller.signal);
385
+ })
386
+ .then(
387
+ (board) => {
388
+ // A build can RESOLVE in the same turn that unmount aborts it, when
389
+ // cleanup already saw `built === null` — so a success landing after
390
+ // an abort frees itself here or it leaks.
391
+ if (controller.signal.aborted) {
392
+ void disposeCanvasBoard(board);
393
+ return;
394
+ }
395
+ built = board;
396
+ setState({ status: 'ready', board });
397
+ },
398
+ (error: unknown) => {
399
+ // A superseded build already freed itself in its own turn.
400
+ if (controller.signal.aborted) return;
401
+ setState({
402
+ status: 'error',
403
+ message: error instanceof Error ? error.message : String(error),
404
+ });
405
+ },
406
+ );
407
+ return () => {
408
+ controller.abort();
409
+ if (built) void disposeCanvasBoard(built);
410
+ };
411
+ }, [modules, revealed]);
412
+
413
+ const board = state.status === 'ready' ? state.board : null;
414
+ const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null);
415
+ useEffect(() => {
416
+ const container = containerRef.current;
417
+ if (!container || !board) return;
418
+ const presentation = presentCanvasBoard(container, board, () => {
419
+ setSelectedStoryId(presentationRef.current?.activeStoryId() ?? null);
420
+ });
421
+ presentationRef.current = presentation;
422
+ setSelectedStoryId(presentation?.activeStoryId() ?? null);
423
+ return () => {
424
+ presentationRef.current = null;
425
+ presentation?.dispose();
426
+ };
427
+ }, [board]);
428
+
429
+ /**
430
+ * WHAT THE SELECTED FRAME IS, said in the Inspector.
431
+ *
432
+ * This board stands behind no authoring adapter — a frame carries no
433
+ * editable transform — so clicking one used to leave the Inspector
434
+ * completely empty, which reads as the click having failed (runhuman pass
435
+ * 86). A frame still HAS an identity, and identity is exactly what a
436
+ * document may describe about itself: the component the exhibit shows, the
437
+ * story composing it, and the module both come from. Read-only by
438
+ * construction: no io, no fields, nothing that implies an edit this surface
439
+ * cannot perform.
440
+ */
441
+ const selectedExhibit = board?.exhibits.find((exhibit) => exhibit.id === selectedStoryId) ?? null;
442
+ // "I AM THE SUBJECT" — the half without which publishing is inert. With no
443
+ // registered selection the Inspector falls back to the shared entity
444
+ // selection, so the board would show whatever was picked in the scene
445
+ // before its tab was opened, and a board with nothing picked in it would
446
+ // never reach the no-selection seam at all
447
+ // (`workspace-document-registry.ts`'s `WorkspaceDocumentSelection`, and
448
+ // `components/AssetEditorShell.tsx`, which registers the same pair).
449
+ useEffect(() => {
450
+ if (!active) return;
451
+ return registerWorkspaceDocumentSelection(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
452
+ adapter: null,
453
+ nodeId: null,
454
+ }));
455
+ }, [active]);
456
+ useEffect(() => {
457
+ if (!active || !selectedExhibit) return;
458
+ return publishDocumentInspectionSubject(CANVAS_COMPONENTS_DOCUMENT_ID, () => ({
459
+ id: selectedExhibit.id,
460
+ title: selectedExhibit.label,
461
+ kindLabel: 'Canvas story',
462
+ note: {
463
+ text: `${selectedExhibit.storyName} · ${selectedExhibit.modulePath}`,
464
+ testId: 'canvas-board-subject-note',
465
+ },
466
+ sections: [],
467
+ }));
468
+ }, [active, selectedExhibit]);
469
+
470
+ // The exhibits' clocks run only while this document is the visible subject.
471
+ // Keyed on the BOARD as well as on `active`: a board that finishes building
472
+ // while its tab is already the active one never sees `active` change, and
473
+ // without this its exhibits would mount frozen on their first frame.
474
+ useEffect(() => {
475
+ presentationRef.current?.setPlaying(active);
476
+ return () => presentationRef.current?.setPlaying(false);
477
+ }, [active, board]);
478
+
479
+ return (
480
+ <div
481
+ data-testid="canvas-board"
482
+ data-vgai-backdrop-color={active ? REACT_DESIGN_CANVAS_COLOR : undefined}
483
+ data-vgai-backdrop-policy={active ? 'dark-frost' : undefined}
484
+ style={{
485
+ position: 'absolute',
486
+ inset: 0,
487
+ overflow: 'hidden',
488
+ pointerEvents: 'auto',
489
+ touchAction: 'none',
490
+ // A DRAG PANS; IT NEVER SWEEPS TEXT. Now that dragging moves the
491
+ // canvas, the browser's own text selection ran alongside it and left
492
+ // frame labels highlighted, which visibly fought the next pan — "you
493
+ // have to double- or triple-click somewhere else for the text to be
494
+ // unhighlighted" (runhuman pass 89). Board chrome is a surface to
495
+ // navigate, not prose to select.
496
+ userSelect: 'none',
497
+ WebkitUserSelect: 'none',
498
+ backgroundColor: REACT_DESIGN_CANVAS_COLOR,
499
+ }}
500
+ ref={containerRef}
501
+ // DRAG PANS, AND A CLICK STILL SELECTS. The board's navigation was
502
+ // wheel-only (wheel pans, cmd-wheel zooms — Figma's own model), so
503
+ // dragging did nothing at all and a tester reported "you can't pan" as
504
+ // their number one fix (runhuman pass 87). Any button drags the canvas,
505
+ // because this board has no marquee and no movable frames, so there is
506
+ // nothing else a drag could mean. The press point is remembered so a
507
+ // drag that ends over a frame stays a pan rather than selecting it —
508
+ // the movement threshold the Scene's own grammar uses, not a modifier.
509
+ onPointerDown={(event) => {
510
+ pressRef.current = { x: event.clientX, y: event.clientY };
511
+ dragRef.current = { x: event.clientX, y: event.clientY };
512
+ event.currentTarget.setPointerCapture(event.pointerId);
513
+ }}
514
+ onPointerMove={(event) => {
515
+ const from = dragRef.current;
516
+ if (!from) return;
517
+ const dx = event.clientX - from.x;
518
+ const dy = event.clientY - from.y;
519
+ if (dx === 0 && dy === 0) return;
520
+ dragRef.current = { x: event.clientX, y: event.clientY };
521
+ panRootBy(dx, dy);
522
+ }}
523
+ onPointerUp={(event) => {
524
+ const press = pressRef.current;
525
+ pressRef.current = null;
526
+ dragRef.current = null;
527
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
528
+ event.currentTarget.releasePointerCapture(event.pointerId);
529
+ }
530
+ if (!press) return;
531
+ const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y);
532
+ if (moved > BOARD_CLICK_SLOP_PX) return;
533
+ presentationRef.current?.selectAt(event.clientX, event.clientY);
534
+ }}
535
+ onPointerCancel={() => {
536
+ pressRef.current = null;
537
+ dragRef.current = null;
538
+ }}
539
+ onDoubleClick={(event) => {
540
+ presentationRef.current?.zoomAt(event.clientX, event.clientY);
541
+ }}
542
+ >
543
+ <DesignCanvasBackdrop />
544
+ {board ? (
545
+ <CanvasBoardStatus board={board} />
546
+ ) : (
547
+ <div
548
+ data-testid="canvas-board-status"
549
+ style={{ padding: 16, fontSize: 12, color: themeVars.content.muted }}
550
+ >
551
+ {state.status === 'error' && state.message}
552
+ {state.status === 'building' && 'Mounting every canvas story off-screen…'}
553
+ {state.status === 'idle' && 'Open this tab to lay out every canvas story at 1× texels.'}
554
+ </div>
555
+ )}
556
+ <ReactCanvasControls containerRef={containerRef} context={null} boardOnly />
557
+ </div>
558
+ );
559
+ }
560
+
561
+ /** Infinite editor canvas behind the frames — the same dot grid, following the
562
+ * same shared transform, as the UI board's. */
563
+ function DesignCanvasBackdrop() {
564
+ const view = useSyncExternalStore(subscribeRootPan, getRootPan);
565
+ const spacing = Math.max(8, 24 * view.zoom);
566
+ const dotRadius = Math.max(0.7, Math.min(1.4, view.zoom));
567
+ return (
568
+ <div
569
+ data-testid="react-design-canvas-backdrop"
570
+ aria-hidden="true"
571
+ style={{
572
+ position: 'absolute',
573
+ inset: 0,
574
+ pointerEvents: 'none',
575
+ backgroundColor: REACT_DESIGN_CANVAS_COLOR,
576
+ backgroundImage: `radial-gradient(circle, ${REACT_DESIGN_CANVAS_DOT} ${dotRadius}px, transparent ${dotRadius}px)`,
577
+ backgroundPosition: `${view.x}px ${view.y}px`,
578
+ backgroundSize: `${spacing}px ${spacing}px`,
579
+ }}
580
+ />
581
+ );
582
+ }
583
+
584
+ /** Counts, plus the honest list of stories that produced no Pixi content.
585
+ * Ghosts and skips are DIFFERENT facts — a ghost is a component with no story,
586
+ * a skip is a story that mounted nothing 2D — and the footer states both. */
587
+ function CanvasBoardStatus({ board }: { readonly board: CanvasBoard }) {
588
+ const [open, setOpen] = useState(false);
589
+ const skips = summarizeCanvasBoardSkips(board.skipped);
590
+ return (
591
+ <div
592
+ data-testid="canvas-board-status"
593
+ style={{
594
+ position: 'absolute',
595
+ left: 8,
596
+ bottom: 8,
597
+ maxWidth: 420,
598
+ padding: '4px 8px',
599
+ borderRadius: themeVars.shape.small,
600
+ background: themeVars.surface.raised,
601
+ border: `1px solid ${themeVars.boundary.default}`,
602
+ fontSize: 10,
603
+ color: themeVars.content.muted,
604
+ pointerEvents: 'auto',
605
+ }}
606
+ >
607
+ {board.exhibits.length} exhibit{board.exhibits.length === 1 ? '' : 's'}
608
+ {board.ghosts.length > 0 && (
609
+ <>
610
+ {' · '}
611
+ {board.ghosts.length} component{board.ghosts.length === 1 ? '' : 's'} without stories
612
+ </>
613
+ )}
614
+ {board.skipped.length > 0 && (
615
+ <>
616
+ {' · '}
617
+ {/* Inline disclosure toggle — the role-annotated span is the
618
+ guard-sanctioned shape for text-link chrome. */}
619
+ <span
620
+ role="button"
621
+ tabIndex={0}
622
+ aria-expanded={open}
623
+ onClick={() => setOpen((value) => !value)}
624
+ onKeyDown={(event) => {
625
+ if (event.key === ' ' || event.key === 'Enter') {
626
+ event.preventDefault();
627
+ setOpen((value) => !value);
628
+ }
629
+ }}
630
+ style={{ cursor: 'pointer', color: themeVars.accent.default }}
631
+ >
632
+ {skips.stories} {skips.stories === 1 ? 'story' : 'stories'} in {skips.modules}{' '}
633
+ {skips.modules === 1 ? 'module' : 'modules'} not on this board
634
+ </span>
635
+ </>
636
+ )}
637
+ {open && (
638
+ <div style={{ marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
639
+ {board.skipped.map((entry) => (
640
+ <div key={entry.id} style={{ padding: '1px 0' }}>
641
+ <code style={{ color: themeVars.content.dim }}>
642
+ {entry.modulePath}#{entry.storyName}
643
+ </code>{' '}
644
+ — {entry.reason}
645
+ </div>
646
+ ))}
647
+ </div>
648
+ )}
649
+ </div>
650
+ );
651
+ }
652
+
653
+ /**
654
+ * Offer the `2D` board as a document the person can open, the way its `UI` and `3D` peers are
655
+ * offered: the component-board registry installs it when the project has canvas stories
656
+ * (`host/stories/story-media-presence.ts`). It never steals focus, and it costs nothing until
657
+ * looked at.
658
+ */
659
+ export function installCanvasBoardDocument(): string {
660
+ return registerAvailableWorkspaceDocument(
661
+ {
662
+ id: CANVAS_COMPONENTS_DOCUMENT_ID,
663
+ title: CANVAS_COMPONENTS_TITLE,
664
+ // A generated browse-over-the-project's-content view, not an authored
665
+ // subject: it is regenerated from the story registry, never persisted.
666
+ kind: 'content-browser',
667
+ workspaceRole: 'workspace-reference',
668
+ Content: CanvasBoardContent,
669
+ closeable: false,
670
+ readOnly: true,
671
+ presentation: () => ({ kind: 'workspace', id: CANVAS_COMPONENTS_DOCUMENT_ID }),
672
+ },
673
+ // A board never steals focus; a canvas root's Scene is the project's default document.
674
+ { category: 'canvas' },
675
+ );
676
+ }