@volter/editor-game 0.5.66 → 0.5.68

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