@volter/editor-game 0.5.65 → 0.5.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/canvas/component-board.service.ts +16 -0
  5. package/contributions/canvas/design-time-mount.service.ts +45 -0
  6. package/contributions/canvas-story-capture.service.ts +12 -0
  7. package/contributions/edit-mode-audio.service.ts +2 -2
  8. package/contributions/edit-mode-networking.service.ts +1 -1
  9. package/contributions/gameplay.command.ts +4 -4
  10. package/contributions/generation.service.ts +3 -3
  11. package/contributions/generations.status.tsx +1 -1
  12. package/contributions/godot.palette.json +99 -0
  13. package/contributions/godot.style.ts +82 -0
  14. package/contributions/godot.view.ts +75 -0
  15. package/contributions/ingest.service.ts +4 -4
  16. package/contributions/instances.command.ts +1 -1
  17. package/contributions/navmesh.menu.ts +1 -1
  18. package/contributions/network-observer.service.ts +14 -0
  19. package/contributions/play.command.ts +11 -5
  20. package/contributions/react/component-board.service.ts +1 -1
  21. package/contributions/react/design-time-mount.service.ts +1 -1
  22. package/contributions/react/react-inspector.service.ts +2 -2
  23. package/contributions/scene-document.service.ts +5 -5
  24. package/contributions/state-watch.menu.ts +1 -1
  25. package/contributions/state-watch.utility.tsx +1 -1
  26. package/contributions/team-playtest.service.ts +4 -4
  27. package/contributions/three/component-board.service.ts +1 -1
  28. package/contributions/three/component-verbs.command.ts +8 -8
  29. package/contributions/three/three-authoring.service.ts +8 -5
  30. package/contributions/three-story-capture.service.ts +12 -0
  31. package/contributions/unity.palette.json +102 -0
  32. package/contributions/unity.style.ts +72 -0
  33. package/contributions/unity.view.ts +82 -0
  34. package/contributions/unreal.palette.json +105 -0
  35. package/contributions/unreal.style.ts +66 -0
  36. package/contributions/unreal.view.ts +93 -0
  37. package/dist-node/serving.mjs +16 -0
  38. package/package.json +33 -16
  39. package/serving/index.ts +27 -0
  40. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  41. package/src/asset-budget/asset-budget-model.ts +4 -4
  42. package/src/asset-budget/optimize-apply.ts +7 -7
  43. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  44. package/src/bridge/dispatch.ts +16 -16
  45. package/src/bridge/live-frames.ts +1 -1
  46. package/src/bridge/screenshot.ts +6 -6
  47. package/src/build/BuildProfilesPanel.tsx +3 -3
  48. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +676 -0
  49. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  50. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  51. package/src/canvas/canvas-design-mount.ts +524 -0
  52. package/src/canvas/design-time-canvas-mount.ts +79 -0
  53. package/src/coverage/live-authoring-surface.ts +6 -6
  54. package/src/coverage/live-project-verbs.ts +2 -2
  55. package/src/coverage/native-system-coverage.ts +6 -6
  56. package/src/coverage/root-coverage.ts +1 -1
  57. package/src/coverage/session-coverage.ts +5 -5
  58. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  59. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +13 -53
  60. package/src/edit-mode/edit-mode-audio.ts +4 -4
  61. package/src/edit-mode/edit-mode-networking.ts +2 -2
  62. package/src/game-document/GameCaptureFrameButton.tsx +2 -2
  63. package/src/game-document/GameDocument.tsx +2 -2
  64. package/src/game-document/GamePanel.tsx +8 -8
  65. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  66. package/src/game-document/crowd-debug.ts +1 -1
  67. package/src/game-document/device-preview.ts +10 -11
  68. package/src/game-document/physics-debug.ts +1 -1
  69. package/src/generation/GenerationActivity.tsx +6 -8
  70. package/src/generation/generation-documents.tsx +8 -8
  71. package/src/generation/generation-jobs.ts +2 -2
  72. package/src/generation/generation-presentation.ts +1 -1
  73. package/src/host/adapter-reach.ts +1 -1
  74. package/src/host/adapter-runtime-bindings.ts +97 -6
  75. package/src/host/api/configurations.ts +2 -2
  76. package/src/host/authoring/babylon-authoring-adapter.ts +8 -8
  77. package/src/host/authoring/contract-hierarchy-authoring.ts +1 -1
  78. package/src/host/authoring/contract-scenes-stories.ts +1 -1
  79. package/src/host/authoring/creation-site-related.ts +2 -2
  80. package/src/host/authoring/ephemeral-persistence.ts +1 -1
  81. package/src/host/authoring/gesture-persist.ts +2 -2
  82. package/src/host/authoring/ingest-data-writer.ts +1 -1
  83. package/src/host/authoring/ingest-source-persistence.ts +8 -8
  84. package/src/host/authoring/mount-isolated-pixi-screen.ts +1 -1
  85. package/src/host/authoring/mounted-authoring.ts +4 -4
  86. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  87. package/src/host/authoring/pixi-authoring-adapter.ts +42 -17
  88. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  89. package/src/host/authoring/pixi-live-write-target.ts +7 -7
  90. package/src/host/authoring/pixi-source-identity.ts +3 -3
  91. package/src/host/authoring/pixi-source-write-target.ts +1614 -0
  92. package/src/host/authoring/pixi-still-presentation.ts +2 -2
  93. package/src/host/authoring/pixi-structure-history.ts +3 -3
  94. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  95. package/src/host/authoring/source-persistence-backend.ts +6 -6
  96. package/src/host/authoring/struct-write-pipe.ts +3 -3
  97. package/src/host/binding-resolver.ts +8 -9
  98. package/src/host/browser-transpile.ts +2 -2
  99. package/src/host/canvas-entry-runtime.ts +59 -48
  100. package/src/host/canvas-preview-frames.ts +482 -0
  101. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  102. package/src/host/components/HeaderTelemetry.tsx +9 -16
  103. package/src/host/components/PixiIsolationSceneContent.tsx +17 -16
  104. package/src/host/components/ThreeIsolationSceneContent.tsx +5 -5
  105. package/src/host/components/frame-debugger-model.ts +2 -2
  106. package/src/host/components/header-telemetry-model.ts +2 -2
  107. package/src/host/components/scene-document.tsx +29 -61
  108. package/src/host/components/utility-view-state.ts +1 -1
  109. package/src/host/components/world-root-stage-binding.tsx +17 -26
  110. package/src/host/components/world-root-stage.ts +130 -63
  111. package/src/host/coverage/capability-coverage.ts +13 -1
  112. package/src/host/coverage/game-contract-seam-evidence.ts +1 -1
  113. package/src/host/coverage/project-verb-coverage.ts +2 -17
  114. package/src/host/coverage/system-adapter-coverage.ts +4 -5
  115. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +14 -8
  116. package/src/host/document-preview-three.ts +2 -2
  117. package/src/host/entry-adjudication.ts +6 -6
  118. package/src/host/game-css-scope-transform.ts +4 -0
  119. package/src/host/game-module-access.ts +1 -1
  120. package/src/host/game-realm-page.ts +16 -8
  121. package/src/host/game-realm-reclaim.ts +1 -1
  122. package/src/host/gameplay-export.ts +25 -14
  123. package/src/host/gameplay-recording.ts +5 -5
  124. package/src/host/gated-globals.ts +6 -6
  125. package/src/host/history/json-history-resource.ts +3 -3
  126. package/src/host/instance-extract-actions.ts +1 -1
  127. package/src/host/instance-fork-actions.ts +1 -1
  128. package/src/host/projection/dom.ts +2 -2
  129. package/src/host/projection/pixi.ts +25 -3
  130. package/src/host/r3f-entry-runtime.ts +65 -34
  131. package/src/host/react-mount-runtime.ts +8 -49
  132. package/src/host/realm-services.ts +2 -2
  133. package/src/host/roots/canvas-root.tsx +361 -0
  134. package/src/host/roots/module-root.ts +1 -1
  135. package/src/host/roots/r3f-root.tsx +473 -0
  136. package/src/host/roots/react-root.ts +9 -43
  137. package/src/host/scene-view-drawability.ts +2 -2
  138. package/src/host/served-bundle-runtime-modules.ts +1 -19
  139. package/src/host/server-log-bridge.ts +3 -3
  140. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  141. package/src/host/stories/pixi-story-model.ts +30 -0
  142. package/src/host/stories/story-media-captures.ts +46 -0
  143. package/src/host/stories/story-media-presence.ts +3 -3
  144. package/src/host/stories/story-pixi-preview.ts +408 -0
  145. package/src/host/stories/story-three-preview.ts +806 -0
  146. package/src/host/stories/three-story-captures.ts +35 -0
  147. package/src/host/story-three-preview-runtime.ts +56 -0
  148. package/src/host/three-ingest-runtime.ts +1 -1
  149. package/src/host/use-active-performance-source.ts +3 -3
  150. package/src/host/viewport-pose-memory.ts +1 -1
  151. package/src/host/viewport-root-presentation.ts +6 -5
  152. package/src/ingest/active-ingest.ts +2 -2
  153. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  154. package/src/ingest/authoring/ingest-root-adapter.ts +13 -13
  155. package/src/ingest/capture-wait-report.ts +1 -1
  156. package/src/ingest/deferred-ingest-play.ts +11 -10
  157. package/src/ingest/discovery-public-ingest.ts +2 -2
  158. package/src/ingest/ingest-boot-viewport.ts +6 -8
  159. package/src/ingest/ingest-canvas-scene-document.tsx +17 -16
  160. package/src/ingest/ingest-canvas-scene.ts +5 -8
  161. package/src/ingest/ingest-evidence-hook.ts +1 -1
  162. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  163. package/src/ingest/ingest-play-control.ts +4 -4
  164. package/src/ingest/ingest-render-debug.ts +11 -11
  165. package/src/ingest/ingest-siblings.ts +16 -28
  166. package/src/ingest/module-mode.ts +18 -19
  167. package/src/ingest/mount-canvas-ingest-root.ts +27 -27
  168. package/src/ingest/mount-coverage.ts +4 -4
  169. package/src/ingest/mount-dom-ingest-root.ts +16 -16
  170. package/src/ingest/mount-ingest-root.ts +14 -14
  171. package/src/ingest/mount-three-ingest-root.ts +12 -12
  172. package/src/ingest/resolve-canvas.ts +1 -1
  173. package/src/ingest/served-html-boot.ts +1 -1
  174. package/src/ingest/surface-canvas.ts +1 -1
  175. package/src/ingest/unmount-ingest-root.ts +7 -7
  176. package/src/navmesh/navmesh-handler.ts +24 -16
  177. package/src/network/NetworkInspectorPanel.tsx +449 -14
  178. package/src/network/network-inspector-model.ts +14 -2
  179. package/src/play/play-log-events.ts +1 -1
  180. package/src/play/play-mode.ts +99 -142
  181. package/src/play/play-recording.ts +2 -2
  182. package/src/play/react-play-live-authoring.ts +3 -3
  183. package/src/play/run-selection.ts +93 -0
  184. package/src/play-bar/PlayBar.tsx +20 -39
  185. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  186. package/src/profiler/PerformancePanel.tsx +4 -4
  187. package/src/react/design-time-react-mount.ts +37 -85
  188. package/src/react/dom-authoring-adapter.ts +11 -11
  189. package/src/react/react-inspector-section.tsx +13 -13
  190. package/src/react/react-world-authoring-adapter.ts +31 -143
  191. package/src/react/story-documents/story-documents.tsx +18 -22
  192. package/src/react/ui-board-document.tsx +10 -11
  193. package/src/react/ui-component-board.ts +5 -5
  194. package/src/runtime/adapter/audio-meter.ts +21 -0
  195. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  196. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  197. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  198. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  199. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  200. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  201. package/src/runtime/adapter/native-debug-module.ts +498 -0
  202. package/src/runtime/audio/bus-mixer.ts +161 -0
  203. package/src/runtime/audio/pose-guard.ts +80 -0
  204. package/src/runtime/core/frame-pacing.ts +126 -0
  205. package/src/runtime/core/game-loop.ts +225 -0
  206. package/src/runtime/core/game-scoped-slot.ts +28 -0
  207. package/src/runtime/core/seeded-random.ts +162 -0
  208. package/src/runtime/core/sim-clock.ts +391 -0
  209. package/src/runtime/core/system-runner.ts +269 -0
  210. package/src/runtime/core/types.ts +104 -0
  211. package/src/runtime/create-runtime.ts +1128 -0
  212. package/src/runtime/debug-bridge.ts +570 -0
  213. package/src/runtime/debug-registry.ts +899 -0
  214. package/src/runtime/dev/chrome-trace.ts +153 -0
  215. package/src/runtime/dev/instruments.ts +403 -0
  216. package/src/runtime/dev/logger.ts +119 -0
  217. package/src/runtime/dev/performance-profiler.ts +367 -0
  218. package/src/runtime/dev/register-render-vitals.ts +276 -0
  219. package/src/runtime/dev/render-census.ts +354 -0
  220. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  221. package/src/runtime/dev/render-memory.ts +226 -0
  222. package/src/runtime/dev/render-vitals.ts +338 -0
  223. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  224. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  225. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  226. package/src/runtime/dev-build.ts +47 -0
  227. package/src/runtime/game.ts +1636 -0
  228. package/src/runtime/gameplay-rng-trap.ts +135 -0
  229. package/src/runtime/host-context.ts +64 -0
  230. package/src/runtime/input-router.ts +169 -0
  231. package/src/runtime/mount-manifest.ts +480 -0
  232. package/src/runtime/pixi/authoring.ts +706 -0
  233. package/src/runtime/pixi/ingest.ts +116 -0
  234. package/src/runtime/pixi/physics-registry.ts +49 -0
  235. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  236. package/src/runtime/pixi/scene-capture.ts +179 -0
  237. package/src/runtime/pixi/system-adapters.ts +69 -0
  238. package/src/runtime/playtest.ts +22 -0
  239. package/src/runtime/presentation.ts +141 -0
  240. package/src/runtime/render-control.ts +642 -0
  241. package/src/runtime/render-seed.ts +77 -0
  242. package/src/runtime/run-ticks-settled.ts +73 -0
  243. package/src/runtime/setup/setup-audio.ts +72 -0
  244. package/src/services/audio-pose-guard.ts +2 -2
  245. package/src/services/game-audio.ts +152 -0
  246. package/src/services/game-network.ts +657 -0
  247. package/src/services/game-physics.ts +334 -0
  248. package/src/state-watch/StateWatchPanel.tsx +1 -1
  249. package/src/three/authoring/camera-runtime-inspector-section.tsx +4 -3
  250. package/src/three/authoring/constraint-inspector-section.tsx +8 -7
  251. package/src/three/authoring/model-asset-inspector-section.tsx +14 -17
  252. package/src/three/authoring/oid-source-persistence.ts +13 -13
  253. package/src/three/authoring/r3f-design-session.ts +79 -61
  254. package/src/three/authoring/r3f-source-authoring-adapter.ts +112 -108
  255. package/src/three/authoring/reflection-probe-inspector-section.tsx +5 -4
  256. package/src/three/authoring/spatial-joint-handles.ts +1 -1
  257. package/src/three/authoring/spatial-lod-handles.ts +1 -1
  258. package/src/three/authoring/spatial-particle-handles.ts +1 -1
  259. package/src/three/authoring/three-authoring-adapter.ts +33 -33
  260. package/src/three/component-verbs/extract-menu.ts +8 -7
  261. package/src/three/component-verbs/fork-menu.ts +8 -7
  262. package/src/three/component-verbs/internals-menu.ts +3 -3
  263. package/src/three/drei-asset-caches.ts +13 -0
  264. package/src/three/story-documents/three-story-documents.tsx +24 -34
  265. package/src/three/three-board/ThreeBoardDocument.tsx +25 -25
  266. package/src/three/three-board/board-scene.ts +7 -7
  267. package/src/three/three-board/three-component-board.ts +5 -5
  268. package/contributions/react/pasteboard.action.ts +0 -41
  269. package/contributions/xstate-behavior.action.ts +0 -42
  270. package/contributions/xstate-behavior.document.tsx +0 -73
  271. package/contributions/xstate-behavior.inspector.tsx +0 -28
  272. package/contributions/xstate-behavior.menu.ts +0 -23
  273. package/src/react/pasteboard-materialize.ts +0 -154
  274. package/src/services/game-audio-unlock.ts +0 -48
  275. package/src/xstate/XStateBehaviorSection.tsx +0 -130
  276. package/src/xstate/XStateMachineInspector.tsx +0 -566
  277. package/src/xstate/character-animation-machine.fixture.ts +0 -74
  278. package/src/xstate/live-behaviors.ts +0 -106
  279. package/src/xstate/use-live-actor-state.ts +0 -44
  280. package/src/xstate/xstate-graph.ts +0 -235
  281. package/src/xstate/xstate-layout.ts +0 -76
@@ -0,0 +1,1614 @@
1
+ /**
2
+ * The TSX-SOURCE half of the canvas surface's persistence axis
3
+ * ({@link CanvasWriteTarget}) — a first-party `@pixi/react` world, whose
4
+ * document IS its source file.
5
+ *
6
+ * Model, and it is the three lane's ({@link
7
+ * ../../three/authoring/r3f-source-authoring-adapter}): the live display tree is truth
8
+ * for STRUCTURE and TRANSFORMS (projected directly, no fabricated document);
9
+ * the project's `.tsx` is truth for PERSISTENCE. A row whose container carries
10
+ * an oid is SOURCE-ADDRESSABLE — its JSX props read from the real source text
11
+ * and write back through the SAME `/__ui-source/*` seam the dom and three
12
+ * lanes use, wrapped in `withProjectSourceHistory` so every write is a
13
+ * checksum-guarded undo/redo entry for free.
14
+ *
15
+ * The literal-vs-dynamic guard is sacred: an expression-bound prop
16
+ * (`x={grunt.x}` — the survivor example's actors are positioned by their sim
17
+ * every tick) is surfaced READ-ONLY and never written, and a gizmo edit on
18
+ * such a channel is refused AND the live object reverts to its pre-drag
19
+ * snapshot. Never a silent two-truths divergence.
20
+ *
21
+ * Local component callsites are projected as native prefab instances. The
22
+ * source transform stamps their callsite identity onto the first Pixi host
23
+ * element, while that host keeps its own definition-side oid. The pair is the
24
+ * same source-derived contract the Three lane uses: callsite props are the
25
+ * instance overrides, declared props are the defaults, and no override sidecar
26
+ * exists.
27
+ */
28
+
29
+ import type {
30
+ CanvasWriteContext,
31
+ CanvasWriteTarget,
32
+ } from './pixi-authoring-adapter';
33
+ import {
34
+ authoringOidForContainer,
35
+ isCanvasComponentInstanceRoot,
36
+ oidOfPixiId,
37
+ readContainerOid,
38
+ } from './pixi-source-identity';
39
+ import {
40
+ formatSourceNumber,
41
+ planChannelWrite,
42
+ transform2DChanged,
43
+ } from './pixi-transform-channels';
44
+ import { createStructWritePipe, type StructOpOptions } from './struct-write-pipe';
45
+ import {
46
+ clipboardOutcome,
47
+ LIVE_ONLY_ACK,
48
+ resolvesLiveOnly,
49
+ runWritePipe,
50
+ type WriteAck,
51
+ type WriteResolution,
52
+ } from '@volter/editor-sdk/kit/write-pipe';
53
+ import type { CanvasPixiNamespace } from '../canvas-entry-runtime';
54
+ import { editorConsole } from '@volter/editor-sdk/kit/editor-console';
55
+ import { withProjectSourceHistory } from '@volter/editor-sdk/kit/history/source-history-backend';
56
+ import { showTransientHint } from '@volter/editor-sdk/kit/transient-hint';
57
+ import {
58
+ type ComponentPropSpec,
59
+ lineColToOffset,
60
+ type OidEntry,
61
+ } from '@volter/editor-react/source/oid-transform';
62
+ import { relativeImportSpecifier } from '@volter/editor-react/source/relative-import-specifier';
63
+ import type { SourceWriteBackend } from '@volter/editor-sdk/kit/ui-source/source-write-backend';
64
+ import {
65
+ analyzeJsxAttributes,
66
+ findElementEnd,
67
+ findTagEnd,
68
+ type JsxAttrInfo,
69
+ } from '@volter/editor-react/source/writer';
70
+ import { CANVAS_COMPONENTS_DOCUMENT_ID } from '@volter/editor-sdk/kit/workspace-document-ids';
71
+ import { activateWorkspaceDocument } from '@volter/editor-sdk/kit/workspace-document-registry';
72
+ import type {
73
+ AssetDropProvider,
74
+ ComponentInstanceApplyResult,
75
+ ComponentInstanceDescription,
76
+ ComponentInstancesProvider,
77
+ NodeCreationSite,
78
+ PersistenceProvider,
79
+ PropertyDescriptor,
80
+ StructureProvider,
81
+ TransformChannel,
82
+ TransformEditability,
83
+ } from '@volter/editor-project/adapter';
84
+ import type { AuthoringAdapter2D, Transform2DValue } from '../../runtime/pixi/authoring';
85
+ import { getComponentPreviewStories } from '@volter/editor-sdk/kit/stories/story-registry';
86
+ import type { Container, Matrix } from 'pixi.js';
87
+ import * as shellPixi from 'pixi.js';
88
+
89
+ /** Why a write here resolves live-only — the ONE spelling for this lane. */
90
+ const NO_SOURCE_WRITER_REASON = 'this session has no source-write backend';
91
+
92
+ /** Props the JSX section never lists: the editor's own stamp, the wrapper's
93
+ * structural props, and the ones the panels above already own. */
94
+ const HIDDEN_PROPS = new Set(['key', 'ref', 'data-oid', 'children', 'style', 'className']);
95
+ /** Owned by the Transform section and the header's Name field. */
96
+ const PANEL_OWNED_PROPS = new Set([
97
+ 'x',
98
+ 'y',
99
+ 'rotation',
100
+ 'scale',
101
+ 'scale-x',
102
+ 'scale-y',
103
+ 'label',
104
+ 'visible',
105
+ ]);
106
+ /** Props the drop snippet writes itself. */
107
+ const DROP_SNIPPET_PROPS = new Set(['x', 'y', 'name', 'children']);
108
+
109
+ const NUMBER_RE = /^-?\d+(\.\d+)?$/;
110
+ const CREATE_SNIPPETS: Readonly<Record<string, string>> = {
111
+ container: '<pixiContainer label="Container" />',
112
+ sprite: '<pixiSprite label="Sprite" />',
113
+ text: '<pixiText label="Text" text="Text" />',
114
+ graphics: '<pixiGraphics label="Graphics" />',
115
+ };
116
+ const CREATE_LABELS: Readonly<Record<string, string>> = {
117
+ container: 'Container',
118
+ sprite: 'Sprite',
119
+ text: 'Text',
120
+ graphics: 'Graphics',
121
+ };
122
+
123
+ function isHiddenProp(name: string): boolean {
124
+ return HIDDEN_PROPS.has(name) || /^on[A-Z]/.test(name) || name.startsWith('__vgai');
125
+ }
126
+
127
+ /** Present a source identifier the way native engine inspectors present
128
+ * property names, while keeping the JSX name as source truth. */
129
+ function humanizeIdentifier(value: string): string {
130
+ const spaced = value
131
+ .replace(/[_-]+/g, ' ')
132
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
133
+ .trim();
134
+ return spaced ? spaced[0]!.toUpperCase() + spaced.slice(1) : value;
135
+ }
136
+
137
+ export interface SourceCanvasWriteTargetOptions {
138
+ /** Manifest root id — the world half of every id this lane mints. */
139
+ readonly worldId: string;
140
+ /** Project-relative entry path, for the console messages a refusal writes. */
141
+ readonly entryPath: string;
142
+ /** The RAW backend; wrapped in `withProjectSourceHistory` here (the same
143
+ * recipe `R3fSourceAuthoringAdapter` uses). Absent ⇒ every write refuses in
144
+ * a sentence rather than pretending. */
145
+ readonly writeBackend?: SourceWriteBackend | undefined;
146
+ /**
147
+ * THE namespace of the world this target writes for — the mount site's, the
148
+ * same one its `PixiAuthoringAdapter` holds
149
+ * (`../../vite-plugin-module-doorways.ts`).
150
+ *
151
+ * Nothing built here today ESCAPES into the world: `Matrix`/`Transform` are
152
+ * used to decompose a reparent into numbers, which is pure value math that
153
+ * measured correct across instances. It is a parameter anyway, because the
154
+ * lane's rule is that a canvas surface holds ONE namespace with no exceptions
155
+ * a reader has to hold in their head — and because the next value constructed
156
+ * in this file may well be a display object, where the same static import
157
+ * would be a silent bug rather than a harmless one.
158
+ */
159
+ readonly pixi?: CanvasPixiNamespace | undefined;
160
+ }
161
+
162
+ interface CanvasStructureClipboard {
163
+ readonly sourceFile: string;
164
+ readonly text: string;
165
+ readonly count: number;
166
+ }
167
+
168
+ interface CanvasClipboardElement {
169
+ readonly file: string;
170
+ readonly offset: number;
171
+ readonly text: string;
172
+ }
173
+
174
+ function dedentJsxElement(source: string, offset: number, end: number): string {
175
+ const indent = source.slice(source.lastIndexOf('\n', offset - 1) + 1, offset);
176
+ const raw = source.slice(offset, end);
177
+ if (!/^\s*$/.test(indent)) return raw;
178
+ return raw
179
+ .split('\n')
180
+ .map((line, index) => (index > 0 && line.startsWith(indent) ? line.slice(indent.length) : line))
181
+ .join('\n');
182
+ }
183
+
184
+ function highestSelectedIds(
185
+ ids: readonly string[],
186
+ parentOf: (id: string) => string | null,
187
+ ): string[] {
188
+ const selected = new Set(ids);
189
+ return [...selected].filter((id) => {
190
+ let parentId = parentOf(id);
191
+ while (parentId) {
192
+ if (selected.has(parentId)) return false;
193
+ parentId = parentOf(parentId);
194
+ }
195
+ return true;
196
+ });
197
+ }
198
+
199
+ export function createSourceCanvasWriteTarget(
200
+ options: SourceCanvasWriteTargetOptions,
201
+ ): CanvasWriteTarget {
202
+ const { worldId, entryPath } = options;
203
+ const pixi = options.pixi ?? shellPixi;
204
+ let a2d: AuthoringAdapter2D;
205
+ let notify: () => void = () => {};
206
+ let backend: SourceWriteBackend | undefined;
207
+ let boundStore: CanvasWriteContext['store'] | undefined;
208
+ let structureClipboard: CanvasStructureClipboard | null = null;
209
+
210
+ /** OID → {file,line,col} — fetched from `/__ui-source/index`. */
211
+ let oidIndex = new Map<string, OidEntry>();
212
+ /** file → current source text (for client-side attr analysis). */
213
+ let sources = new Map<string, string>();
214
+ /** Optimistic write echo (id|path → value) until the source re-read lands. */
215
+ const valueEcho = new Map<string, unknown>();
216
+ /** id|channel refused as dynamic this session — rendered read-only. */
217
+ const dynamicPaths = new Set<string>();
218
+ /** Pre-gesture 2D poses, one per id currently under a drag. */
219
+ const editStarts = new Map<string, Transform2DValue>();
220
+ let commitQueue = Promise.resolve();
221
+
222
+ const oidOf = (id: string): string | null => {
223
+ // The LIVE object is the authority (an id can outlive a remount), with the
224
+ // id's own encoding as the fallback for a row the walk no longer holds.
225
+ const object = a2d.displayObject(id);
226
+ return (object ? authoringOidForContainer(object) : undefined) ?? oidOfPixiId(worldId, id);
227
+ };
228
+
229
+ const liveOids = (): Set<string> => {
230
+ const oids = new Set<string>();
231
+ const visit = (id: string): void => {
232
+ // BOTH addresses. `attrsOf`/`oidOf` resolve a row by its CALLSITE oid,
233
+ // which for a component-instance root lives in the PARENT's file — a
234
+ // file that may contribute no host element of its own (`src/world.tsx`,
235
+ // which only renders `<SurvivorScene />`). A set built from the
236
+ // element's own oid alone never named it, so its source was never
237
+ // fetched and every row addressed there — including anything a drop
238
+ // wrote into it — read "Source metadata is still loading or
239
+ // unavailable" for good (runhuman passes 130/132; traced on a local
240
+ // hosted tier, 2026-09-03). `onReindex`'s staleness probe shares this.
241
+ const object = a2d.displayObject(id);
242
+ const own = readContainerOid(object);
243
+ if (own !== undefined) oids.add(own);
244
+ const callsite = object ? authoringOidForContainer(object) : undefined;
245
+ if (callsite !== undefined) oids.add(callsite);
246
+ for (const childId of a2d.node(id)?.childIds ?? []) visit(childId);
247
+ };
248
+ for (const root of a2d.roots()) visit(root.id);
249
+ return oids;
250
+ };
251
+
252
+ /** Refetch the OID index + the source text of every file it references. */
253
+ const refreshSourceState = async (): Promise<void> => {
254
+ if (!backend?.index || !backend.readSource) return;
255
+ try {
256
+ const index = await backend.index();
257
+ oidIndex = new Map(Object.entries(index));
258
+ const files = new Set<string>();
259
+ for (const oid of liveOids()) {
260
+ const entry = oidIndex.get(oid);
261
+ if (entry) files.add(entry.file);
262
+ }
263
+ const next = new Map<string, string>();
264
+ await Promise.all(
265
+ [...files].map(async (file) => {
266
+ const res = await backend!.readSource!(file);
267
+ next.set(file, res.source);
268
+ }),
269
+ );
270
+ sources = next;
271
+ notify();
272
+ } catch {
273
+ // Honest degradation: rows stay live-projected (read-only) when the
274
+ // index/source cannot be fetched.
275
+ }
276
+ };
277
+
278
+ /** The node's JSX attributes, analyzed from the CURRENT source text (null
279
+ * when the node isn't source-addressable or the source isn't cached yet). */
280
+ const attrsOf = (id: string): JsxAttrInfo[] | null => {
281
+ const oid = oidOf(id);
282
+ if (!oid) return null;
283
+ const entry = oidIndex.get(oid);
284
+ if (!entry) return null;
285
+ const source = sources.get(entry.file);
286
+ if (source === undefined) return null;
287
+ const start = lineColToOffset(source, entry.line, entry.col);
288
+ if (source[start] !== '<') return null; // stale index vs source — refuse honestly
289
+ const tagEnd = findTagEnd(source, start);
290
+ if (tagEnd < 0) return null;
291
+ return analyzeJsxAttributes(source, start, tagEnd);
292
+ };
293
+
294
+ const projectRelativeSourceFile = (file: string): string => {
295
+ const normalized = file.replaceAll('\\', '/');
296
+ const normalizedEntry = entryPath.replaceAll('\\', '/').replace(/^\.?\//, '');
297
+ if (!normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized)) return normalized;
298
+ for (const entry of oidIndex.values()) {
299
+ const indexed = entry.file.replaceAll('\\', '/');
300
+ if (indexed !== normalizedEntry && !indexed.endsWith(`/${normalizedEntry}`)) continue;
301
+ const projectPrefix = indexed.slice(0, -normalizedEntry.length);
302
+ if (normalized.startsWith(projectPrefix)) return normalized.slice(projectPrefix.length);
303
+ }
304
+ return normalizedEntry;
305
+ };
306
+
307
+ const declaredPropsOf = (id: string): readonly ComponentPropSpec[] => {
308
+ const oid = oidOf(id);
309
+ return (oid ? oidIndex.get(oid)?.props : undefined) ?? [];
310
+ };
311
+
312
+ const definitionOidOf = (id: string): string | null => {
313
+ const object = a2d.displayObject(id);
314
+ if (!object || !isCanvasComponentInstanceRoot(object)) return null;
315
+ const own = readContainerOid(object);
316
+ const callsite = authoringOidForContainer(object);
317
+ return own && own !== callsite ? own : null;
318
+ };
319
+
320
+ const componentIdentityOf = (id: string): { name: string; sourcePath?: string } | null => {
321
+ const object = a2d.displayObject(id);
322
+ if (!object || !isCanvasComponentInstanceRoot(object)) return null;
323
+ const definitionOid = definitionOidOf(id);
324
+ const definition = definitionOid ? oidIndex.get(definitionOid) : undefined;
325
+ const callsiteOid = oidOf(id);
326
+ const callsite = callsiteOid ? oidIndex.get(callsiteOid) : undefined;
327
+ const name = definition?.component ?? callsite?.tag;
328
+ if (!name) return null;
329
+ return {
330
+ name,
331
+ ...(definition?.file ? { sourcePath: projectRelativeSourceFile(definition.file) } : {}),
332
+ };
333
+ };
334
+
335
+ const creationSiteOf = (id: string): NodeCreationSite => {
336
+ const oid = oidOf(id);
337
+ if (!oid) {
338
+ return {
339
+ anchored: false,
340
+ reason: 'This rendered Canvas object has no authored source identity.',
341
+ };
342
+ }
343
+ const entry = oidIndex.get(oid);
344
+ if (!entry) {
345
+ return { anchored: false, reason: 'Source metadata is still loading or unavailable.' };
346
+ }
347
+ const file = projectRelativeSourceFile(entry.file);
348
+ return {
349
+ anchored: true,
350
+ kind: 'source',
351
+ file,
352
+ line: entry.line,
353
+ col: entry.col,
354
+ display: `${file}:${entry.line}`,
355
+ };
356
+ };
357
+
358
+ const persistence: PersistenceProvider | undefined = options.writeBackend
359
+ ? {
360
+ isDirty: () => false,
361
+ save: async () => undefined,
362
+ destination: entryPath,
363
+ lastError: () => {
364
+ const error = boundStore?.shell.projectHistory?.getSnapshot().lastError;
365
+ if (!error) return null;
366
+ return error.code === 'apply-failed' ||
367
+ error.code === 'compensation-failed' ||
368
+ error.code === 'history-limit'
369
+ ? error.message
370
+ : null;
371
+ },
372
+ }
373
+ : undefined;
374
+
375
+ const read2D = (id: string): Transform2DValue =>
376
+ a2d.getTransform(id) ?? { position: [0, 0], rotation: 0, scale: [1, 1] };
377
+
378
+ /**
379
+ * A channel a COMPONENT INSTANCE's own code owns: the write would need a
380
+ * prop the component neither authors at the callsite nor declares, so the
381
+ * byte lands in the file and never reaches the running object. The same
382
+ * question the drop asks at creation (`declaredSpecsOf`) and the
383
+ * gesture asks at commit — asked by `transformEditability` too, so the
384
+ * field is disabled with the real reason instead of accepting a drag it is
385
+ * going to revert.
386
+ */
387
+ const componentOwnedChannel = (
388
+ id: string,
389
+ channel: TransformChannel,
390
+ attrs: readonly JsxAttrInfo[],
391
+ value: Transform2DValue,
392
+ ): boolean => {
393
+ if (!componentIdentityOf(id)) return false;
394
+ const plan = planChannelWrite(channel, value, attrs);
395
+ if (!plan.writable) return false; // its own refusal names the reason
396
+ const declared = declaredPropsOf(id);
397
+ return plan.writes.some(
398
+ (write) =>
399
+ !attrs.some((attr) => attr.name === write.prop) &&
400
+ !declared.some((spec) => spec.name === write.prop),
401
+ );
402
+ };
403
+
404
+ const literalValueOf = (attr: JsxAttrInfo): unknown => {
405
+ if (!attr.isLiteral) return `{${attr.rawValue}}`;
406
+ const raw = attr.rawValue;
407
+ if (!attr.isExpression) return raw;
408
+ if (raw === 'true') return true;
409
+ if (raw === 'false') return false;
410
+ if (NUMBER_RE.test(raw)) return Number(raw);
411
+ return raw.replace(/^['"]|['"]$/g, '');
412
+ };
413
+
414
+ const descriptorType = (attr: JsxAttrInfo): PropertyDescriptor['type'] => {
415
+ if (!attr.isExpression) return 'string';
416
+ if (attr.rawValue === 'true' || attr.rawValue === 'false') return 'boolean';
417
+ if (NUMBER_RE.test(attr.rawValue)) return 'number';
418
+ return 'string';
419
+ };
420
+
421
+ const serializeValue = (value: unknown): string => {
422
+ if (typeof value === 'number') return formatSourceNumber(value);
423
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
424
+ return String(value);
425
+ };
426
+
427
+ const propValue = (
428
+ attrs: readonly JsxAttrInfo[] | null,
429
+ declared: readonly ComponentPropSpec[],
430
+ prop: string,
431
+ ): unknown => {
432
+ const attr = attrs?.find((candidate) => candidate.name === prop);
433
+ if (attr) return literalValueOf(attr);
434
+ const spec = declared.find((candidate) => candidate.name === prop);
435
+ return spec ? (spec.defaultValue ?? spec.defaultText) : undefined;
436
+ };
437
+
438
+ const isOverride = (
439
+ id: string,
440
+ attr: JsxAttrInfo | undefined,
441
+ spec: ComponentPropSpec | undefined,
442
+ ): boolean =>
443
+ Boolean(
444
+ attr?.isLiteral &&
445
+ spec?.optional &&
446
+ backend?.removeProp &&
447
+ !dynamicPaths.has(`${id}|jsx.${attr.name}`),
448
+ );
449
+
450
+ /**
451
+ * Gesture end → one source write per CHANGED channel.
452
+ *
453
+ * A refused channel (an expression-bound prop, a shape this lane cannot
454
+ * express, no backend) reverts the live object to its pre-gesture snapshot:
455
+ * source stays truth, and the viewport never shows a value the file does not
456
+ * have. A multi-channel gesture goes through `runGesture` so one Ctrl+Z
457
+ * restores the whole thing.
458
+ */
459
+ /** One closed gesture. Answers whether ANY byte reached the entry TSX — a
460
+ * gesture every channel of which was refused is `false`, and the live
461
+ * object has been reverted to match. */
462
+ const commitTransform = async (id: string, before: Transform2DValue): Promise<boolean> => {
463
+ const after = read2D(id);
464
+ const channels = (['position', 'rotation', 'scale'] as const).filter((channel) =>
465
+ transform2DChanged(channel, before, after),
466
+ );
467
+ if (channels.length === 0) return false;
468
+
469
+ const revert = (channel: TransformChannel): void => {
470
+ if (channel === 'position') a2d.setTransform(id, { position: before.position });
471
+ else if (channel === 'rotation') a2d.setTransform(id, { rotation: before.rotation });
472
+ else a2d.setTransform(id, { scale: before.scale });
473
+ notify();
474
+ };
475
+
476
+ const oid = oidOf(id);
477
+ if (!oid || !backend?.writeProp) {
478
+ // biome-ignore lint/suspicious/noConsole: a lost write must stay diagnosable
479
+ console.warn(
480
+ `[canvas-source ${entryPath}] transform on "${id}" is not persistable ` +
481
+ `(${oid ? 'no source-write backend' : 'node has no source stamp'}) — reverting.`,
482
+ );
483
+ for (const channel of channels) revert(channel);
484
+ return false;
485
+ }
486
+
487
+ const attrs = attrsOf(id) ?? [];
488
+ // A COMPONENT INSTANCE's transform reaches the game only through props the
489
+ // component DECLARES — the same declared-prop gate the three lane's
490
+ // `writeJsxProp` applies to addIfMissing. Appending x/y to a callsite whose
491
+ // component takes no props writes dead schema while the component's own
492
+ // code keeps driving the live object — the tester-visible "it resets on
493
+ // its own" (runhuman pass 63, Survivor's sim-driven HeroActor). An attr
494
+ // the tag ALREADY authors stays editable: that shape is the author's own.
495
+ const instance = componentIdentityOf(id);
496
+ const channelBlocked = (channel: TransformChannel): boolean =>
497
+ componentOwnedChannel(id, channel, attrs, after);
498
+ const writes: Array<{ channel: TransformChannel; prop: string; value: string }> = [];
499
+ for (const channel of channels) {
500
+ if (channelBlocked(channel)) {
501
+ dynamicPaths.add(`${id}|${channel}`);
502
+ // editorConsole, not console: the user watching the value snap back
503
+ // must see WHY in the editor's own Console (the three lane's
504
+ // writeTypedThree narrates its refusals the same way).
505
+ // The Console alone does not reach a user watching the viewport: pass 65
506
+ // read the snap-back as unexplained with the refusal already printed
507
+ // three lines away. The hint says it where they are looking; the
508
+ // Console keeps the full reason.
509
+ showTransientHint(
510
+ `${channel} is owned by <${instance?.name ?? 'this component'}>'s own code — see Console`,
511
+ );
512
+ editorConsole.warn(
513
+ `${channel} on <${instance?.name ?? 'this component'}> is not writable — the ` +
514
+ `component declares no prop for it, so a written value would never reach the running ` +
515
+ `game (the component's own code owns that channel). Edit ` +
516
+ `${instance?.sourcePath ?? 'the component definition'} instead. Reverting.`,
517
+ 'authoring',
518
+ );
519
+ revert(channel);
520
+ continue;
521
+ }
522
+ const plan = planChannelWrite(channel, after, attrs);
523
+ if (!plan.writable) {
524
+ dynamicPaths.add(`${id}|${channel}`);
525
+ // biome-ignore lint/suspicious/noConsole: the refusal reason is the whole point
526
+ console.warn(
527
+ `[canvas-source ${entryPath}] ${channel} on "${id}" is not writable — ${plan.reason} ` +
528
+ 'Reverting the live object.',
529
+ );
530
+ revert(channel);
531
+ continue;
532
+ }
533
+ for (const write of plan.writes) writes.push({ channel, ...write });
534
+ }
535
+ if (writes.length === 0) return false;
536
+
537
+ let persisted = false;
538
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
539
+ for (const write of writes) {
540
+ const res = await selected.writeProp!(oid, write.prop, write.value, {
541
+ // Editing a channel the tag doesn't author yet APPENDS it — placing
542
+ // an object that has never been placed is ordinary authoring, and it
543
+ // must not need a trip to the text editor.
544
+ addIfMissing: true,
545
+ // `scale={1.5}` becomes `scale={{ x, y }}` when a gesture makes it non-uniform.
546
+ ...(write.prop === 'scale' ? { allowShapeUpgrade: true } : {}),
547
+ });
548
+ if (res.changed) {
549
+ persisted = true;
550
+ continue;
551
+ }
552
+ // A channel can span several props. During a revert, for example,
553
+ // x may already be the requested literal while y is the byte that
554
+ // actually changes. That first result is satisfied, not refused: only
555
+ // a dynamic expression or a named backend error closes the channel.
556
+ if (!res.dynamic && !res.error) continue;
557
+ if (res.dynamic) dynamicPaths.add(`${id}|${write.channel}`);
558
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
559
+ console.warn(
560
+ `[canvas-source ${entryPath}] ${write.prop} write refused for oid "${oid}": ` +
561
+ `${res.dynamic ? 'dynamic expression (guarded)' : (res.error ?? 'no change')} — reverting.`,
562
+ );
563
+ revert(write.channel);
564
+ }
565
+ };
566
+ if (writes.length > 1 && backend.runGesture) {
567
+ await backend.runGesture('Transform', (scoped) => run(scoped));
568
+ } else {
569
+ await run(backend);
570
+ }
571
+ // The file changed under us; re-read it so the next gesture plans against
572
+ // what is now on disk rather than the pre-write text.
573
+ await refreshSourceState();
574
+ notify();
575
+ return persisted;
576
+ };
577
+
578
+ /** Mirror a written prop onto the live object where Pixi owns a field of
579
+ * that name. Deliberately narrow: a redraw courtesy, not a second write
580
+ * path — anything Pixi does not carry simply waits for the remount. */
581
+ /** The live object's current value for a prop `applyLiveProp` can mirror —
582
+ * what a refused write must be restored to. */
583
+ const readLiveProp = (id: string, prop: string): unknown => {
584
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
585
+ if (!display) return undefined;
586
+ return prop === 'label' ? display.label : prop in display ? display[prop] : undefined;
587
+ };
588
+
589
+ const applyLiveProp = (id: string, prop: string, value: unknown): void => {
590
+ const display = a2d.displayObject(id) as (Container & Record<string, unknown>) | null;
591
+ if (!display) return;
592
+ if (prop === 'label') display.label = String(value);
593
+ else if (prop in display) display[prop] = value;
594
+ };
595
+
596
+ /** One prop edit. Answers whether a byte reached the entry TSX; every
597
+ * refusal has already named itself on the console before it returns. */
598
+ const writeJsxProp = async (
599
+ id: string,
600
+ path: string,
601
+ prop: string,
602
+ value: unknown,
603
+ ): Promise<boolean> => {
604
+ const oid = oidOf(id);
605
+ if (!oid || !backend?.writeProp) {
606
+ // biome-ignore lint/suspicious/noConsole: a refused write must say why
607
+ console.warn(
608
+ `[canvas-source ${entryPath}] cannot write "${prop}" on "${id}": ` +
609
+ `${oid ? 'no source-write backend in this session' : 'node has no source stamp'}.`,
610
+ );
611
+ return false;
612
+ }
613
+ const echoKey = `${id}|${path}`;
614
+ const liveBefore = readLiveProp(id, prop);
615
+ valueEcho.set(echoKey, value);
616
+ // Optimistic on the LIVE object too, so the paused design frame redraws
617
+ // with the new value instead of waiting for a remount.
618
+ applyLiveProp(id, prop, value);
619
+ notify();
620
+ let res: Awaited<ReturnType<NonNullable<SourceWriteBackend['writeProp']>>>;
621
+ try {
622
+ res = await backend.writeProp(oid, prop, serializeValue(value), { addIfMissing: true });
623
+ } catch (error) {
624
+ valueEcho.delete(echoKey);
625
+ notify();
626
+ // biome-ignore lint/suspicious/noConsole: a failed write must stay diagnosable
627
+ console.warn(
628
+ `[canvas-source ${entryPath}] prop write failed for oid "${oid}" prop "${prop}": ${String(error)}`,
629
+ );
630
+ return false;
631
+ }
632
+ if (!res.changed) {
633
+ valueEcho.delete(echoKey);
634
+ if (res.dynamic) dynamicPaths.add(echoKey);
635
+ // REVERT + NARRATE IN THE PRODUCT. A raw console.warn is invisible: a
636
+ // tester renamed a component instance, watched the name stay put with no
637
+ // explanation anywhere they could see, and reported the editor as unable
638
+ // to change the game (runhuman pass 72). Same contract the transform path
639
+ // above and the three lane's typed writes already keep.
640
+ if (liveBefore !== undefined) applyLiveProp(id, prop, liveBefore);
641
+ notify();
642
+ const subject = componentIdentityOf(id)?.name ?? a2d.displayObject(id)?.label ?? id;
643
+ const reason = res.dynamic
644
+ ? `it is bound to an expression in <${subject}>'s own source, so a direct edit cannot land`
645
+ : `the component declares no ${JSON.stringify(prop)} prop to write` +
646
+ `${res.error ? ` (${res.error})` : ''}`;
647
+ showTransientHint(`${prop} on <${subject}> is not writable — see Console`);
648
+ editorConsole.warn(
649
+ `${JSON.stringify(prop)} on <${subject}> is not writable — ${reason}. ` +
650
+ 'Edit the component definition (right-click the row → Open Component Source), or the ' +
651
+ 'prop that drives it. Reverting.',
652
+ 'authoring',
653
+ );
654
+ return false;
655
+ }
656
+ await refreshSourceState();
657
+ return true;
658
+ };
659
+
660
+ /** Where THIS node's bytes land — its creation-site file, not the world
661
+ * entry. `writeProp` addresses an OID that may live in a scene/prefab the
662
+ * entry only imports; naming `entryPath` here is how a Floor edit in
663
+ * `SurvivorScene.tsx` acked `src/world.tsx`. */
664
+ const destinationOf = (id: string): string => {
665
+ const site = creationSiteOf(id);
666
+ return site.anchored ? site.file : entryPath;
667
+ };
668
+
669
+ /**
670
+ * THIS TARGET'S PLUG INTO THE ONE PERSISTENCE PIPE
671
+ * (`resolve(anchor) → write(dialect) → record(recorder)`, `write-pipe.ts`).
672
+ *
673
+ * The dialect is one — `SourceWriteBackend.writeProp` against the node's
674
+ * own source file — so the resolution is one question: is a writer bound
675
+ * in this session. The target's own per-edit refusals (an expression-bound
676
+ * attribute, an unstamped node, a plan this channel cannot express) are
677
+ * WRITE outcomes rather than resolutions: they revert the live object and
678
+ * name themselves on the console, so the edit's honest ack is the live-only
679
+ * floor and the pipe supplies it.
680
+ *
681
+ * `record` is a no-op because the source-write backend is wrapped in the
682
+ * project's own source history — the transaction that carries the bytes IS
683
+ * the history entry, and journaling here would make one gesture two undos.
684
+ */
685
+ const piped = (
686
+ write: () => Promise<boolean>,
687
+ /** Which backend verb THIS edit's dialect needs bound. A removal is the
688
+ * same dialect and the same anchor as a prop write, but it needs
689
+ * `removeProp`; resolving on the wrong verb acks `source-prop` for a door
690
+ * the session does not have. */
691
+ bound: boolean = backend?.writeProp !== undefined,
692
+ destination: string = entryPath,
693
+ ): Promise<WriteAck> =>
694
+ runWritePipe({
695
+ resolve: (): WriteResolution =>
696
+ bound
697
+ ? { reaches: 'writer', anchorKind: 'source-prop', destination, write }
698
+ : resolvesLiveOnly(NO_SOURCE_WRITER_REASON),
699
+ record: () => undefined,
700
+ // A session with no writer degrades LOUDLY — the write helpers used to be
701
+ // the ones discovering it, and resolution now discovers it first, so the
702
+ // sentence has to move with it.
703
+ report: (reason) => {
704
+ // biome-ignore lint/suspicious/noConsole: a lane that cannot persist must say so
705
+ console.warn(`[canvas-source ${destination}] this edit stays live-only — ${reason}.`);
706
+ },
707
+ });
708
+
709
+ const reportStruct = (message: string): void => {
710
+ // biome-ignore lint/suspicious/noConsole: source refusal must be visible
711
+ console.warn(`[canvas-source ${entryPath}] ${message}`);
712
+ };
713
+
714
+ /**
715
+ * THE STRUCT DIALECT — the shared one-producer pipe
716
+ * (`struct-write-pipe.ts`), configured with this target's reporter, its
717
+ * live backend and its reconcile hook. The struct verbs resolve on their
718
+ * own door and ack `source-structure`, never the value lane's
719
+ * `source-prop` — the shared module's header carries the full rule.
720
+ */
721
+ const structPipe = createStructWritePipe({
722
+ report: reportStruct,
723
+ noWriterReason: NO_SOURCE_WRITER_REASON,
724
+ backend: () => backend,
725
+ onChanged: async () => {
726
+ await refreshSourceState();
727
+ notify();
728
+ },
729
+ });
730
+ const structRefusal = (reason: string): Promise<WriteAck> => structPipe.structRefusal(reason);
731
+
732
+ const structOp = (
733
+ id: string,
734
+ op: string,
735
+ opts?: StructOpOptions,
736
+ oidOverride?: string,
737
+ ): Promise<WriteAck> =>
738
+ structPipe.structOp(oidOverride ?? oidOf(id), id, op, opts, destinationOf(id));
739
+
740
+ const structMany = (
741
+ ids: readonly string[],
742
+ op: string,
743
+ opts?: { wrapperTag?: string },
744
+ ): Promise<WriteAck> => {
745
+ const oids = [...new Set(ids.map(oidOf).filter((oid): oid is string => Boolean(oid)))];
746
+ if (oids.length !== new Set(ids).size) {
747
+ return structRefusal(`${op} refused: every selected node must be source-addressable.`);
748
+ }
749
+ return structPipe.structMany(oids, op, opts, destinationOf(ids[0] ?? ''));
750
+ };
751
+
752
+ const clipboardPayload = (ids: readonly string[]): CanvasStructureClipboard | null => {
753
+ const topLevel = highestSelectedIds(ids, (id) => a2d.node(id)?.parentId ?? null);
754
+ const elementFor = (id: string): CanvasClipboardElement | null => {
755
+ const oid = oidOf(id);
756
+ const entry = oid ? oidIndex.get(oid) : undefined;
757
+ const source = entry ? sources.get(entry.file) : undefined;
758
+ if (!oid || !entry || source === undefined) return null;
759
+ const offset = lineColToOffset(source, entry.line, entry.col);
760
+ const end = findElementEnd(source, offset);
761
+ return source[offset] === '<' && end > offset
762
+ ? { file: entry.file, offset, text: dedentJsxElement(source, offset, end) }
763
+ : null;
764
+ };
765
+ const elements: CanvasClipboardElement[] = [];
766
+ const seenOids = new Set<string>();
767
+ for (const id of topLevel) {
768
+ const oid = oidOf(id);
769
+ if (!oid || seenOids.has(oid)) continue;
770
+ seenOids.add(oid);
771
+ const element = elementFor(id);
772
+ if (!element) return null;
773
+ elements.push(element);
774
+ }
775
+ if (elements.length === 0) return null;
776
+ const sourceFile = elements[0]!.file;
777
+ if (elements.some((element) => element.file !== sourceFile)) return null;
778
+ elements.sort((a, b) => a.offset - b.offset);
779
+ return {
780
+ sourceFile,
781
+ text: elements.map((element) => element.text).join('\n'),
782
+ count: elements.length,
783
+ };
784
+ };
785
+
786
+ const refuseClipboard = (reason: string): false => {
787
+ reportStruct(reason);
788
+ showTransientHint(reason);
789
+ return false;
790
+ };
791
+
792
+ const copyStructure = async (ids: readonly string[]): Promise<boolean> => {
793
+ structureClipboard = null;
794
+ const payload = clipboardPayload(ids);
795
+ if (!payload) {
796
+ return refuseClipboard('Copy needs source-addressable Canvas nodes from one source file.');
797
+ }
798
+ const clipboard = globalThis.navigator?.clipboard;
799
+ if (!clipboard?.writeText) return refuseClipboard('The system clipboard is unavailable.');
800
+ try {
801
+ await clipboard.writeText(payload.text);
802
+ } catch {
803
+ return refuseClipboard('The system clipboard refused the Canvas copy.');
804
+ }
805
+ structureClipboard = payload;
806
+ showTransientHint(`Copied ${payload.count} ${payload.count === 1 ? 'node' : 'nodes'}.`);
807
+ return true;
808
+ };
809
+
810
+ const removeSourceNodes = async (ids: readonly string[]): Promise<WriteAck> => {
811
+ if (backend?.writeStructMany) return structMany(ids, 'delete');
812
+ // No batch door: the per-id loop IS the write, and the gesture's honest ack
813
+ // is the last id's — one gesture, one answer, never a blanket `true`.
814
+ let ack: WriteAck = LIVE_ONLY_ACK;
815
+ for (const id of ids) ack = await structOp(id, 'delete');
816
+ return ack;
817
+ };
818
+
819
+ const pasteTarget = (
820
+ parentId: string | null,
821
+ ): { id: string; oid: string; file: string; op: 'create' | 'create-sibling' } | null => {
822
+ const targetFor = (id: string, op: 'create' | 'create-sibling', preferDefinition: boolean) => {
823
+ const definitionOid = preferDefinition ? definitionOidOf(id) : null;
824
+ const oid = definitionOid ?? oidOf(id);
825
+ const entry = oid ? oidIndex.get(oid) : undefined;
826
+ return oid && entry ? { id, oid, file: entry.file, op, definition: !!definitionOid } : null;
827
+ };
828
+ if (parentId) {
829
+ return targetFor(parentId, 'create', true);
830
+ }
831
+ const lastRoot = a2d.roots().at(-1);
832
+ if (!lastRoot) return null;
833
+ const target = targetFor(lastRoot.id, 'create-sibling', true);
834
+ return target ? { ...target, op: target.definition ? 'create' : 'create-sibling' } : null;
835
+ };
836
+
837
+ const canPasteStructure = (parentId: string | null): boolean => {
838
+ const target = pasteTarget(parentId);
839
+ return !!structureClipboard && !!target && target.file === structureClipboard.sourceFile;
840
+ };
841
+
842
+ const pasteStructure = async (parentId: string | null): Promise<false | WriteAck> => {
843
+ const payload = structureClipboard;
844
+ if (!payload) return refuseClipboard('Copy or cut a Canvas node before pasting.');
845
+ const clipboard = globalThis.navigator?.clipboard;
846
+ if (!clipboard?.readText) return refuseClipboard('The system clipboard is unavailable.');
847
+ try {
848
+ if ((await clipboard.readText()) !== payload.text) {
849
+ return refuseClipboard('The clipboard changed after the Canvas copy.');
850
+ }
851
+ } catch {
852
+ return refuseClipboard('The system clipboard refused the Canvas paste.');
853
+ }
854
+ const target = pasteTarget(parentId);
855
+ if (!target) return refuseClipboard('Paste needs a source-addressable Canvas parent.');
856
+ if (target.file !== payload.sourceFile) {
857
+ return refuseClipboard(
858
+ 'Cross-file paste is refused because the destination may not import the copied dependencies.',
859
+ );
860
+ }
861
+ return clipboardOutcome(
862
+ await structOp(target.id, target.op, { snippet: payload.text }, target.oid),
863
+ );
864
+ };
865
+
866
+ const localToRootMatrix = (object: Container): Matrix => {
867
+ const chain: Container[] = [];
868
+ let cursor: Container | null = object;
869
+ while (cursor) {
870
+ chain.push(cursor);
871
+ cursor = cursor.parent as Container | null;
872
+ }
873
+ const result = new pixi.Matrix();
874
+ for (let index = chain.length - 1; index >= 0; index--) {
875
+ const node = chain[index]!;
876
+ node.updateLocalTransform();
877
+ result.append(node.localTransform);
878
+ }
879
+ return result;
880
+ };
881
+
882
+ const reparentRebase = (
883
+ id: string,
884
+ parentId: string,
885
+ ): {
886
+ position: readonly number[];
887
+ rotation: readonly number[];
888
+ scale: readonly number[];
889
+ scalarChannels: readonly ['rotation'];
890
+ } | null => {
891
+ const object = a2d.displayObject(id);
892
+ const parent = a2d.displayObject(parentId);
893
+ if (!object || !parent) return null;
894
+ const local = localToRootMatrix(parent).clone().invert().append(localToRootMatrix(object));
895
+ const decomposed = new pixi.Transform();
896
+ local.decompose(decomposed);
897
+ const pivot = object.pivot ?? { x: 0, y: 0 };
898
+ return {
899
+ position: [
900
+ local.tx + pivot.x * local.a + pivot.y * local.c,
901
+ local.ty + pivot.x * local.b + pivot.y * local.d,
902
+ ],
903
+ rotation: [decomposed.rotation],
904
+ scale: [decomposed.scale.x, decomposed.scale.y],
905
+ scalarChannels: ['rotation'],
906
+ };
907
+ };
908
+
909
+ const oidInstanceCount = (oid: string): number => {
910
+ let count = 0;
911
+ const visit = (id: string): void => {
912
+ if (readContainerOid(a2d.displayObject(id)) === oid) count++;
913
+ for (const childId of a2d.node(id)?.childIds ?? []) visit(childId);
914
+ };
915
+ for (const root of a2d.roots()) visit(root.id);
916
+ return count;
917
+ };
918
+
919
+ const affectedInstances = (id: string, prop: string): number => {
920
+ const definitionOid = definitionOidOf(id);
921
+ if (!definitionOid) return 0;
922
+ let count = 1;
923
+ const visit = (candidateId: string): void => {
924
+ if (
925
+ candidateId !== id &&
926
+ definitionOidOf(candidateId) === definitionOid &&
927
+ !attrsOf(candidateId)?.some((attr) => attr.name === prop)
928
+ ) {
929
+ count += 1;
930
+ }
931
+ for (const childId of a2d.node(candidateId)?.childIds ?? []) visit(childId);
932
+ };
933
+ for (const root of a2d.roots()) visit(root.id);
934
+ return count;
935
+ };
936
+
937
+ const removeOverride = async (id: string, path: string): Promise<boolean> => {
938
+ const oid = oidOf(id);
939
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
940
+ if (!oid || !prop || !backend?.removeProp) return false;
941
+ const result = await backend.removeProp(oid, prop);
942
+ if (!result.changed) {
943
+ reportStruct(`revert ${prop} refused/no-op: ${result.error ?? 'no change'}.`);
944
+ return false;
945
+ }
946
+ valueEcho.delete(`${id}|${path}`);
947
+ await refreshSourceState();
948
+ notify();
949
+ return true;
950
+ };
951
+
952
+ const authoredTransformProps = (
953
+ channel: TransformChannel,
954
+ attrs: readonly JsxAttrInfo[],
955
+ ): string[] => {
956
+ const names =
957
+ channel === 'position'
958
+ ? ['x', 'y']
959
+ : channel === 'rotation'
960
+ ? ['rotation']
961
+ : attrs.some((attr) => attr.name === 'scale-x' || attr.name === 'scale-y')
962
+ ? ['scale-x', 'scale-y']
963
+ : ['scale'];
964
+ return names.filter((name) => attrs.some((attr) => attr.name === name));
965
+ };
966
+
967
+ const removeTransform = async (id: string, channel: TransformChannel): Promise<boolean> => {
968
+ const oid = oidOf(id);
969
+ const attrs = attrsOf(id);
970
+ if (!oid || !attrs || !backend?.removeProp) return false;
971
+ const props = authoredTransformProps(channel, attrs);
972
+ if (props.length === 0) return false;
973
+ let changed = false;
974
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
975
+ for (const prop of props) {
976
+ const result = await selected.removeProp?.(oid, prop);
977
+ if (result?.changed) changed = true;
978
+ else reportStruct(`revert ${prop} refused/no-op: ${result?.error ?? 'no change'}.`);
979
+ }
980
+ };
981
+ if (props.length > 1 && backend.runGesture) {
982
+ await backend.runGesture(`Remove ${channel}`, (selected) => run(selected));
983
+ } else {
984
+ await run(backend);
985
+ }
986
+ if (!changed) return false;
987
+ await refreshSourceState();
988
+ notify();
989
+ return true;
990
+ };
991
+
992
+ const instancesProvider: ComponentInstancesProvider = {
993
+ openComponent: () => {
994
+ void activateWorkspaceDocument(CANVAS_COMPONENTS_DOCUMENT_ID);
995
+ },
996
+ describe: (id): ComponentInstanceDescription | null => {
997
+ const component = componentIdentityOf(id);
998
+ if (!component) return null;
999
+ // THE CSF GATE, and it lives HERE because this target is the one whose
1000
+ // instances ARE portable-CSF prefab instances — the same sentence
1001
+ // `r3f-source-authoring-adapter.ts`'s `instances.describe` says, in the
1002
+ // adapter that owns both halves of it. It was previously a blanket
1003
+ // wrapper in `PixiAuthoringAdapter`, asked of every target that names a
1004
+ // component; once the LIVE target learned to name a class-owned object's
1005
+ // component, that wrapper silently deleted the creation-site lane's whole
1006
+ // instances capability for any class with no matching story. A target
1007
+ // whose instance notion is the CONSTRUCTION STATEMENT has no story to
1008
+ // require, and it answers for itself.
1009
+ if (getComponentPreviewStories(component.name, component.sourcePath).length === 0) {
1010
+ return null;
1011
+ }
1012
+ const attrs = attrsOf(id);
1013
+ if (!attrs) return null;
1014
+ const declared = declaredPropsOf(id);
1015
+ const overrides = attrs
1016
+ .map((attr) => {
1017
+ const path = `jsx.${attr.name}`;
1018
+ const spec = declared.find((candidate) => candidate.name === attr.name);
1019
+ if (
1020
+ isHiddenProp(attr.name) ||
1021
+ PANEL_OWNED_PROPS.has(attr.name) ||
1022
+ !isOverride(id, attr, spec)
1023
+ ) {
1024
+ return null;
1025
+ }
1026
+ const canApplyToComponent = Boolean(
1027
+ spec?.defaultValue !== undefined &&
1028
+ definitionOidOf(id) &&
1029
+ backend?.runGesture &&
1030
+ backend.writeComponentDefault &&
1031
+ backend.removeProp,
1032
+ );
1033
+ return {
1034
+ path,
1035
+ label: humanizeIdentifier(attr.name),
1036
+ value: literalValueOf(attr),
1037
+ ...(spec?.defaultText === undefined ? {} : { defaultText: spec.defaultText }),
1038
+ canApplyToComponent,
1039
+ affectedInstanceCount: affectedInstances(id, attr.name),
1040
+ ...(canApplyToComponent
1041
+ ? {}
1042
+ : {
1043
+ applyUnavailableReason:
1044
+ spec?.defaultValue === undefined
1045
+ ? 'The component default is computed or absent, so source cannot be changed safely.'
1046
+ : 'This session cannot atomically update the component and its callsite.',
1047
+ }),
1048
+ };
1049
+ })
1050
+ .filter((override): override is NonNullable<typeof override> => override !== null);
1051
+ return {
1052
+ componentName: component.name,
1053
+ ...(component.sourcePath ? { sourcePath: component.sourcePath } : {}),
1054
+ overrides,
1055
+ };
1056
+ },
1057
+ revert: async (id, paths) => {
1058
+ let changed = false;
1059
+ const run = async (selected: SourceWriteBackend): Promise<void> => {
1060
+ const oid = oidOf(id);
1061
+ if (!oid) return;
1062
+ for (const path of paths) {
1063
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
1064
+ if (!prop) continue;
1065
+ const result = await selected.removeProp?.(oid, prop);
1066
+ changed ||= result?.changed === true;
1067
+ }
1068
+ };
1069
+ if (paths.length > 1 && backend?.runGesture) {
1070
+ await backend.runGesture(`Revert ${paths.length} Overrides`, run);
1071
+ } else if (backend) {
1072
+ await run(backend);
1073
+ }
1074
+ await refreshSourceState();
1075
+ notify();
1076
+ return changed ? { destination: destinationOf(id), persisted: true } : undefined;
1077
+ },
1078
+ applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
1079
+ const prop = path.startsWith('jsx.') ? path.slice(4) : null;
1080
+ const spec = prop
1081
+ ? declaredPropsOf(id).find((candidate) => candidate.name === prop)
1082
+ : undefined;
1083
+ const definitionOid = definitionOidOf(id);
1084
+ const callsiteOid = oidOf(id);
1085
+ const component = componentIdentityOf(id);
1086
+ if (
1087
+ !prop ||
1088
+ spec?.defaultValue === undefined ||
1089
+ !definitionOid ||
1090
+ !callsiteOid ||
1091
+ !backend?.runGesture ||
1092
+ !backend.writeComponentDefault ||
1093
+ !backend.removeProp
1094
+ ) {
1095
+ return {
1096
+ changed: false,
1097
+ message: 'Apply was refused because both literal source writes are not available.',
1098
+ };
1099
+ }
1100
+ const value = propValue(attrsOf(id), declaredPropsOf(id), prop);
1101
+ await backend.runGesture(
1102
+ `Apply ${prop} to ${component?.name ?? 'Component'}`,
1103
+ async (scoped) => {
1104
+ const applied = await scoped.writeComponentDefault?.(
1105
+ definitionOid,
1106
+ prop,
1107
+ serializeValue(value),
1108
+ );
1109
+ if (!applied?.changed)
1110
+ throw new Error(applied?.error ?? 'The component default did not change.');
1111
+ const reverted = await scoped.removeProp?.(callsiteOid, prop);
1112
+ if (!reverted?.changed)
1113
+ throw new Error(reverted?.error ?? 'The callsite override was not removed.');
1114
+ },
1115
+ );
1116
+ await refreshSourceState();
1117
+ notify();
1118
+ return {
1119
+ changed: true,
1120
+ message: `${prop} now defaults to ${String(value)} in ${component?.name ?? 'the component'}.`,
1121
+ write: {
1122
+ destination: component?.sourcePath ?? destinationOf(id),
1123
+ persisted: true,
1124
+ },
1125
+ };
1126
+ },
1127
+ };
1128
+
1129
+ const sourceStructure: StructureProvider = {
1130
+ // The new element's oid is minted server-side on the remount, so the id
1131
+ // half is '' and the ack half is this creation's own piped write — handed
1132
+ // back rather than fired `void` (see `StructuralIdWrite`).
1133
+ create: (kind, parentId) => {
1134
+ const snippet = CREATE_SNIPPETS[kind];
1135
+ // Nothing was attempted (the palette is empty in both cases), so there is
1136
+ // no write to ack.
1137
+ if (!snippet || !backend?.writeStruct) return { id: '', ack: undefined };
1138
+ if (parentId) return { id: '', ack: structOp(parentId, 'create', { snippet }) };
1139
+ const lastRoot = a2d.roots().at(-1);
1140
+ if (!lastRoot) {
1141
+ return {
1142
+ id: '',
1143
+ ack: structRefusal(
1144
+ 'create refused: the source needs one addressable root insertion point.',
1145
+ ),
1146
+ };
1147
+ }
1148
+ return { id: '', ack: structOp(lastRoot.id, 'create-sibling', { snippet }) };
1149
+ },
1150
+ creatableKinds: (parentId) => {
1151
+ if (!backend?.writeStruct) return [];
1152
+ if (parentId && !oidOf(parentId)) return [];
1153
+ if (!parentId && a2d.roots().length === 0) return [];
1154
+ return Object.keys(CREATE_SNIPPETS).map((kind) => ({
1155
+ kind,
1156
+ label: CREATE_LABELS[kind] ?? kind,
1157
+ }));
1158
+ },
1159
+ // Returns the promise (never `void structOp(…)`): `deleteSelection`
1160
+ // (`editor-hotkeys.ts`) awaits each id so a same-file multi-delete's writes
1161
+ // land strictly one at a time. Firing them un-awaited is the lost-update
1162
+ // race that fix exists for — every id read the file before any of them
1163
+ // wrote it back.
1164
+ remove: (id) => structOp(id, 'delete'),
1165
+ removeMany: (ids) => removeSourceNodes(ids),
1166
+ copy: (ids) => copyStructure(ids),
1167
+ canCopy: (ids) => clipboardPayload(ids) !== null,
1168
+ cut: async (ids) => {
1169
+ if (!(await copyStructure(ids))) return false;
1170
+ return clipboardOutcome(await removeSourceNodes(ids));
1171
+ },
1172
+ paste: (parentId) => pasteStructure(parentId),
1173
+ canPaste: (parentId) => canPasteStructure(parentId),
1174
+ // Same shape as `remove` above, and for the same reason: the write's promise
1175
+ // goes BACK to the caller so `duplicateSelection` can await each id's byte
1176
+ // before firing the next, instead of racing N same-file writes.
1177
+ duplicate: (id) => ({ id, ack: structOp(id, 'duplicate') }),
1178
+ wrap: (id, wrapperTag) => structOp(id, 'wrap', { wrapperTag: wrapperTag ?? 'pixiContainer' }),
1179
+ unwrap: (id) => structOp(id, 'unwrap'),
1180
+ group: (ids) => {
1181
+ const unique = [...new Set(ids)];
1182
+ if (unique.length < 2) return { id: null, ack: undefined };
1183
+ const parentId = a2d.node(unique[0]!)?.parentId ?? null;
1184
+ if (unique.some((id) => a2d.node(id)?.parentId !== parentId)) {
1185
+ return { id: null, ack: structRefusal('group refused: select two or more siblings.') };
1186
+ }
1187
+ return { id: '', ack: structMany(unique, 'group', { wrapperTag: 'pixiContainer' }) };
1188
+ },
1189
+ canUngroup: (id) => {
1190
+ const oid = oidOf(id);
1191
+ return Boolean(
1192
+ oid &&
1193
+ oidIndex.get(oid)?.tag === 'pixiContainer' &&
1194
+ (a2d.node(id)?.childIds.length ?? 0) > 0,
1195
+ );
1196
+ },
1197
+ ungroup: (id) => {
1198
+ if (!sourceStructure.canUngroup?.(id)) return { ids: [], ack: undefined };
1199
+ const children = [...(a2d.node(id)?.childIds ?? [])];
1200
+ return { ids: children, ack: structOp(id, 'unwrap') };
1201
+ },
1202
+ reparent: (id, parentId) => {
1203
+ if (!parentId) {
1204
+ return structRefusal('reparent refused: drop onto a source-addressable container.');
1205
+ }
1206
+ const parentOid = oidOf(parentId);
1207
+ const sourceOid = oidOf(id);
1208
+ const rebase = reparentRebase(id, parentId);
1209
+ if (!parentOid || !sourceOid || !rebase) {
1210
+ return structRefusal('reparent refused: the source or destination is not addressable.');
1211
+ }
1212
+ return structOp(id, 'reparent', {
1213
+ parentOid,
1214
+ rebase,
1215
+ destinationInstances: oidInstanceCount(parentOid),
1216
+ sourceInstances: oidInstanceCount(sourceOid),
1217
+ });
1218
+ },
1219
+ reorder: (id, beforeSiblingId) => {
1220
+ if (beforeSiblingId) {
1221
+ const targetOid = oidOf(beforeSiblingId);
1222
+ if (!targetOid) {
1223
+ return structRefusal('reorder refused: the target has no source identity.');
1224
+ }
1225
+ return structOp(id, 'reorder', { targetOid });
1226
+ }
1227
+ const parentId = a2d.node(id)?.parentId;
1228
+ const parentOid = parentId ? oidOf(parentId) : null;
1229
+ if (!parentOid) {
1230
+ return structRefusal('reorder-to-end refused: the parent has no source identity.');
1231
+ }
1232
+ return structOp(id, 'reorder', { parentOid });
1233
+ },
1234
+ };
1235
+
1236
+ const relativeProjectModule = (fromFile: string, targetFile: string): string =>
1237
+ relativeImportSpecifier(
1238
+ projectRelativeSourceFile(fromFile),
1239
+ projectRelativeSourceFile(targetFile),
1240
+ );
1241
+
1242
+ /**
1243
+ * Does this component declare a placement prop (`x`/`y`) of its own?
1244
+ * `true` yes, `false` it declares props and none of them place it, `null`
1245
+ * when the declaration could not be read at all (no backend, unreadable
1246
+ * file, or a prop surface this syntax-only pass cannot follow).
1247
+ */
1248
+ const declaredSpecsOf = async (item: {
1249
+ name: string;
1250
+ sourcePath?: string;
1251
+ }): Promise<readonly ComponentPropSpec[] | null> => {
1252
+ const file = item.sourcePath;
1253
+ if (!file || !backend?.readSource) return null;
1254
+ try {
1255
+ const { source } = await backend.readSource(file);
1256
+ const { parseAuthoringTsx } = await import('@volter/editor-react/source/ts-ast');
1257
+ const { localComponentPropSpecs } = await import('@volter/editor-react/source/syntactic-prop-specs');
1258
+ return (
1259
+ localComponentPropSpecs(parseAuthoringTsx(file, source), new Map()).get(item.name) ?? null
1260
+ );
1261
+ } catch {
1262
+ return null;
1263
+ }
1264
+ };
1265
+ /** Props the drop cannot supply: required, no default, not an enum (an
1266
+ * enum gets its first option), not something the snippet writes. */
1267
+ const unsuppliedRequiredProps = (specs: readonly ComponentPropSpec[]): ComponentPropSpec[] =>
1268
+ specs.filter(
1269
+ (spec) =>
1270
+ !spec.optional &&
1271
+ spec.defaultText === undefined &&
1272
+ spec.type !== 'enum' &&
1273
+ !DROP_SNIPPET_PROPS.has(spec.name),
1274
+ );
1275
+ const requiredEnumAttrs = (specs: readonly ComponentPropSpec[]): string =>
1276
+ specs
1277
+ .filter(
1278
+ (spec) =>
1279
+ !spec.optional &&
1280
+ spec.defaultText === undefined &&
1281
+ spec.type === 'enum' &&
1282
+ spec.options?.[0] !== undefined,
1283
+ )
1284
+ .map((spec) => {
1285
+ const first = spec.options?.[0];
1286
+ return typeof first === 'string'
1287
+ ? ` ${spec.name}=${JSON.stringify(first)}`
1288
+ : ` ${spec.name}={${first}}`;
1289
+ })
1290
+ .join('');
1291
+
1292
+ const sourceAssetDrop: AssetDropProvider = {
1293
+ accepts: (nodeId, assetPath, context) => {
1294
+ if (!backend?.writeStruct) return false;
1295
+ const target = nodeId ? oidOf(nodeId) : a2d.roots().at(-1)?.id;
1296
+ if (!target) return false;
1297
+ const item = context?.item;
1298
+ return item?.kind === 'component'
1299
+ ? item.surface === 'canvas'
1300
+ : /\.(?:png|jpe?g|webp|gif|avif|svg)$/i.test(assetPath);
1301
+ },
1302
+ // A drop that lands as an ELEMENT in the source IS a structural write, so it
1303
+ // answers with that write's ack — `AssetDropProvider.drop`'s own contract.
1304
+ // This used to `await structOp(…)` and return `undefined`, which threw the
1305
+ // answer away one frame after producing it.
1306
+ drop: async (nodeId, assetPath, context) => {
1307
+ const anchorId = nodeId || a2d.roots().at(-1)?.id || '';
1308
+ const anchorOid = oidOf(anchorId);
1309
+ const anchorEntry = anchorOid ? oidIndex.get(anchorOid) : undefined;
1310
+ if (!anchorId || !anchorEntry) {
1311
+ return structRefusal('asset drop refused: there is no source-addressable insertion point.');
1312
+ }
1313
+ const item = context?.item;
1314
+ const position = context?.position;
1315
+ const xy = position
1316
+ ? ` x={${formatSourceNumber(position[0])}} y={${formatSourceNumber(position[1])}}`
1317
+ : '';
1318
+ let snippet: string;
1319
+ let ensureImport: { name: string; module: string; kind?: 'default' | 'named' };
1320
+ if (item?.kind === 'component') {
1321
+ if (item.surface !== 'canvas') {
1322
+ return structRefusal(`${item.name} is a ${item.surface} component, not a Canvas prefab.`);
1323
+ }
1324
+ // A COMPONENT THAT PLACES ITSELF IS NOT GIVEN COORDINATES. Writing
1325
+ // `x`/`y` onto a callsite whose component declares neither is dead
1326
+ // schema — the same write `channelBlocked` above refuses for a drag,
1327
+ // in this file, for this reason: the component's own code keeps
1328
+ // driving the live object. The drop did it anyway, so a survivor
1329
+ // prefab like `<Grunt>` (props: `phase`) was created carrying
1330
+ // coordinates it ignores, landed where its sim put it, and then
1331
+ // refused every later edit with "position is owned by <Grunt>'s own
1332
+ // code" — which two testers read as the editor being broken (runhuman
1333
+ // passes 115 and 119). Say it at CREATION instead, where the author
1334
+ // is looking, and write only what the component can read. An
1335
+ // UNRESOLVABLE declaration (props from an imported alias this
1336
+ // syntax-only read cannot follow) is not an answer, so it keeps the
1337
+ // coordinates: never turn "cannot tell" into a silent omission.
1338
+ const specs = await declaredSpecsOf(item);
1339
+ // A COMPONENT THAT NEEDS WHAT NO DROP CAN GIVE IS REFUSED BY NAME. The
1340
+ // retro shooter's <Bunker> takes the sim's own `grid: PixelGrid`;
1341
+ // dropped bare it threw in its first render ("Cannot read properties
1342
+ // of undefined (reading 'width')"), the world went down, and the
1343
+ // author saw "no renderable content" (runhuman pass 148). A required
1344
+ // enum gets its first option; anything else required and defaultless
1345
+ // stops the drop here and says which props and where to fix it.
1346
+ const missing = specs ? unsuppliedRequiredProps(specs) : [];
1347
+ if (missing.length > 0) {
1348
+ const named = missing.map((spec) => `\`${spec.name}\``).join(', ');
1349
+ return structRefusal(
1350
+ `${item.name} needs ${named} — required props with no default, which a drop cannot ` +
1351
+ `supply. Place it from a story that provides them, or give them defaults in ` +
1352
+ `${item.sourcePath ?? 'its component'}.`,
1353
+ );
1354
+ }
1355
+ const placement = specs
1356
+ ? specs.some((spec) => spec.name === 'x' || spec.name === 'y')
1357
+ : null;
1358
+ const ownsPlacement = placement === false;
1359
+ if (ownsPlacement && position) {
1360
+ const said =
1361
+ `${item.name} places itself — its own code owns x/y, so the drop point was not ` +
1362
+ 'written. Move it by editing that component, or give it x/y props.';
1363
+ showTransientHint(said);
1364
+ reportStruct(said);
1365
+ }
1366
+ snippet = `<${item.name}${ownsPlacement ? '' : xy}${specs ? requiredEnumAttrs(specs) : ''} />`;
1367
+ ensureImport = {
1368
+ name: item.name,
1369
+ module: relativeProjectModule(anchorEntry.file, item.sourcePath),
1370
+ kind: item.exportKind,
1371
+ };
1372
+ } else {
1373
+ if (!/\.(?:png|jpe?g|webp|gif|avif|svg)$/i.test(assetPath)) {
1374
+ return structRefusal(`${assetPath} is not an image or Canvas prefab.`);
1375
+ }
1376
+ const label =
1377
+ assetPath
1378
+ .split('/')
1379
+ .pop()
1380
+ ?.replace(/\.[^.]+$/, '') || 'Sprite';
1381
+ snippet = `<pixiSprite label="${label.replace(/["\\]/g, '')}" texture={Texture.from(${JSON.stringify(assetPath)})}${xy} />`;
1382
+ ensureImport = { name: 'Texture', module: 'pixi.js' };
1383
+ }
1384
+ return structOp(anchorId, nodeId ? 'create' : 'create-sibling', {
1385
+ snippet,
1386
+ ensureImport,
1387
+ });
1388
+ },
1389
+ };
1390
+
1391
+ return {
1392
+ provenance: {
1393
+ source: 'source-code',
1394
+ label: 'Pixi source',
1395
+ detail:
1396
+ 'Live @pixi/react display tree; literal JSX props write back to the .tsx source ' +
1397
+ '(expression-bound props are read-only).',
1398
+ },
1399
+ // Accepted edits auto-save through project history. The provider is still
1400
+ // the generic shell's honest destination/failure report, exactly like the
1401
+ // R3F source lane; save itself has nothing pending to flush.
1402
+ ...(persistence ? { persistence } : {}),
1403
+ structure: sourceStructure,
1404
+ assetDrop: sourceAssetDrop,
1405
+
1406
+ truth: (id) => ({
1407
+ site: creationSiteOf(id),
1408
+ writeAnchorKind: backend && oidOf(id) ? 'source-prop' : 'live-only',
1409
+ }),
1410
+ componentIdentity: componentIdentityOf,
1411
+ instances: () => instancesProvider,
1412
+
1413
+ bind(context: CanvasWriteContext): void {
1414
+ a2d = context.a2d;
1415
+ notify = context.notify;
1416
+ boundStore = context.store;
1417
+ backend = withProjectSourceHistory(options.writeBackend, context.store.shell.projectHistory);
1418
+ void refreshSourceState();
1419
+ },
1420
+
1421
+ onReindex(): void {
1422
+ // A late subtree can live in files the first fetch never saw (the fetch
1423
+ // caches only files referenced by then-LIVE oids), so refetch when any
1424
+ // live oid is unindexed or its file uncached — otherwise the new rows'
1425
+ // props read as unaddressable rather than editable.
1426
+ for (const oid of liveOids()) {
1427
+ const entry = oidIndex.get(oid);
1428
+ if (entry === undefined || !sources.has(entry.file)) {
1429
+ void refreshSourceState();
1430
+ return;
1431
+ }
1432
+ }
1433
+ },
1434
+
1435
+ transformEditability(id: string, channel: TransformChannel): TransformEditability {
1436
+ const oid = oidOf(id);
1437
+ if (!oid) {
1438
+ return { writable: false, reason: 'This rendered part has no authored source identity.' };
1439
+ }
1440
+ if (!backend?.writeProp) {
1441
+ return { writable: false, reason: 'This session has no source writer.' };
1442
+ }
1443
+ if (dynamicPaths.has(`${id}|${channel}`)) {
1444
+ return { writable: false, reason: `${channel} is controlled by a JSX expression.` };
1445
+ }
1446
+ const attrs = attrsOf(id);
1447
+ if (!attrs) {
1448
+ return { writable: false, reason: 'Source metadata is still loading or unavailable.' };
1449
+ }
1450
+ if (componentOwnedChannel(id, channel, attrs, read2D(id))) {
1451
+ const name = componentIdentityOf(id)?.name ?? 'this component';
1452
+ return {
1453
+ writable: false,
1454
+ reason: `${channel} is owned by <${name}>'s own code — it declares no prop for it.`,
1455
+ };
1456
+ }
1457
+ const plan = planChannelWrite(channel, read2D(id), attrs);
1458
+ return plan.writable
1459
+ ? {
1460
+ writable: true,
1461
+ ...(backend.removeProp && authoredTransformProps(channel, attrs).length > 0
1462
+ ? { removable: true }
1463
+ : {}),
1464
+ }
1465
+ : { writable: false, reason: plan.reason };
1466
+ },
1467
+
1468
+ beginTransformEdit(id: string): void {
1469
+ if (!editStarts.has(id)) editStarts.set(id, read2D(id));
1470
+ },
1471
+
1472
+ writeTransform(id: string, next: Transform2DValue): void {
1473
+ // The live tree moves first so the paused frame follows the gesture; the
1474
+ // source write happens once, at the end.
1475
+ a2d.setTransform(id, next);
1476
+ notify();
1477
+ },
1478
+
1479
+ // THE GESTURE'S OWN ACK, through the pipe and AWAITED — the commit still
1480
+ // serializes behind whatever is already queued (one ordered source/history
1481
+ // transaction per gesture is why the queue exists), and the promise this
1482
+ // returns resolves only once the bytes have landed or the lane has honestly
1483
+ // reported that none did.
1484
+ endTransformEdit(id: string): void | Promise<WriteAck> {
1485
+ const before = editStarts.get(id);
1486
+ if (!before) return;
1487
+ editStarts.delete(id);
1488
+ const acked = commitQueue.then(() =>
1489
+ piped(
1490
+ () =>
1491
+ commitTransform(id, before).catch((error: unknown) => {
1492
+ // biome-ignore lint/suspicious/noConsole: a lost source write must stay diagnosable
1493
+ console.error(`[canvas-source ${destinationOf(id)}] transform commit failed`, error);
1494
+ return false;
1495
+ }),
1496
+ backend?.writeProp !== undefined,
1497
+ destinationOf(id),
1498
+ ),
1499
+ );
1500
+ // The QUEUE must never inherit a rejection — one poisoned link would
1501
+ // strand every later gesture on this world. It absorbs and says so; the
1502
+ // gesture's own caller still gets the rejection through `acked`.
1503
+ commitQueue = acked.then(
1504
+ () => undefined,
1505
+ (error: unknown) => {
1506
+ // biome-ignore lint/suspicious/noConsole: a lost source write must stay diagnosable
1507
+ console.error(`[canvas-source ${entryPath}] transform commit failed`, error);
1508
+ },
1509
+ );
1510
+ return acked;
1511
+ },
1512
+
1513
+ removeTransform(id: string, channel: TransformChannel): Promise<WriteAck> {
1514
+ return piped(
1515
+ () => removeTransform(id, channel),
1516
+ backend?.removeProp !== undefined,
1517
+ destinationOf(id),
1518
+ );
1519
+ },
1520
+
1521
+ // Same channel every other refusal on this lane uses, so the world's
1522
+ // authoring story is told in one voice: the `.tsx` is truth for
1523
+ // persistence, and this op did not reach it.
1524
+ reportStructureLiveOnly(label: string, reason: string): void {
1525
+ // biome-ignore lint/suspicious/noConsole: a live-only structural edit must stay diagnosable
1526
+ console.warn(`[canvas-source ${entryPath}] ${label} stayed live-only — ${reason}.`);
1527
+ },
1528
+
1529
+ properties(id: string): PropertyDescriptor[] {
1530
+ if (!a2d.displayObject(id)) return [];
1531
+ // `name` is the reserved path the shell renders the hierarchy rename
1532
+ // field against; on this surface it IS Pixi's `label` prop.
1533
+ const props: PropertyDescriptor[] = [
1534
+ { path: 'name', label: 'Name', type: 'string' },
1535
+ { path: 'visible', label: 'Visible', type: 'boolean', group: 'Visibility' },
1536
+ ];
1537
+ const attrs = attrsOf(id) ?? [];
1538
+ const declared = declaredPropsOf(id);
1539
+ const attrByName = new Map(attrs.map((attr) => [attr.name, attr]));
1540
+ const names = [
1541
+ ...declared.map((spec) => spec.name),
1542
+ ...attrs
1543
+ .filter((attr) => !declared.some((spec) => spec.name === attr.name))
1544
+ .map((attr) => attr.name),
1545
+ ];
1546
+ const group = componentIdentityOf(id)?.name ?? 'Properties';
1547
+ for (const name of names) {
1548
+ if (isHiddenProp(name) || PANEL_OWNED_PROPS.has(name)) continue;
1549
+ const attr = attrByName.get(name);
1550
+ const spec = declared.find((candidate) => candidate.name === name);
1551
+ const opaqueDefault =
1552
+ !attr && spec?.defaultText !== undefined && spec.defaultValue === undefined;
1553
+ const resettable = isOverride(id, attr, spec);
1554
+ props.push({
1555
+ path: `jsx.${name}`,
1556
+ label: humanizeIdentifier(name),
1557
+ type: opaqueDefault ? 'string' : (spec?.type ?? (attr ? descriptorType(attr) : 'string')),
1558
+ ...(spec?.options ? { options: [...spec.options] } : {}),
1559
+ readonly: opaqueDefault || (attr ? !attr.isLiteral : false),
1560
+ ...(attr ? {} : { defaulted: true }),
1561
+ ...(resettable ? { resettable: true } : {}),
1562
+ ...(resettable && spec?.defaultText !== undefined ? { revertsTo: spec.defaultText } : {}),
1563
+ group,
1564
+ });
1565
+ }
1566
+ return props;
1567
+ },
1568
+
1569
+ get(id: string, path: string): unknown {
1570
+ const display = a2d.displayObject(id);
1571
+ if (!display) return undefined;
1572
+ const echoed = valueEcho.get(`${id}|${path}`);
1573
+ if (echoed !== undefined) return echoed;
1574
+ if (path === 'name') return display.label ?? '';
1575
+ if (path === 'visible') return display.visible;
1576
+ if (!path.startsWith('jsx.')) return undefined;
1577
+ return propValue(attrsOf(id), declaredPropsOf(id), path.slice(4));
1578
+ },
1579
+
1580
+ // Same ack contract as {@link endTransformEdit}: the pipe's answer for THIS
1581
+ // edit, awaited. A path this target owns no prop for performed no write and
1582
+ // returns nothing.
1583
+ set(id: string, path: string, value: unknown): void | Promise<WriteAck> {
1584
+ const prop =
1585
+ path === 'name'
1586
+ ? 'label'
1587
+ : path === 'visible'
1588
+ ? 'visible'
1589
+ : path.startsWith('jsx.')
1590
+ ? path.slice(4)
1591
+ : null;
1592
+ if (!prop) return;
1593
+ return piped(() => writeJsxProp(id, path, prop, value), undefined, destinationOf(id));
1594
+ },
1595
+
1596
+ // Removal is a write and answers like one — the same pipe, the same
1597
+ // awaited per-edit ack. It is also the ONLY door that can restore
1598
+ // byte-absence: `set` writes a value, so reverting a prop a gesture
1599
+ // APPENDED by setting the default back leaves the attribute in the file.
1600
+ remove(id: string, path: string): void | Promise<WriteAck> {
1601
+ return piped(
1602
+ () => removeOverride(id, path),
1603
+ backend?.removeProp !== undefined,
1604
+ destinationOf(id),
1605
+ );
1606
+ },
1607
+
1608
+ dispose(): void {
1609
+ valueEcho.clear();
1610
+ dynamicPaths.clear();
1611
+ editStarts.clear();
1612
+ },
1613
+ };
1614
+ }