@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,1380 @@
1
+ import { Checkbox, TextInput, themeVars } from '@volter/editor-sdk/widgets';
2
+
3
+ /**
4
+ * Story DOCUMENTS (W2 — inventory row B9): a selected CSF story opens as a
5
+ * CENTER workspace document (kind `'story'`, title = the story's export name
6
+ * as authored, §8), mounted through the SAME Storybook portable-story
7
+ * pipeline the Stories dock used (`stories/StoryPreviewMount.tsx`'s isolated
8
+ * `react-dom/client` root — never a child of the editor's own tree), with:
9
+ *
10
+ * - **args → Inspector** (§5 "Storybook args | right Inspector"): a
11
+ * story-scoped section registered through the EXISTING
12
+ * inspector-section registry, live-editable for primitive args (string/
13
+ * number/boolean — the Storybook-controls semantic); edits re-render the
14
+ * SAME composed story with override props (portable stories accept
15
+ * partial props over their composed args — Storybook's own API, not a
16
+ * re-implementation);
17
+ * - **addon results → utilities** (§5 "Actions/interactions … story-scoped
18
+ * utility tabs"): `Actions` (function-valued args are wrapped with
19
+ * logging spies at mount — the actions-addon behavior — and every call
20
+ * from the story's own rendered component is logged) and `Interactions`
21
+ * (the story's `play()` run results) are registered in the W0
22
+ * workspace-utility registry, available only while a story document is
23
+ * active. The host draws available registrations as utility tabs in the
24
+ * region below the viewport.
25
+ *
26
+ * §4.2 Play interplay: a story document renders in its OWN isolated preview
27
+ * root — never the live React world root. During Play the live connector
28
+ * owns the React root (design-time layers are suspended, `design-time-
29
+ * layers.ts`); opening/activating a story document must NOT stop Play and
30
+ * does not pretend the story is mounted on the root — it only drops the T6.3
31
+ * input gate (`onActivate` writes the store's `'scene'` tab) exactly like
32
+ * activating any non-Game document. What Stop restores on the root stays
33
+ * owned by `design-time-layers.ts`'s remembered per-root story selection.
34
+ *
35
+ * Per-document runtime state (arg overrides, action log, play results, the
36
+ * live preview container) lives in a module-scope map in the house
37
+ * `useSyncExternalStore` shape; dropped on document dispose.
38
+ */
39
+
40
+ // `@editor/active-project` and not `@editor/project-manager`: the same
41
+ // function, re-exported, without dragging the whole project-open/storage
42
+ // estate into this package's program.
43
+ import { getCurrentProject } from '@volter/editor-core/active-project';
44
+ import { type Resolution, ResolutionPicker } from '../../host/components/ResolutionPicker';
45
+ import { SurfaceStateOverlay } from '@volter/editor-core/components/SurfaceStateOverlay';
46
+ import { registerDocumentOpener } from '@volter/editor-core/document-open-registry';
47
+ import type { ViewportTab } from '@volter/editor-core/editor-shell-store';
48
+ import { createHmrRegistrationGroup } from '@volter/editor-core/hmr-registration-group';
49
+ import {
50
+ CONTRIBUTED_SECTION_ORDER,
51
+ STORY_ARGS_SECTION_ID,
52
+ STORY_ARGS_SECTION_TITLE,
53
+ } from '@volter/editor-core/inspection/model';
54
+ import { registerInspectorSections } from '@volter/editor-core/inspector-section-registry';
55
+ import type { ComposedProjectStory } from '@volter/editor-core/stories/compose-project-stories';
56
+ import { getProjectStoryRegions } from '@volter/editor-core/stories/project-story-regions';
57
+ import { mountIsolatedStory, runStoryPlay } from '@volter/editor-core/stories/StoryPreviewMount';
58
+ import { declaredStoryMedium } from '@volter/editor-core/stories/story-declared-medium';
59
+ import {
60
+ ISOLATED_STORY_DOCUMENT_OPENER,
61
+ STORY_DOCS_DOCUMENT_OPENER,
62
+ type StoryDocumentOpenRequest,
63
+ } from '@volter/editor-core/stories/story-document-openers';
64
+ import {
65
+ getProjectStoryModules,
66
+ refreshProjectStories,
67
+ subscribeProjectStoryModules,
68
+ whenProjectStoriesReady,
69
+ } from '@volter/editor-core/stories/story-registry';
70
+ import { domHasRenderableContent } from '../../host/surface-content';
71
+ import { explainSurface, type SurfaceContentState } from '@volter/editor-core/surface-state';
72
+ import { CONTRIBUTED_WORKSPACE_UTILITIES } from '@volter/editor-core/workspace-core-utilities';
73
+ import {
74
+ activeWorkspaceDocument,
75
+ openWorkspaceDocument,
76
+ type WorkspaceDocumentContentProps,
77
+ } from '@volter/editor-core/workspace-document-registry';
78
+ import { registerWorkspaceDocumentRestorer } from '@volter/editor-core/workspace-document-restore';
79
+ import { registerWorkspaceUtility } from '@volter/editor-core/workspace-utility-registry';
80
+ import { fitPresentation } from '@volter/game-runtime/runtime/presentation';
81
+ import { Button, fontMono, fontSizeVar, spaceVar } from '@volter/editor-sdk/widgets';
82
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
83
+ import { STORY_ARGS_SECTION_ICON } from './story-args-section';
84
+
85
+ /** One logged action-arg invocation (the actions-addon semantic). */
86
+ export interface StoryActionEntry {
87
+ readonly name: string;
88
+ readonly args: readonly unknown[];
89
+ readonly time: number;
90
+ }
91
+
92
+ export interface StoryAccessibilityViolation {
93
+ readonly id: string;
94
+ readonly impact: string | null;
95
+ readonly help: string;
96
+ readonly helpUrl: string;
97
+ readonly nodes: readonly { readonly html: string; readonly target: readonly string[] }[];
98
+ }
99
+
100
+ export interface StoryDocumentState {
101
+ readonly modulePath: string;
102
+ readonly storyName: string;
103
+ readonly mode: 'preview' | 'docs';
104
+ readonly store: StoryDocumentStore;
105
+ argOverrides: Record<string, unknown>;
106
+ actionLog: StoryActionEntry[];
107
+ playResult: { ran: boolean; error?: string } | null;
108
+ accessibility:
109
+ | { status: 'idle' | 'running'; violations: StoryAccessibilityViolation[]; error?: undefined }
110
+ | { status: 'complete'; violations: StoryAccessibilityViolation[]; error?: undefined }
111
+ | { status: 'error'; violations: StoryAccessibilityViolation[]; error: string };
112
+ /** The live isolated preview container (set by the content on mount) —
113
+ * what `play()` receives as its `canvasElement` scope. */
114
+ container: HTMLElement | null;
115
+ /** True only after the current composed story + loaders have mounted. */
116
+ previewReady: boolean;
117
+ /** Invalidates async play/accessibility results across remounts. */
118
+ previewRevision: number;
119
+ resolution: Resolution;
120
+ scale: number;
121
+ }
122
+
123
+ const _states = new Map<string, StoryDocumentState>();
124
+ let _version = 0;
125
+ const _listeners = new Set<() => void>();
126
+
127
+ function notifyChanged(): void {
128
+ _version++;
129
+ for (const fn of _listeners) fn();
130
+ }
131
+
132
+ /** Subscribe to story-document state changes. Returns unsubscribe. */
133
+ export function subscribeStoryDocuments(fn: () => void): () => void {
134
+ _listeners.add(fn);
135
+ return () => {
136
+ _listeners.delete(fn);
137
+ };
138
+ }
139
+
140
+ /** Monotonic change counter — the `getSnapshot` for `useSyncExternalStore`. */
141
+ export function storyDocumentsVersion(): number {
142
+ return _version;
143
+ }
144
+
145
+ /** Stable §7.1 identity for one story document. */
146
+ export function storyDocumentId(modulePath: string, storyName: string): string {
147
+ return `story:${modulePath}#${storyName}`;
148
+ }
149
+
150
+ /** Stable identity for the Storybook Docs companion document. */
151
+ export function storyDocsDocumentId(modulePath: string, storyName: string): string {
152
+ return `story-docs:${modulePath}#${storyName}`;
153
+ }
154
+
155
+ /** The state behind an open story document id, or `undefined`. */
156
+ export function storyDocumentState(id: string): StoryDocumentState | undefined {
157
+ return _states.get(id);
158
+ }
159
+
160
+ /** The ACTIVE center document's story state, or `null` when the active
161
+ * document isn't a story (utilities/inspector section gate on this). */
162
+ export function activeStoryDocumentState(): (StoryDocumentState & { id: string }) | null {
163
+ const active = activeWorkspaceDocument();
164
+ if (!active || active.descriptor.kind !== 'story') return null;
165
+ const state = _states.get(active.descriptor.id);
166
+ return state?.mode === 'preview' ? { ...state, id: active.descriptor.id } : null;
167
+ }
168
+
169
+ /** Set one arg override (Inspector Story Args section) — remounts the story
170
+ * against the merged args. `value === undefined` CLEARS the override (the
171
+ * `delete`-the-key convention used elsewhere, e.g. `MaterialSection.tsx`'s
172
+ * `setField`) rather than storing `undefined`/`NaN` — the composed-args
173
+ * spread merge (`composedStoryArgs`/`activeStoryDocumentState` callers) only
174
+ * falls back to the authored value when the key is ABSENT, so a stored
175
+ * `NaN` (e.g. from an emptied number input) would otherwise win over the
176
+ * authored value and also make the input go from controlled to `NaN`. */
177
+ export function setStoryArgOverride(id: string, key: string, value: unknown): void {
178
+ const state = _states.get(id);
179
+ if (!state) return;
180
+ if (value === undefined) {
181
+ const next = { ...state.argOverrides };
182
+ delete next[key];
183
+ state.argOverrides = next;
184
+ } else {
185
+ state.argOverrides = { ...state.argOverrides, [key]: value };
186
+ }
187
+ notifyChanged();
188
+ }
189
+
190
+ function logStoryAction(id: string, name: string, args: readonly unknown[]): void {
191
+ const state = _states.get(id);
192
+ if (!state) return;
193
+ state.actionLog = [...state.actionLog, { name, args, time: Date.now() }].slice(-200);
194
+ notifyChanged();
195
+ }
196
+
197
+ /** Clear the action log (Actions utility affordance). */
198
+ export function clearStoryActionLog(id: string): void {
199
+ const state = _states.get(id);
200
+ if (!state || state.actionLog.length === 0) return;
201
+ state.actionLog = [];
202
+ notifyChanged();
203
+ }
204
+
205
+ /** The narrow store surface story documents need (T6.3 gate hand-off —
206
+ * same contract as `asset-documents.tsx`). */
207
+ export interface StoryDocumentStore {
208
+ setActiveViewportTab(tab: ViewportTab): void;
209
+ }
210
+
211
+ /**
212
+ * Open (or activate) the center document for one named story. §4.2: this
213
+ * never touches the play session — during Play it only opens a tab; the
214
+ * live connector keeps owning the React root.
215
+ */
216
+ export function openStoryDocument(
217
+ store: StoryDocumentStore,
218
+ modulePath: string,
219
+ storyName: string,
220
+ options: { activate?: boolean; title?: string } = {},
221
+ ): string {
222
+ ensureStoryContributionsRegistered();
223
+ const id = storyDocumentId(modulePath, storyName);
224
+ if (!_states.has(id)) {
225
+ _states.set(id, {
226
+ modulePath,
227
+ storyName,
228
+ mode: 'preview',
229
+ store,
230
+ argOverrides: {},
231
+ actionLog: [],
232
+ playResult: null,
233
+ accessibility: { status: 'idle', violations: [] },
234
+ container: null,
235
+ previewReady: false,
236
+ previewRevision: 0,
237
+ resolution: { label: 'Fill', width: null, height: null },
238
+ scale: 1,
239
+ });
240
+ notifyChanged();
241
+ }
242
+ const openOptions = options.activate === undefined ? {} : { activate: options.activate };
243
+ return openWorkspaceDocument(
244
+ {
245
+ id,
246
+ // §8 — the story's export name exactly as authored in the CSF module
247
+ // (`Default`, `Playing`), never a humanized/uppercased variant.
248
+ title: options.title ?? storyName,
249
+ kind: 'story',
250
+ workspaceRole: 'authored-subject',
251
+ provenance: { sourcePath: modulePath },
252
+ Content: StoryDocumentContent,
253
+ Toolbar: StoryDocumentToolbar,
254
+ closeable: true,
255
+ presentation: () => ({ kind: 'story', modulePath, storyName, mode: 'preview' }),
256
+ persist: () => ({ modulePath, storyName }),
257
+ onActivate: () => store.setActiveViewportTab('edit'),
258
+ onDispose: (documentId) => {
259
+ if (_states.delete(documentId)) notifyChanged();
260
+ },
261
+ },
262
+ openOptions,
263
+ );
264
+ }
265
+
266
+ /** Open the composed story's Storybook Docs companion as a center document. */
267
+ export function openStoryDocsDocument(
268
+ store: StoryDocumentStore,
269
+ modulePath: string,
270
+ storyName: string,
271
+ ): string {
272
+ const id = storyDocsDocumentId(modulePath, storyName);
273
+ if (!_states.has(id)) {
274
+ _states.set(id, {
275
+ modulePath,
276
+ storyName,
277
+ mode: 'docs',
278
+ store,
279
+ argOverrides: {},
280
+ actionLog: [],
281
+ playResult: null,
282
+ accessibility: { status: 'idle', violations: [] },
283
+ container: null,
284
+ previewReady: false,
285
+ previewRevision: 0,
286
+ resolution: { label: 'Fill', width: null, height: null },
287
+ scale: 1,
288
+ });
289
+ notifyChanged();
290
+ }
291
+ return openWorkspaceDocument({
292
+ id,
293
+ title: `${storyName} — Docs`,
294
+ kind: 'story',
295
+ workspaceRole: 'workspace-reference',
296
+ provenance: { sourcePath: modulePath },
297
+ Content: StoryDocsDocumentContent,
298
+ closeable: true,
299
+ presentation: () => ({ kind: 'story', modulePath, storyName, mode: 'docs' }),
300
+ persist: () => ({ modulePath, storyName, view: 'docs' }),
301
+ onActivate: () => store.setActiveViewportTab('edit'),
302
+ onDispose: (documentId) => {
303
+ if (_states.delete(documentId)) notifyChanged();
304
+ },
305
+ });
306
+ }
307
+
308
+ /**
309
+ * THE STORY REGISTRY MUST BE SETTLED BEFORE ANY STORY DOCUMENT RESTORES —
310
+ * both this kind and the three-medium one (`three-story-documents.tsx`) read
311
+ * it synchronously to verify. Shared as ONE function so the restore host,
312
+ * which dedupes prepares by identity, refreshes once rather than twice (a
313
+ * second concurrent refresh bumps the registry's generation and aborts the
314
+ * first).
315
+ */
316
+ export function prepareStoryDocumentRestore(): Promise<void> {
317
+ const project = getCurrentProject();
318
+ if (!project) return Promise.resolve();
319
+ // Awaiting OUR refresh is not enough: a concurrent later pass (the
320
+ // discovery lifecycle's own, a project change) aborts this one, which then
321
+ // returns early over an EMPTY registry — and an empty registry reads as "the
322
+ // story is gone", so the restore drops the tab. Settle on the registry's own
323
+ // readiness instead; see `whenProjectStoriesReady`.
324
+ return refreshProjectStories(project).then(whenProjectStoriesReady, whenProjectStoriesReady);
325
+ }
326
+
327
+ /** Reopen a persisted story document. VERIFIED against the settled registry:
328
+ * the module must still load AND still export that story name. A preview
329
+ * additionally checks the story's DECLARED medium — a saved renderer is
330
+ * convenience state, not the story's current declaration, so a preview whose
331
+ * medium has moved to `three` is dropped here rather than mounted on the
332
+ * wrong one (`three-story-documents.tsx` restores that one). */
333
+ registerWorkspaceDocumentRestorer({
334
+ kind: 'story',
335
+ owner: 'story-documents',
336
+ prepare: prepareStoryDocumentRestore,
337
+ restore: ({ state, store }) => {
338
+ const record = state as
339
+ | { modulePath?: unknown; storyName?: unknown; view?: unknown }
340
+ | null
341
+ | undefined;
342
+ const modulePath = typeof record?.modulePath === 'string' ? record.modulePath : null;
343
+ const storyName = typeof record?.storyName === 'string' ? record.storyName : null;
344
+ if (!modulePath || !storyName) return false;
345
+ const module_ = getProjectStoryModules().find((m) => m.modulePath === modulePath);
346
+ if (!module_?.ok) return false;
347
+ if (!module_.stories.some((story) => story.name === storyName)) return false;
348
+ if (record?.view === 'docs') {
349
+ openStoryDocsDocument(store, modulePath, storyName);
350
+ return true;
351
+ }
352
+ const { medium } = declaredStoryMedium({ modulePath, regions: getProjectStoryRegions() });
353
+ if (medium !== 'dom' && medium !== 'canvas') return false;
354
+ openStoryDocument(store, modulePath, storyName);
355
+ return true;
356
+ },
357
+ });
358
+
359
+ /**
360
+ * The two addresses this module answers (`document-open-registry.ts`): the
361
+ * isolated preview and the Docs companion. Which address a gesture uses is
362
+ * the CALLER's decision — the declared medium for a routed open, the owning
363
+ * component's surface for the Content grid — exactly as it was when these
364
+ * were imported functions.
365
+ */
366
+ registerDocumentOpener<StoryDocumentOpenRequest>({
367
+ id: ISOLATED_STORY_DOCUMENT_OPENER,
368
+ owner: 'story-documents',
369
+ open: (store, request) =>
370
+ openStoryDocument(store, request.modulePath, request.storyName, {
371
+ ...(request.title === undefined ? {} : { title: request.title }),
372
+ ...(request.activate === undefined ? {} : { activate: request.activate }),
373
+ }),
374
+ });
375
+ registerDocumentOpener<StoryDocumentOpenRequest>({
376
+ id: STORY_DOCS_DOCUMENT_OPENER,
377
+ owner: 'story-documents',
378
+ open: (store, request) => openStoryDocsDocument(store, request.modulePath, request.storyName),
379
+ });
380
+
381
+ /** Test-only reset — drops all story-document state (pair with the document
382
+ * registry's own reset). */
383
+ export function __resetStoryDocumentsForTest(): void {
384
+ _states.clear();
385
+ notifyChanged();
386
+ }
387
+
388
+ // --- Story resolution against the live registry ---------------------------
389
+
390
+ function findComposedStory(
391
+ modulePath: string,
392
+ storyName: string,
393
+ ): { story: ComposedProjectStory | undefined; moduleError: string | undefined } {
394
+ const module_ = getProjectStoryModules().find((m) => m.modulePath === modulePath);
395
+ if (!module_) return { story: undefined, moduleError: undefined };
396
+ if (!module_.ok) return { story: undefined, moduleError: module_.error };
397
+ return { story: module_.stories.find((s) => s.name === storyName), moduleError: undefined };
398
+ }
399
+
400
+ /** A composed story's own args (Storybook attaches them to the portable
401
+ * component). Defensive read — `{}` when absent. */
402
+ export function composedStoryArgs(story: ComposedProjectStory): Record<string, unknown> {
403
+ const args = (story.Component as unknown as Record<string, unknown>)['args'];
404
+ return args && typeof args === 'object' ? (args as Record<string, unknown>) : {};
405
+ }
406
+
407
+ /** Merge order: composed args ← wrapped action spies ← user overrides. The
408
+ * spies re-wrap the ORIGINAL function so authored handlers still run. */
409
+ function buildOverrideProps(id: string, story: ComposedProjectStory): Record<string, unknown> {
410
+ const state = _states.get(id);
411
+ const overrides: Record<string, unknown> = {};
412
+ for (const [key, value] of Object.entries(composedStoryArgs(story))) {
413
+ if (typeof value === 'function') {
414
+ overrides[key] = (...callArgs: unknown[]) => {
415
+ logStoryAction(id, key, callArgs);
416
+ return (value as (...a: unknown[]) => unknown)(...callArgs);
417
+ };
418
+ }
419
+ }
420
+ return { ...overrides, ...(state?.argOverrides ?? {}) };
421
+ }
422
+
423
+ // --- Document content + toolbar -------------------------------------------
424
+
425
+ function StoryDocumentContent({ documentId }: WorkspaceDocumentContentProps) {
426
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
427
+ // Re-resolve the composed story on every registry refresh (project change,
428
+ // `story-files-changed` SSE) — same live-rescan physics as the Stories dock.
429
+ useSyncExternalStore(subscribeProjectStoryModules, () => getProjectStoryModules());
430
+ const state = _states.get(documentId);
431
+ const outerRef = useRef<HTMLDivElement | null>(null);
432
+ const containerRef = useRef<HTMLDivElement | null>(null);
433
+ const [loading, setLoading] = useState(false);
434
+ const [previewContent, setPreviewContent] = useState<SurfaceContentState>('unknown');
435
+ const [previewFailure, setPreviewFailure] = useState<string | null>(null);
436
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
437
+ const { story, moduleError } = state
438
+ ? findComposedStory(state.modulePath, state.storyName)
439
+ : { story: undefined, moduleError: undefined };
440
+ // Arg-override identity for the remount effect below.
441
+ const overridesKey = state ? JSON.stringify(state.argOverrides) : '';
442
+
443
+ useEffect(() => {
444
+ const element = outerRef.current;
445
+ if (!element) return;
446
+ if (typeof ResizeObserver === 'undefined') {
447
+ setContainerSize({ width: element.clientWidth, height: element.clientHeight });
448
+ return;
449
+ }
450
+ const observer = new ResizeObserver(([entry]) => {
451
+ if (!entry) return;
452
+ const { width, height } = entry.contentRect;
453
+ setContainerSize((current) =>
454
+ current.width === width && current.height === height ? current : { width, height },
455
+ );
456
+ });
457
+ observer.observe(element);
458
+ return () => observer.disconnect();
459
+ }, []);
460
+
461
+ useEffect(() => {
462
+ const container = containerRef.current;
463
+ if (!container || !state) return;
464
+ if (state.container !== container) {
465
+ state.container = container; // play()'s canvasElement scope
466
+ notifyChanged();
467
+ }
468
+ const previewRevision = state.previewRevision + 1;
469
+ state.previewRevision = previewRevision;
470
+ state.previewReady = false;
471
+ state.playResult = null;
472
+ state.accessibility = { status: 'idle', violations: [] };
473
+ notifyChanged();
474
+ setPreviewContent('unknown');
475
+ setPreviewFailure(null);
476
+ if (!story) {
477
+ setLoading(false);
478
+ setPreviewContent('empty');
479
+ return;
480
+ }
481
+ let cancelled = false;
482
+ let unmountFn: (() => void) | undefined;
483
+ let settleTask: ReturnType<typeof setTimeout> | undefined;
484
+ const updatePreviewContent = (): void => {
485
+ if (cancelled) return;
486
+ setPreviewContent(domHasRenderableContent(container) ? 'present' : 'empty');
487
+ };
488
+ const observer = new MutationObserver(updatePreviewContent);
489
+ observer.observe(container, { childList: true, subtree: true, characterData: true });
490
+ setLoading(true);
491
+ void mountIsolatedStory(
492
+ container,
493
+ state.modulePath,
494
+ state.storyName,
495
+ story.Component,
496
+ buildOverrideProps(documentId, story),
497
+ ).then(
498
+ (result) => {
499
+ if (cancelled) {
500
+ result?.unmount();
501
+ return;
502
+ }
503
+ if (!result) {
504
+ if (_states.get(documentId)?.previewRevision === previewRevision) {
505
+ setLoading(false);
506
+ setPreviewContent('empty');
507
+ }
508
+ return;
509
+ }
510
+ const current = _states.get(documentId);
511
+ if (!current || current.previewRevision !== previewRevision) {
512
+ result.unmount();
513
+ return;
514
+ }
515
+ unmountFn = result.unmount;
516
+ current.previewReady = true;
517
+ setLoading(false);
518
+ // `root.render()` may commit after mountIsolatedStory resolves. Cross one event-loop task;
519
+ // unlike rAF this also runs in a hidden editor tab, where live control and capture remain
520
+ // supported. The observer above keeps the answer live for later Suspense/async commits.
521
+ settleTask = setTimeout(updatePreviewContent, 0);
522
+ notifyChanged();
523
+ },
524
+ (reason: unknown) => {
525
+ if (cancelled || _states.get(documentId)?.previewRevision !== previewRevision) return;
526
+ setLoading(false);
527
+ setPreviewFailure(reason instanceof Error ? reason.message : String(reason));
528
+ setPreviewContent('empty');
529
+ },
530
+ );
531
+ return () => {
532
+ cancelled = true;
533
+ observer.disconnect();
534
+ if (settleTask !== undefined) clearTimeout(settleTask);
535
+ unmountFn?.();
536
+ };
537
+ // `story.Component` identity changes on registry refresh; overridesKey on
538
+ // arg edits — both remount, which is exactly the wanted behavior.
539
+ }, [documentId, state, story, overridesKey]);
540
+
541
+ const isFill = state?.resolution.width === null;
542
+ // Device-preview semantics: never upscale (`fitPresentation`'s default). This artboard is one of
543
+ // the two callers that module deduplicated — do not reintroduce inline fit math here.
544
+ const scale =
545
+ !state || isFill
546
+ ? 1
547
+ : fitPresentation(containerSize, {
548
+ width: state.resolution.width!,
549
+ height: state.resolution.height!,
550
+ }).scale;
551
+ useEffect(() => {
552
+ if (!state || !Number.isFinite(scale) || scale <= 0 || state.scale === scale) return;
553
+ state.scale = scale;
554
+ notifyChanged();
555
+ }, [scale, state]);
556
+
557
+ if (!state) {
558
+ return (
559
+ <div
560
+ style={{
561
+ padding: spaceVar[6],
562
+ fontSize: fontSizeVar.md,
563
+ color: themeVars.content.muted,
564
+ pointerEvents: 'auto',
565
+ }}
566
+ >
567
+ This story document is no longer available.
568
+ </div>
569
+ );
570
+ }
571
+
572
+ const storyRootId = `${state.modulePath}#${state.storyName}`;
573
+ const storyExplanation = !story
574
+ ? {
575
+ tone: 'error' as const,
576
+ title: `Story ${storyRootId} is unavailable`,
577
+ description:
578
+ moduleError ??
579
+ `No story export named “${state.storyName}” exists in ${state.modulePath}; it may have been renamed or removed.`,
580
+ }
581
+ : previewFailure
582
+ ? {
583
+ tone: 'error' as const,
584
+ title: `Story ${storyRootId} failed`,
585
+ description: previewFailure,
586
+ }
587
+ : explainSurface({
588
+ surface: 'Story',
589
+ rootIds: [storyRootId],
590
+ phase: loading ? 'loading' : 'ready',
591
+ content: previewContent,
592
+ });
593
+
594
+ return (
595
+ <div
596
+ style={{
597
+ // §2.31: no root fill — the dock document surface shows through.
598
+ flex: 1,
599
+ minHeight: 0,
600
+ display: 'flex',
601
+ flexDirection: 'column',
602
+ pointerEvents: 'auto',
603
+ }}
604
+ >
605
+ <div
606
+ ref={outerRef}
607
+ style={{
608
+ // §2.31: the letterbox around the device canvas is transparent —
609
+ // the story ARTBOARD below keeps its inset stage fill (a preview
610
+ // canvas backing, exempt like the viewport's).
611
+ position: 'relative',
612
+ flex: 1,
613
+ minHeight: 0,
614
+ overflow: 'auto',
615
+ display: 'flex',
616
+ alignItems: 'center',
617
+ justifyContent: 'center',
618
+ }}
619
+ >
620
+ <div
621
+ style={
622
+ isFill
623
+ ? { position: 'absolute', inset: 0 }
624
+ : {
625
+ position: 'relative',
626
+ width: state.resolution.width! * scale,
627
+ height: state.resolution.height! * scale,
628
+ flex: '0 0 auto',
629
+ }
630
+ }
631
+ >
632
+ <div
633
+ data-testid="story-preview-root"
634
+ ref={containerRef}
635
+ // A story owns the whole selected device canvas. Containment
636
+ // keeps fixed/full-page UI inside that canvas, never over editor
637
+ // chrome.
638
+ style={{
639
+ position: 'absolute',
640
+ inset: 0,
641
+ isolation: 'isolate',
642
+ contain: 'layout paint',
643
+ overflow: 'auto',
644
+ background: themeVars.surface.inset,
645
+ ...(isFill
646
+ ? {}
647
+ : {
648
+ width: state.resolution.width!,
649
+ height: state.resolution.height!,
650
+ transform: `scale(${scale})`,
651
+ transformOrigin: 'top left',
652
+ }),
653
+ }}
654
+ />
655
+ <SurfaceStateOverlay explanation={storyExplanation} testId="story-surface-status" />
656
+ </div>
657
+ </div>
658
+ </div>
659
+ );
660
+ }
661
+
662
+ function docsDescription(story: ComposedProjectStory): { component?: string; story?: string } {
663
+ const docs = story.parameters['docs'];
664
+ if (!docs || typeof docs !== 'object') return {};
665
+ const description = (docs as Record<string, unknown>)['description'];
666
+ if (typeof description === 'string') return { story: description };
667
+ if (!description || typeof description !== 'object') return {};
668
+ const record = description as Record<string, unknown>;
669
+ return {
670
+ ...(typeof record['component'] === 'string' ? { component: record['component'] } : {}),
671
+ ...(typeof record['story'] === 'string' ? { story: record['story'] } : {}),
672
+ };
673
+ }
674
+
675
+ /** Storybook Docs companion: composed CSF metadata, canvas, and args table. */
676
+ export function StoryDocsDocumentContent({ documentId }: WorkspaceDocumentContentProps) {
677
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
678
+ useSyncExternalStore(subscribeProjectStoryModules, () => getProjectStoryModules());
679
+ const state = _states.get(documentId);
680
+ const previewRef = useRef<HTMLDivElement | null>(null);
681
+ const [previewContent, setPreviewContent] = useState<SurfaceContentState>('unknown');
682
+ const [previewFailure, setPreviewFailure] = useState<string | null>(null);
683
+ const { story, moduleError } = state
684
+ ? findComposedStory(state.modulePath, state.storyName)
685
+ : { story: undefined, moduleError: undefined };
686
+ useEffect(() => {
687
+ const container = previewRef.current;
688
+ if (!container || !state || !story) return;
689
+ let disposed = false;
690
+ let unmount: (() => void) | null = null;
691
+ let settleTask: ReturnType<typeof setTimeout> | undefined;
692
+ setPreviewContent('unknown');
693
+ setPreviewFailure(null);
694
+ const updatePreviewContent = (): void => {
695
+ if (!disposed) setPreviewContent(domHasRenderableContent(container) ? 'present' : 'empty');
696
+ };
697
+ const observer = new MutationObserver(updatePreviewContent);
698
+ observer.observe(container, { childList: true, subtree: true, characterData: true });
699
+ void mountIsolatedStory(container, state.modulePath, state.storyName, story.Component).then(
700
+ (result) => {
701
+ if (!result) {
702
+ if (!disposed) setPreviewContent('empty');
703
+ return;
704
+ }
705
+ if (disposed) result.unmount();
706
+ else {
707
+ unmount = result.unmount;
708
+ settleTask = setTimeout(updatePreviewContent, 0);
709
+ }
710
+ },
711
+ (reason: unknown) => {
712
+ if (disposed) return;
713
+ setPreviewFailure(reason instanceof Error ? reason.message : String(reason));
714
+ setPreviewContent('empty');
715
+ },
716
+ );
717
+ return () => {
718
+ disposed = true;
719
+ observer.disconnect();
720
+ if (settleTask !== undefined) clearTimeout(settleTask);
721
+ unmount?.();
722
+ };
723
+ }, [state, story]);
724
+
725
+ if (!state || !story) {
726
+ return (
727
+ <div
728
+ style={{ padding: spaceVar[8], color: themeVars.semantic.danger, fontSize: fontSizeVar.md }}
729
+ >
730
+ {moduleError ?? 'This Storybook Docs document is no longer available.'}
731
+ </div>
732
+ );
733
+ }
734
+ const description = docsDescription(story);
735
+ const args = story.args;
736
+ const storyRootId = `${state.modulePath}#${state.storyName}`;
737
+ const canvasExplanation = previewFailure
738
+ ? {
739
+ tone: 'error' as const,
740
+ title: `Story ${storyRootId} failed`,
741
+ description: previewFailure,
742
+ }
743
+ : explainSurface({
744
+ surface: 'Story',
745
+ rootIds: [storyRootId],
746
+ phase: previewContent === 'unknown' ? 'loading' : 'ready',
747
+ content: previewContent,
748
+ });
749
+ return (
750
+ <article
751
+ data-testid="story-docs-document"
752
+ // §2.31 P2 amendment: docs prose reads over a local frost layer.
753
+ className="vgai-content-frost"
754
+ style={{
755
+ // §2.31: no root fill — the dock document surface shows through.
756
+ flex: 1,
757
+ minHeight: 0,
758
+ overflow: 'auto',
759
+ pointerEvents: 'auto',
760
+ padding: '24px clamp(18px, 5vw, 64px)',
761
+ }}
762
+ >
763
+ <div
764
+ style={{
765
+ maxWidth: 920,
766
+ margin: '0 auto',
767
+ display: 'flex',
768
+ flexDirection: 'column',
769
+ gap: 22,
770
+ }}
771
+ >
772
+ <header>
773
+ <div
774
+ style={{ color: themeVars.content.dim, fontSize: fontSizeVar.base, marginBottom: 5 }}
775
+ >
776
+ {story.title ?? state.modulePath}
777
+ </div>
778
+ <h1
779
+ style={{ color: themeVars.content.primary, fontSize: fontSizeVar.heading, margin: 0 }}
780
+ >
781
+ {story.label}
782
+ </h1>
783
+ {(description.story ?? description.component) && (
784
+ <p
785
+ style={{
786
+ color: themeVars.content.primary,
787
+ lineHeight: 1.6,
788
+ maxWidth: 760,
789
+ whiteSpace: 'pre-wrap',
790
+ }}
791
+ >
792
+ {description.story ?? description.component}
793
+ </p>
794
+ )}
795
+ </header>
796
+ <section>
797
+ <h2 style={{ color: themeVars.content.primary, fontSize: fontSizeVar.xl }}>Canvas</h2>
798
+ <div style={{ position: 'relative', minHeight: 180 }}>
799
+ <div
800
+ ref={previewRef}
801
+ data-testid="story-docs-canvas"
802
+ style={{
803
+ position: 'relative',
804
+ isolation: 'isolate',
805
+ contain: 'layout paint',
806
+ minHeight: 180,
807
+ padding: 18,
808
+ overflow: 'auto',
809
+ background: themeVars.surface.inset,
810
+ border: `1px solid ${themeVars.boundary.default}`,
811
+ borderRadius: themeVars.shape.medium,
812
+ }}
813
+ />
814
+ <SurfaceStateOverlay
815
+ explanation={canvasExplanation}
816
+ testId="story-docs-surface-status"
817
+ />
818
+ </div>
819
+ </section>
820
+ <section>
821
+ <h2 style={{ color: themeVars.content.primary, fontSize: fontSizeVar.xl }}>Args</h2>
822
+ <div
823
+ style={{
824
+ border: `1px solid ${themeVars.boundary.default}`,
825
+ borderRadius: themeVars.shape.medium,
826
+ overflow: 'hidden',
827
+ }}
828
+ >
829
+ {Object.entries(args).map(([name, value]) => (
830
+ <div
831
+ key={name}
832
+ style={{
833
+ display: 'grid',
834
+ gridTemplateColumns: 'minmax(120px, .4fr) minmax(140px, .6fr) 1fr',
835
+ gap: spaceVar[6],
836
+ padding: `${spaceVar[4]} ${spaceVar[5]}`,
837
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
838
+ fontSize: fontSizeVar.base,
839
+ }}
840
+ >
841
+ <code style={{ color: themeVars.accent.default }}>{name}</code>
842
+ <span style={{ color: themeVars.content.muted }}>
843
+ {Array.isArray(value) ? 'array' : typeof value}
844
+ </span>
845
+ <code
846
+ style={{
847
+ color: themeVars.content.primary,
848
+ overflow: 'hidden',
849
+ textOverflow: 'ellipsis',
850
+ }}
851
+ >
852
+ {typeof value === 'function' ? 'function' : JSON.stringify(value)}
853
+ </code>
854
+ </div>
855
+ ))}
856
+ {Object.keys(args).length === 0 && (
857
+ <div
858
+ style={{
859
+ padding: spaceVar[5],
860
+ color: themeVars.content.dim,
861
+ fontSize: fontSizeVar.base,
862
+ }}
863
+ >
864
+ This story declares no args.
865
+ </div>
866
+ )}
867
+ </div>
868
+ </section>
869
+ <footer style={{ color: themeVars.content.dim, fontSize: fontSizeVar.sm }}>
870
+ {state.modulePath} · {state.storyName}
871
+ </footer>
872
+ </div>
873
+ </article>
874
+ );
875
+ }
876
+
877
+ /** Document-local toolbar (§7.1 contribution): source module + Run play(). */
878
+ function StoryDocumentToolbar({ documentId }: WorkspaceDocumentContentProps) {
879
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
880
+ useSyncExternalStore(subscribeProjectStoryModules, () => getProjectStoryModules());
881
+ const state = _states.get(documentId);
882
+ if (!state) return null;
883
+ const { story } = findComposedStory(state.modulePath, state.storyName);
884
+ const hasPlay = typeof story?.Component.play === 'function';
885
+ const result = state.playResult;
886
+ return (
887
+ <div
888
+ data-testid="story-document-toolbar"
889
+ style={{
890
+ display: 'flex',
891
+ alignItems: 'center',
892
+ gap: spaceVar[4],
893
+ padding: '3px 10px',
894
+ fontSize: fontSizeVar.sm,
895
+ color: themeVars.content.muted,
896
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
897
+ }}
898
+ >
899
+ <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
900
+ {state.modulePath} —{' '}
901
+ <code style={{ color: themeVars.accent.default }}>{state.storyName}</code>
902
+ </span>
903
+ <ResolutionPicker
904
+ resolution={state.resolution}
905
+ scale={state.scale}
906
+ onResolutionChange={(resolution) => {
907
+ state.resolution = resolution;
908
+ notifyChanged();
909
+ }}
910
+ />
911
+ <Button
912
+ size="compact"
913
+ data-testid="story-open-docs"
914
+ onClick={() => openStoryDocsDocument(state.store, state.modulePath, state.storyName)}
915
+ style={{ flexShrink: 0 }}
916
+ >
917
+ Docs
918
+ </Button>
919
+ {hasPlay && story && (
920
+ <Button
921
+ variant="outline"
922
+ size="compact"
923
+ data-testid="story-run-play"
924
+ onClick={async () => {
925
+ if (!state.container || !state.previewReady) return;
926
+ const previewRevision = state.previewRevision;
927
+ const playResult = await runStoryPlay(
928
+ state.modulePath,
929
+ state.storyName,
930
+ story.Component,
931
+ state.container,
932
+ );
933
+ const current = _states.get(documentId);
934
+ if (current?.previewReady && current.previewRevision === previewRevision) {
935
+ current.playResult = playResult;
936
+ notifyChanged();
937
+ }
938
+ }}
939
+ disabled={!state.previewReady}
940
+ style={{ flexShrink: 0 }}
941
+ >
942
+ Run play()
943
+ </Button>
944
+ )}
945
+ {result?.ran && !result.error && (
946
+ <span
947
+ data-testid="story-play-result"
948
+ style={{ color: themeVars.semantic.success, fontWeight: 600 }}
949
+ >
950
+ play() ran
951
+ </span>
952
+ )}
953
+ {result?.error && (
954
+ <span data-testid="story-play-result" style={{ color: themeVars.semantic.danger }}>
955
+ {result.error}
956
+ </span>
957
+ )}
958
+ </div>
959
+ );
960
+ }
961
+
962
+ // --- Inspector "Story Args" section (§5: Storybook args → right Inspector) -
963
+
964
+ function ArgRow({ id, name, value }: { id: string; name: string; value: unknown }) {
965
+ return (
966
+ <div style={{ display: 'flex', alignItems: 'center', gap: spaceVar[3], padding: '3px 0' }}>
967
+ <span
968
+ style={{
969
+ width: 90,
970
+ flexShrink: 0,
971
+ fontSize: fontSizeVar.base,
972
+ color: themeVars.content.muted,
973
+ overflow: 'hidden',
974
+ textOverflow: 'ellipsis',
975
+ }}
976
+ title={name}
977
+ >
978
+ {name}
979
+ </span>
980
+ {typeof value === 'boolean' ? (
981
+ <Checkbox
982
+ className="vgai-checkbox"
983
+ data-testid={`story-arg-${name}`}
984
+ checked={value}
985
+ onChange={(e) => setStoryArgOverride(id, name, e.target.checked)}
986
+ />
987
+ ) : typeof value === 'number' ? (
988
+ <TextInput
989
+ type="number"
990
+ className="vgai-input"
991
+ data-testid={`story-arg-${name}`}
992
+ value={value}
993
+ onChange={(e) => {
994
+ // Empty/unparseable input clears the override (falls back to the
995
+ // authored arg) instead of setting NaN — `Number.parseFloat('')`
996
+ // is NaN, and storing NaN would both win over the authored value
997
+ // in the composed-args merge (see `setStoryArgOverride`'s doc
998
+ // comment) and make this a controlled input whose displayed
999
+ // `value` is `NaN`, which React warns about.
1000
+ const raw = e.target.value;
1001
+ const parsed = Number.parseFloat(raw);
1002
+ setStoryArgOverride(id, name, raw === '' || Number.isNaN(parsed) ? undefined : parsed);
1003
+ }}
1004
+ style={{ width: 80 }}
1005
+ />
1006
+ ) : typeof value === 'string' ? (
1007
+ <TextInput
1008
+ type="text"
1009
+ className="vgai-input"
1010
+ data-testid={`story-arg-${name}`}
1011
+ value={value}
1012
+ onChange={(e) => setStoryArgOverride(id, name, e.target.value)}
1013
+ style={{ flex: 1, minWidth: 0 }}
1014
+ />
1015
+ ) : (
1016
+ <span
1017
+ style={{ fontSize: fontSizeVar.base, color: themeVars.content.dim }}
1018
+ data-testid={`story-arg-${name}`}
1019
+ >
1020
+ {typeof value === 'function' ? 'fn (logged to Actions)' : JSON.stringify(value)}
1021
+ </span>
1022
+ )}
1023
+ </div>
1024
+ );
1025
+ }
1026
+
1027
+ /** Registered section: renders while the ACTIVE center document is a story —
1028
+ * the args of THAT story (composed args merged with live overrides). */
1029
+ export function StoryArgsSection() {
1030
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
1031
+ useSyncExternalStore(subscribeProjectStoryModules, () => getProjectStoryModules());
1032
+ const active = activeStoryDocumentState();
1033
+ if (!active) return null;
1034
+ const { story } = findComposedStory(active.modulePath, active.storyName);
1035
+ const args = story ? { ...composedStoryArgs(story), ...active.argOverrides } : {};
1036
+ const names = Object.keys(args);
1037
+ return (
1038
+ <div
1039
+ data-testid="inspector-story-args"
1040
+ style={{
1041
+ pointerEvents: 'auto',
1042
+ padding: spaceVar[4],
1043
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
1044
+ }}
1045
+ >
1046
+ <div
1047
+ style={{
1048
+ fontSize: fontSizeVar.base,
1049
+ color: themeVars.content.dim,
1050
+ textTransform: 'uppercase',
1051
+ marginBottom: spaceVar[2],
1052
+ }}
1053
+ >
1054
+ Story Args
1055
+ </div>
1056
+ <div
1057
+ style={{
1058
+ fontSize: fontSizeVar.base,
1059
+ color: themeVars.content.primary,
1060
+ fontWeight: 600,
1061
+ marginBottom: spaceVar[2],
1062
+ }}
1063
+ >
1064
+ {active.storyName}
1065
+ </div>
1066
+ {names.length === 0 && (
1067
+ <div style={{ fontSize: fontSizeVar.base, color: themeVars.content.dim }}>
1068
+ {story ? 'This story declares no args.' : 'Story source unavailable.'}
1069
+ </div>
1070
+ )}
1071
+ {names.map((name) => (
1072
+ <ArgRow key={name} id={active.id} name={name} value={args[name]} />
1073
+ ))}
1074
+ </div>
1075
+ );
1076
+ }
1077
+
1078
+ // --- Utilities: Actions + Interactions (§5 story-scoped utility tabs) ------
1079
+
1080
+ /** Actions utility content: the wrapped-arg call log of the active story. */
1081
+ export function StoryActionsUtility() {
1082
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
1083
+ const active = activeStoryDocumentState();
1084
+ if (!active) {
1085
+ return (
1086
+ <div style={{ padding: spaceVar[6], fontSize: fontSizeVar.md, color: themeVars.content.dim }}>
1087
+ No active story.
1088
+ </div>
1089
+ );
1090
+ }
1091
+ return (
1092
+ <div
1093
+ data-testid="story-actions-utility"
1094
+ style={{
1095
+ flex: 1,
1096
+ minHeight: 0,
1097
+ overflowY: 'auto',
1098
+ padding: spaceVar[4],
1099
+ fontSize: fontSizeVar.base,
1100
+ }}
1101
+ >
1102
+ <Button
1103
+ size="compact"
1104
+ style={{ marginBottom: spaceVar[3] }}
1105
+ onClick={() => clearStoryActionLog(active.id)}
1106
+ >
1107
+ Clear
1108
+ </Button>
1109
+ {active.actionLog.length === 0 ? (
1110
+ <div style={{ color: themeVars.content.dim }}>
1111
+ No actions logged — function-valued args are wrapped with logging spies; interact with the
1112
+ story preview to see calls here.
1113
+ </div>
1114
+ ) : (
1115
+ active.actionLog.map((entry, i) => (
1116
+ <div
1117
+ key={i}
1118
+ style={{
1119
+ padding: `${spaceVar[1]} 0`,
1120
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
1121
+ }}
1122
+ >
1123
+ <code style={{ color: themeVars.accent.default }}>{entry.name}</code>{' '}
1124
+ <span style={{ color: themeVars.content.muted }}>
1125
+ (
1126
+ {entry.args
1127
+ .map((a) => {
1128
+ try {
1129
+ return typeof a === 'object' ? JSON.stringify(a)?.slice(0, 80) : String(a);
1130
+ } catch {
1131
+ return String(a);
1132
+ }
1133
+ })
1134
+ .join(', ')}
1135
+ )
1136
+ </span>
1137
+ </div>
1138
+ ))
1139
+ )}
1140
+ </div>
1141
+ );
1142
+ }
1143
+
1144
+ /** Interactions utility content: the active story's play() run results. */
1145
+ export function StoryInteractionsUtility() {
1146
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
1147
+ const active = activeStoryDocumentState();
1148
+ if (!active) {
1149
+ return (
1150
+ <div style={{ padding: spaceVar[6], fontSize: fontSizeVar.md, color: themeVars.content.dim }}>
1151
+ No active story.
1152
+ </div>
1153
+ );
1154
+ }
1155
+ const result = active.playResult;
1156
+ return (
1157
+ <div
1158
+ data-testid="story-interactions-utility"
1159
+ style={{
1160
+ flex: 1,
1161
+ minHeight: 0,
1162
+ overflowY: 'auto',
1163
+ padding: spaceVar[6],
1164
+ fontSize: fontSizeVar.md,
1165
+ }}
1166
+ >
1167
+ {!result && (
1168
+ <div style={{ color: themeVars.content.dim }}>
1169
+ No interaction run yet — use the story document's “Run play()”.
1170
+ </div>
1171
+ )}
1172
+ {result && !result.ran && (
1173
+ <div style={{ color: themeVars.content.dim }}>This story declares no play() function.</div>
1174
+ )}
1175
+ {result?.ran && !result.error && (
1176
+ <div style={{ color: themeVars.semantic.success, fontWeight: 600 }}>
1177
+ play() ran without errors.
1178
+ </div>
1179
+ )}
1180
+ {result?.error && (
1181
+ <div style={{ color: themeVars.semantic.danger, whiteSpace: 'pre-wrap' }}>
1182
+ {result.error}
1183
+ </div>
1184
+ )}
1185
+ </div>
1186
+ );
1187
+ }
1188
+
1189
+ /** Run axe-core against the active story's isolated preview root. */
1190
+ export async function runStoryAccessibility(id: string): Promise<void> {
1191
+ const state = _states.get(id);
1192
+ if (!state?.container || !state.previewReady) return;
1193
+ const container = state.container;
1194
+ const previewRevision = state.previewRevision;
1195
+ state.accessibility = { status: 'running', violations: [] };
1196
+ notifyChanged();
1197
+ try {
1198
+ const axe = (await import('axe-core')).default;
1199
+ const result = await axe.run(container);
1200
+ const current = _states.get(id);
1201
+ if (
1202
+ !current?.previewReady ||
1203
+ current.previewRevision !== previewRevision ||
1204
+ current.container !== container
1205
+ )
1206
+ return;
1207
+ current.accessibility = {
1208
+ status: 'complete',
1209
+ violations: result.violations.map((violation) => ({
1210
+ id: violation.id,
1211
+ impact: violation.impact ?? null,
1212
+ help: violation.help,
1213
+ helpUrl: violation.helpUrl,
1214
+ nodes: violation.nodes.map((node) => ({
1215
+ html: node.html,
1216
+ target: node.target.map(String),
1217
+ })),
1218
+ })),
1219
+ };
1220
+ notifyChanged();
1221
+ } catch (reason) {
1222
+ const current = _states.get(id);
1223
+ if (
1224
+ !current?.previewReady ||
1225
+ current.previewRevision !== previewRevision ||
1226
+ current.container !== container
1227
+ )
1228
+ return;
1229
+ current.accessibility = {
1230
+ status: 'error',
1231
+ violations: [],
1232
+ error: reason instanceof Error ? reason.message : String(reason),
1233
+ };
1234
+ notifyChanged();
1235
+ }
1236
+ }
1237
+
1238
+ /** Story-scoped accessibility results, powered by the same axe engine Storybook uses. */
1239
+ export function StoryAccessibilityUtility() {
1240
+ useSyncExternalStore(subscribeStoryDocuments, storyDocumentsVersion);
1241
+ const active = activeStoryDocumentState();
1242
+ if (!active)
1243
+ return (
1244
+ <div style={{ padding: spaceVar[6], fontSize: fontSizeVar.md, color: themeVars.content.dim }}>
1245
+ No active story.
1246
+ </div>
1247
+ );
1248
+ const result = active.accessibility;
1249
+ return (
1250
+ <div
1251
+ data-testid="story-accessibility-utility"
1252
+ style={{
1253
+ flex: 1,
1254
+ minHeight: 0,
1255
+ overflowY: 'auto',
1256
+ padding: spaceVar[5],
1257
+ fontSize: fontSizeVar.base,
1258
+ }}
1259
+ >
1260
+ <Button
1261
+ variant="outline"
1262
+ size="compact"
1263
+ disabled={!active.container || !active.previewReady || result.status === 'running'}
1264
+ onClick={() => void runStoryAccessibility(active.id)}
1265
+ style={{ marginBottom: spaceVar[4] }}
1266
+ >
1267
+ {result.status === 'running' ? 'Checking…' : 'Run accessibility check'}
1268
+ </Button>
1269
+ {result.status === 'idle' && (
1270
+ <div style={{ color: themeVars.content.dim }}>
1271
+ Run axe against the mounted story preview.
1272
+ </div>
1273
+ )}
1274
+ {result.status === 'error' && (
1275
+ <div style={{ color: themeVars.semantic.danger }}>{result.error}</div>
1276
+ )}
1277
+ {result.status === 'complete' && result.violations.length === 0 && (
1278
+ <div style={{ color: themeVars.semantic.success, fontWeight: 600 }}>
1279
+ No accessibility violations found.
1280
+ </div>
1281
+ )}
1282
+ {result.violations.map((violation) => (
1283
+ <details
1284
+ key={violation.id}
1285
+ style={{
1286
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
1287
+ padding: `${spaceVar[3]} 0`,
1288
+ }}
1289
+ >
1290
+ <summary
1291
+ style={{
1292
+ color:
1293
+ violation.impact === 'critical' || violation.impact === 'serious'
1294
+ ? themeVars.semantic.danger
1295
+ : themeVars.content.primary,
1296
+ cursor: 'pointer',
1297
+ }}
1298
+ >
1299
+ {violation.help} · {violation.nodes.length} node
1300
+ {violation.nodes.length === 1 ? '' : 's'}
1301
+ </summary>
1302
+ <a
1303
+ href={violation.helpUrl}
1304
+ target="_blank"
1305
+ rel="noreferrer"
1306
+ style={{ color: themeVars.accent.default, display: 'inline-block', margin: '5px 0' }}
1307
+ >
1308
+ How to fix
1309
+ </a>
1310
+ {violation.nodes.map((node, index) => (
1311
+ <pre
1312
+ key={`${violation.id}:${index}`}
1313
+ style={{
1314
+ color: themeVars.content.muted,
1315
+ fontFamily: fontMono,
1316
+ fontSize: fontSizeVar.sm,
1317
+ whiteSpace: 'pre-wrap',
1318
+ overflowWrap: 'anywhere',
1319
+ }}
1320
+ >
1321
+ {node.target.join(' ')}
1322
+ {`\n`}
1323
+ {node.html}
1324
+ </pre>
1325
+ ))}
1326
+ </details>
1327
+ ))}
1328
+ </div>
1329
+ );
1330
+ }
1331
+
1332
+ const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'story-contributions');
1333
+
1334
+ /**
1335
+ * Idempotent one-time registration of the story-scoped shell contributions:
1336
+ * the Inspector `Story Args` section and the `Actions`/`Interactions`
1337
+ * utilities. Called from `openStoryDocument` (and importable directly) so
1338
+ * merely importing this module in a test doesn't mutate the registries.
1339
+ */
1340
+ export function ensureStoryContributionsRegistered(): void {
1341
+ registrationGroup.ensure((track) => {
1342
+ track(
1343
+ registerInspectorSections({
1344
+ match: () => activeWorkspaceDocument()?.descriptor.kind === 'story',
1345
+ id: STORY_ARGS_SECTION_ID,
1346
+ title: STORY_ARGS_SECTION_TITLE,
1347
+ icon: STORY_ARGS_SECTION_ICON,
1348
+ order: CONTRIBUTED_SECTION_ORDER,
1349
+ Section: StoryArgsSection,
1350
+ }),
1351
+ );
1352
+ const storyActive = () => activeStoryDocumentState() !== null;
1353
+ track(
1354
+ registerWorkspaceUtility({
1355
+ ...CONTRIBUTED_WORKSPACE_UTILITIES.storyActions,
1356
+ Content: StoryActionsUtility,
1357
+ available: storyActive,
1358
+ }),
1359
+ );
1360
+ track(
1361
+ registerWorkspaceUtility({
1362
+ ...CONTRIBUTED_WORKSPACE_UTILITIES.storyInteractions,
1363
+ Content: StoryInteractionsUtility,
1364
+ available: storyActive,
1365
+ }),
1366
+ );
1367
+ track(
1368
+ registerWorkspaceUtility({
1369
+ ...CONTRIBUTED_WORKSPACE_UTILITIES.storyAccessibility,
1370
+ Content: StoryAccessibilityUtility,
1371
+ available: storyActive,
1372
+ }),
1373
+ );
1374
+ });
1375
+ }
1376
+
1377
+ /** Test-only: allow re-registration after a registry reset. */
1378
+ export function __resetStoryContributionsForTest(): void {
1379
+ registrationGroup.reset();
1380
+ }