@volter/editor-sdk 0.5.57

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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
package/src/types.ts ADDED
@@ -0,0 +1,1581 @@
1
+ /**
2
+ * The two viewport tabs — the editor's two MODES, named for what the user is
3
+ * doing rather than for what happens to be mounted (ARCHITECTURE-CORE
4
+ * §Vocabulary, decided 2026-08-02). Deliberately not `'scene' | 'game'`:
5
+ * `'scene'` names a document format (a three root is TSX, and
6
+ * asset/story/tool documents live on that tab too), and `'game'`
7
+ * names the mounted artifact rather than the mode. Not to be confused with the
8
+ * PLAY TRANSPORT (the five stable controls) — this is which tab is showing.
9
+ */
10
+ export type ViewportTab = 'edit' | 'play';
11
+
12
+ export type AssetKind =
13
+ | 'model'
14
+ | 'image'
15
+ | 'video'
16
+ | 'audio'
17
+ | 'animation'
18
+ | 'json'
19
+ | 'prefab'
20
+ | 'source';
21
+
22
+ /**
23
+ * The editor's NAMED WORKSPACES — task-named layout memories over the one
24
+ * physical dock (ARCHITECTURE-CORE §Editor chrome). Named for the TASK, each
25
+ * borrowing the arrangement of the tool that does it best; `game` is the
26
+ * default and is the editor's standing arrangement. Restated here (rather than
27
+ * imported from the editor) for the same reason every other id union in this
28
+ * file is: the SDK is a wire client and must not depend on the editor bundle.
29
+ */
30
+ /** One entry of the resolved document table, as `EditorState['adapter']['scenes']`
31
+ * and `documentTable()` carry it over the wire. */
32
+ export interface DocumentTableEntryProjection {
33
+ id: string;
34
+ label: string;
35
+ /** OPEN: `scene`, `prefab`, `page`, `model`, `shot`, `take`, … */
36
+ kind: string;
37
+ region: string | null;
38
+ authorable: boolean;
39
+ reach: { kind: string; [field: string]: unknown };
40
+ source?: { path: string; export?: string };
41
+ finder?: string;
42
+ }
43
+
44
+ export interface DocumentTableProjection {
45
+ default: string | null;
46
+ entries: DocumentTableEntryProjection[];
47
+ /** True while a declared finder has no registration yet — a contribution's
48
+ * finder before the contribution pass — so the table is NOT final. */
49
+ pending: boolean;
50
+ }
51
+
52
+ /** A registered workspace's id: one of the editor's own (`look`, `animate`,
53
+ * `design`) or one a declared package contributes (`game`, `model`,
54
+ * `sculpt`, `texture`, `stage`, …). Validated by the editor against its
55
+ * live registry, the way a utility id is — not a closed set here. */
56
+ export type EditorWorkspaceName = string;
57
+
58
+ export interface HelperVisibility {
59
+ bounds: boolean;
60
+ lights: boolean;
61
+ cameras: boolean;
62
+ colliders: boolean;
63
+ joints: boolean;
64
+ particles: boolean;
65
+ lod: boolean;
66
+ audio: boolean;
67
+ splines: boolean;
68
+ navmesh: boolean;
69
+ constraints: boolean;
70
+ reflectionProbes: boolean;
71
+ triggerVolumes: boolean;
72
+ skeletons: boolean;
73
+ /**
74
+ * The WEIGHT display — a mesh coloured by its active vertex group
75
+ * (`@vgai/blender`'s `blender-runtime-weights.ts`). Added 2026-09-19 (I4)
76
+ * because nothing in this set stood for it: `skeletons` is the bones, and
77
+ * Blender's own viewport overlay has a Bones checkbox but reaches weight
78
+ * colours through Weight Paint MODE, which an inspection surface has no
79
+ * brushes to enter. OFF by default, the way Blender shows no weights until
80
+ * you ask for them.
81
+ */
82
+ weights: boolean;
83
+ }
84
+
85
+ export interface Vec3Value {
86
+ x: number;
87
+ y: number;
88
+ z: number;
89
+ }
90
+
91
+ export interface EditorCameraState {
92
+ position: Vec3Value;
93
+ target: Vec3Value;
94
+ fov?: number;
95
+ }
96
+
97
+ export interface EditorEntitySummary {
98
+ id: string;
99
+ name: string;
100
+ childIds: string[];
101
+ }
102
+
103
+ export interface ViewportCapture {
104
+ base64: string;
105
+ mimeType: 'image/png';
106
+ }
107
+
108
+ /**
109
+ * How an animated look move on the open Object3D document ended. Never an
110
+ * error: the camera is SHARED with the person watching, so "they grabbed it
111
+ * mid-orbit" is an outcome to read, not a failure to handle.
112
+ */
113
+ export interface DocumentLookOutcome {
114
+ /** True only when the whole move was drawn. */
115
+ completed: boolean;
116
+ /** Why it stopped early: a human drag, a later look verb, or a closed document. */
117
+ cancelledBy?: 'human' | 'superseded' | 'closed';
118
+ /** Where the camera ended up, radians around the framed subject. */
119
+ azimuth: number;
120
+ /** Radians above the subject's horizon. */
121
+ elevation: number;
122
+ /** Seconds of the move that were actually drawn. */
123
+ seconds: number;
124
+ }
125
+
126
+ /** Where the open document's camera is standing and what it is aimed at. */
127
+ export interface DocumentCameraPose {
128
+ position: [number, number, number];
129
+ target: [number, number, number];
130
+ }
131
+
132
+ /**
133
+ * Unit 4 (live-front-door wave) — the RUNNING GAME's pixels, as captured by
134
+ * the `bridge-screenshot` relay op (`command-listener.ts`'s
135
+ * `handleBridgeScreenshot`): the full play-mode game stack — canvas(es) PLUS
136
+ * the DOM adapter layers (for example React roots) composited by
137
+ * `capturePlayComposite`. Distinct from {@link ViewportCapture}, which is the
138
+ * EDITOR viewport's own canvas (`capture-viewport`) and knows nothing about
139
+ * play mode or the HUD.
140
+ *
141
+ * `composite` reports honestly whether the DOM-layer composite leg actually
142
+ * ran, or whether the capture degraded to a canvas-only `toDataURL` frame (no
143
+ * container, no DOM `Image`/`XMLSerializer`, or a rasterization failure) —
144
+ * never a silently HUD-less image passed off as the whole game. `layers` is
145
+ * only present on the composite leg.
146
+ *
147
+ * `flatness` and `loopRecoveryFrame` are the same honesty contract applied to
148
+ * the PIXELS: how much of the frame is one flat surface (a near-blank capture
149
+ * carries its own "weak evidence" warning), and whether the frame exists only
150
+ * because capture recovered a starved host loop with one deterministic tick.
151
+ */
152
+ export interface GameCapture {
153
+ base64: string;
154
+ mimeType: 'image/png';
155
+ composite: boolean;
156
+ layers?: { canvases: number; domOverlays: number };
157
+ /** Degeneracy measure — `packages/editor/src/composite-screenshot.ts`'s
158
+ * `measureFlatness`. Absent when pixel readback was unavailable. */
159
+ flatness?: {
160
+ dominantFraction: number;
161
+ dominantColor: string;
162
+ distinctRegions: number;
163
+ degenerate: boolean;
164
+ /** Present iff `degenerate`; the sentence to show the caller verbatim. */
165
+ warning?: string;
166
+ };
167
+ /** True when the host loop was starved and the runtime rendered one
168
+ * deterministic tick to produce this frame. */
169
+ loopRecoveryFrame?: boolean;
170
+ /** Present when this frame came out of a RECORDED run (every `vgai play`
171
+ * records). The still is delivered either way; `notice` is the sentence
172
+ * naming the clip, its offset-0 wall clock, and what a still cannot answer —
173
+ * shown verbatim, never re-derived by the caller. */
174
+ recording?: {
175
+ path: string;
176
+ startedAt: string;
177
+ notice: string;
178
+ };
179
+ }
180
+
181
+ /** Options for recording the clean running-game composite. */
182
+ export interface GameplayRecordingOptions {
183
+ /** Requested real-time capture cadence. Defaults to 30; range 1–60. */
184
+ fps?: number;
185
+ /** `composite-webm` is the compatible one-file artifact. `canvas-dom`
186
+ * records the world canvas directly and writes the HUD to a synchronized
187
+ * replay sidecar, avoiding live DOM rasterization. */
188
+ format?: 'composite-webm' | 'canvas-dom';
189
+ /** Names the file under `.vgai/recordings/`. During Play, absence names the
190
+ * clip after its durable Gameplay Session. */
191
+ name?: string | null;
192
+ }
193
+
194
+ /** Facts fixed when a gameplay recording starts. */
195
+ export interface GameplayRecordingStarted {
196
+ format: 'composite-webm' | 'canvas-dom';
197
+ startedAt: string;
198
+ mimeType: string;
199
+ width: number;
200
+ height: number;
201
+ fps: number;
202
+ audio: boolean;
203
+ layers: { canvases: number; domOverlays: number };
204
+ /** Where the bytes are landing — known at START, so a caller can say where
205
+ * the evidence for a run still in progress will be. */
206
+ path: string;
207
+ /** Replay sidecar directory for `canvas-dom`; null for a composite. */
208
+ replayPath: string | null;
209
+ /** True only for the out-of-session rotating fallback. */
210
+ rotates: boolean;
211
+ /** This run's `logs/play-*.jsonl`, whose event timestamps map to clip offsets. */
212
+ logFile: string | null;
213
+ }
214
+
215
+ /** Final browser recording. Media chunks stream directly to the project while
216
+ * recording, so the command result stays small even for a long playthrough. */
217
+ export interface GameplayRecordingCapture extends GameplayRecordingStarted {
218
+ durationMs: number;
219
+ droppedFrames: number;
220
+ frameErrors: number;
221
+ /** The cadence ACHIEVED (frames over wall duration), which diverges from the
222
+ * requested `fps` on a throttled hidden tab. Read this before reasoning
223
+ * about a clip's timing. */
224
+ effectiveFps: number;
225
+ /** True when the tab was hidden for any part of the recording. */
226
+ hidden: boolean;
227
+ }
228
+
229
+ /** A position on the active recorder's authoritative monotonic timeline.
230
+ * `startedAt` identifies the recording; `elapsedMs` shares the exact origin
231
+ * used to calculate the finalized capture's `durationMs`. */
232
+ export interface GameplayRecordingTimeline {
233
+ startedAt: string;
234
+ elapsedMs: number;
235
+ }
236
+
237
+ /** A PNG reconstructed on demand from canvas video plus its DOM timeline. */
238
+ export interface GameplayReplayCapture {
239
+ replayPath: string;
240
+ positionMs: number;
241
+ width: number;
242
+ height: number;
243
+ base64: string;
244
+ mimeType: 'image/png';
245
+ layers: { canvases: number; domOverlays: number };
246
+ flatness?: GameCapture['flatness'];
247
+ }
248
+
249
+ /** What Play can report while its recording is still open. Geometry, layers, media cadence, and
250
+ * final health are deliberately absent: those facts are only authoritative after Stop finalizes
251
+ * the capture. */
252
+ export interface PlayRecordingStatus {
253
+ path: string;
254
+ format: 'composite-webm' | 'canvas-dom';
255
+ replayPath: string | null;
256
+ startedAt: string;
257
+ rotates: boolean;
258
+ logFile: string | null;
259
+ idleAutoStopMs: number;
260
+ }
261
+
262
+ /** What `play` reports back once the run is up and recording. */
263
+ export interface PlayStarted {
264
+ /** Absent only when the recorder could not start; play is up either way and
265
+ * the editor console carries the reason. */
266
+ recording?: PlayRecordingStatus;
267
+ }
268
+
269
+ export type AssetPreviewView = 'front' | 'right' | 'top' | 'perspective';
270
+ export type AssetPreviewBackground = 'neutral' | 'transparent';
271
+
272
+ /**
273
+ * What the Asset Lab is being asked to photograph: a same-origin project
274
+ * model path, a live scene entity, or RAW GLB BYTES that travel with the
275
+ * command.
276
+ *
277
+ * The bytes form exists because rasterization only happens where there is a
278
+ * GPU. A Node host that built a model in memory — the module-look lane's
279
+ * `project.bake.preview`, which compiles a project TS module's `Object3D` and
280
+ * exports it to an in-memory GLB without writing anything — has pixels
281
+ * nowhere until the live editor session renders them. It is four-view,
282
+ * lab-stage only: bytes stand nowhere, so `stage: 'scene'`, the shot-set /
283
+ * source-review modes and compare are all refused by name at the relay.
284
+ */
285
+ export type AssetPreviewSource =
286
+ | { assetPath: string; entityId?: never; glbBase64?: never }
287
+ | { entityId: string; assetPath?: never; glbBase64?: never }
288
+ | { glbBase64: string; assetPath?: never; entityId?: never };
289
+
290
+ /**
291
+ * Where an ENTITY capture is staged.
292
+ *
293
+ * `'lab'` (the default on every surface) is the editor's neutral Asset Lab
294
+ * stage: an isolated, yaw-normalized snapshot under fixed studio lighting,
295
+ * identical whatever the entity's surroundings are. `'scene'` photographs the
296
+ * entity where it stands in the live scene, under the scene's own lighting,
297
+ * with the editor's own grid/gizmos/helpers excluded — see
298
+ * `packages/editor/src/asset-preview.ts`'s `captureSceneStageAssetPreview`.
299
+ *
300
+ * `'scene'` is an ENTITY-only, four-view option: the relay refuses it by name
301
+ * for an `assetPath` source (a model loaded from disk stands nowhere) and for
302
+ * the shot-set / source-review / compare modes (each stages its own subject).
303
+ */
304
+ export type AssetPreviewStage = 'lab' | 'scene';
305
+
306
+ /**
307
+ * A free capture camera for the Asset Lab legs (`vgai screenshot`'s
308
+ * `--azimuth/--elevation/--distance`): ONE view from a chosen angle instead
309
+ * of the fixed four. Angles are relative to the subject's AUTHORED front —
310
+ * azimuth 0 photographs the declared front, 90 walks toward the side the
311
+ * turntable's yaw-90 shot shows; elevation raises the camera (degrees above
312
+ * level); `distance` is meters from the framing center, auto-fit when
313
+ * omitted.
314
+ */
315
+ export interface AssetPreviewCameraChoice {
316
+ azimuthDegrees: number;
317
+ elevationDegrees: number;
318
+ distance?: number;
319
+ }
320
+
321
+ /**
322
+ * Pose an animated subject before capturing (`vgai screenshot`'s
323
+ * `--clip <name> --time <t>`): the named clip is sampled at `timeSeconds`
324
+ * on the capture's disposable snapshot — the source is never mutated. The
325
+ * capture fails loudly (naming the clips that DO exist) when the subject
326
+ * carries no clip by this name.
327
+ */
328
+ export interface AssetPreviewPose {
329
+ clip: string;
330
+ timeSeconds: number;
331
+ }
332
+
333
+ export interface AssetPreviewOptions {
334
+ width?: number;
335
+ height?: number;
336
+ background?: AssetPreviewBackground;
337
+ stage?: AssetPreviewStage;
338
+ camera?: AssetPreviewCameraChoice;
339
+ pose?: AssetPreviewPose;
340
+ }
341
+
342
+ /**
343
+ * How the captured subject was oriented relative to its AUTHORED coordinates.
344
+ * The editor yaw-normalizes a subject so the front camera photographs its
345
+ * declared front; a 180-degree yaw maps authored +X to screen-LEFT in the
346
+ * front view. The yaw is reported here (and stamped onto the images' own
347
+ * pixels as `+X>` / `<+X` markers) so it is never applied silently.
348
+ */
349
+ export interface AssetPreviewOrientation {
350
+ /** The subject's declared forward, `[0,0,1]` when it declares none. */
351
+ forward: [number, number, number];
352
+ /** Yaw applied to face the front camera; 0 means authored axes = world axes. */
353
+ yawDegrees: number;
354
+ }
355
+
356
+ export interface AssetPreviewCapture {
357
+ width: number;
358
+ height: number;
359
+ /** Absent from editors that predate orientation reporting. */
360
+ orientation?: AssetPreviewOrientation;
361
+ views: Array<ViewportCapture & { view: AssetPreviewView }>;
362
+ contactSheet: ViewportCapture & { width: number; height: number };
363
+ }
364
+
365
+ /**
366
+ * The STORY lane (`vgai screenshot <file>.stories.tsx`): a project CSF file's
367
+ * exports rendered in the live session's DOM and captured through the same
368
+ * composite leg the game lane uses, as ONE variant sheet per file. `story`
369
+ * narrows to a single export. See
370
+ * `packages/editor/src/stories/story-capture.ts`.
371
+ */
372
+ export interface StoryCaptureOptions {
373
+ /** Narrow the sheet to one CSF export name (`--story <export>`). */
374
+ story?: string;
375
+ /** Per-variant cell size in CSS pixels; defaults to a 960x540 rectangle,
376
+ * because a UI story is not the Asset Lab's square 3D view. */
377
+ width?: number;
378
+ height?: number;
379
+ /** Free capture camera for THREE stories (same contract as the Asset Lab's
380
+ * {@link AssetPreviewCameraChoice}). A selected story that renders on the
381
+ * DOM leg refuses these BY NAME rather than silently ignoring them. */
382
+ camera?: AssetPreviewCameraChoice;
383
+ /** Clip pose for THREE stories (same contract as the Asset Lab's
384
+ * {@link AssetPreviewPose}); DOM-leg stories refuse it by name. */
385
+ pose?: AssetPreviewPose;
386
+ }
387
+
388
+ export interface StoryVariantImage extends ViewportCapture {
389
+ /** The CSF export name — the file name each variant PNG is written under. */
390
+ name: string;
391
+ /** Storybook's human-facing story name. */
392
+ label: string;
393
+ }
394
+
395
+ export interface StoryVariantCapture {
396
+ /** Project-relative path of the CSF module that was photographed. */
397
+ modulePath: string;
398
+ width: number;
399
+ height: number;
400
+ variants: StoryVariantImage[];
401
+ contactSheet: ViewportCapture & { width: number; height: number };
402
+ }
403
+
404
+ /**
405
+ * B8.4 — the Asset Lab compare mode (`vgai screenshot <model.glb>
406
+ * --compare <ref.glb>`): the asset and a caller-supplied reference GLB rendered with
407
+ * matched orthographic front + side framing (equal-height bounding-box
408
+ * normalization, both yaw-normalized to face the camera), scored by
409
+ * silhouette IoU with per-view overlay evidence (orange asset / cyan
410
+ * reference / near-white agreement). See
411
+ * `packages/editor/src/asset-compare.ts`.
412
+ */
413
+ export type AssetCompareView = 'front' | 'side';
414
+
415
+ export interface AssetCompareOptions {
416
+ width?: number;
417
+ height?: number;
418
+ /** Override the reference GLB's ground-plane forward vector; defaults to
419
+ * its `userData.forward` extras when present, else glTF's +Z. */
420
+ refForward?: [number, number, number];
421
+ }
422
+
423
+ export interface AssetCompareCapture {
424
+ width: number;
425
+ height: number;
426
+ views: Array<{
427
+ view: AssetCompareView;
428
+ /** Silhouette intersection-over-union in [0, 1]. */
429
+ iou: number;
430
+ overlay: ViewportCapture;
431
+ asset: ViewportCapture;
432
+ ref: ViewportCapture;
433
+ }>;
434
+ }
435
+
436
+ /**
437
+ * THE shot-set contract. This block is the ONE declaration of it.
438
+ *
439
+ * A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`).
440
+ * The DEFINITION is project data: a registered project tool named
441
+ * `project.<set>.previewShots` returns it (installed capabilities register
442
+ * theirs — the bird, humanoid and walking-castle capabilities each contribute
443
+ * their canonical verify set), and the editor's generic capture engine renders
444
+ * it — turntable yaw angles and skeleton-anchored zoom crops, optionally under
445
+ * a named pose applied to a disposable snapshot. Every shot is labeled so a
446
+ * flat directory of PNGs is self-describing without a manifest file.
447
+ *
448
+ * The contract lives HERE, in the SDK, because it crosses the editor relay:
449
+ * the capability tool that authors a set, the CLI that ships it across, and
450
+ * `packages/editor/src/asset-preview.ts`'s capture engine that renders it are
451
+ * three different programs. Each of those used to declare its own copy — five
452
+ * declarations in total — and the copies had already drifted on what a pose
453
+ * step's `radians` is measured FROM. Every side now type-checks against this
454
+ * one: the capture engine annotates its parser's return with
455
+ * `AssetPreviewShotSetDefinition`, and each capability tool annotates its
456
+ * exported set with it, so a Zod schema that stops matching this shape is a
457
+ * compile error rather than a runtime surprise at the relay.
458
+ */
459
+ export interface ShotSetPoseRotation {
460
+ bone: string;
461
+ axis: 'x' | 'y' | 'z';
462
+ /**
463
+ * A DELTA about the bone's own local axis, in radians, relative to the
464
+ * loaded GLB's baked rest pose — NOT an absolute local rotation.
465
+ *
466
+ * That is what the capture engine does with it: `bone.rotateX/Y/Z(radians)`,
467
+ * which composes onto the bone's existing local quaternion. The consequence
468
+ * to watch for is that "rest pose" means whatever the GLB baked, not
469
+ * identity — a rig baked mid-gait needs `pose(t) - pose(0)` here, while a
470
+ * rig baked at identity can pass its absolute angle unchanged. Getting that
471
+ * backwards silently double-applies the baked pose.
472
+ */
473
+ radians: number;
474
+ }
475
+
476
+ /** The other half of a pose: a named morph target driven to an influence.
477
+ * Morphs are how a rig expresses what a joint cannot — an eyelid sliding over
478
+ * an eyeball, a brow band tilting — so a set that poses a FACE needs both
479
+ * halves or it can only ever show the jaw. A rig whose meshes carry no such
480
+ * morph is posed by the rotations alone: the same degrade-don't-throw rule
481
+ * the rotations follow for a missing joint. */
482
+ export interface ShotSetPoseMorph {
483
+ morph: string;
484
+ influence: number;
485
+ }
486
+
487
+ /** The TRANSLATION channel of a pose: a joint displaced along its own local
488
+ * axis. Rotations alone cannot state a gait's vertical truth — a crouch, a
489
+ * jump apex, the hip dip that makes a walk read as weighted — because those
490
+ * are the root/hips MOVING, not a joint bending (round-4 finding: shot sets
491
+ * could not photograph a gait). Same delta semantics as the rotation: the
492
+ * capture engine applies `bone.translateX/Y/Z(meters)`, composing onto
493
+ * whatever local position the GLB baked. */
494
+ export interface ShotSetPoseTranslation {
495
+ bone: string;
496
+ axis: 'x' | 'y' | 'z';
497
+ /** A DELTA along the bone's own local axis, in meters, relative to the
498
+ * loaded GLB's baked rest position. */
499
+ meters: number;
500
+ }
501
+
502
+ /** One step of a named pose. A flat union rather than parallel lists: a
503
+ * single expression is normally one rotation AND one morph (a jaw ROTATION
504
+ * plus a brow MORPH), so keeping them in one ordered list means a definition
505
+ * never has to zip them. Discriminated by field name: `radians` is a
506
+ * rotation, `meters` a translation, `influence` a morph. */
507
+ export type ShotSetPoseStep = ShotSetPoseRotation | ShotSetPoseMorph | ShotSetPoseTranslation;
508
+
509
+ export type ShotSetShot =
510
+ | { label: string; view: 'turntable'; yaw: number; pose?: string | undefined }
511
+ | {
512
+ label: string;
513
+ view: 'bone-zoom';
514
+ bones: string[];
515
+ spanFraction: number;
516
+ /**
517
+ * The angle the crop is taken FROM, in the same convention and units as
518
+ * a turntable shot's `yaw` (radians; 0 is the front camera, -PI/2 the
519
+ * subject's left, +PI/2 its right). Omitted means 0 — the front-camera
520
+ * framing every bone-zoom shot had before this field existed, so an
521
+ * existing shot set renders unchanged.
522
+ *
523
+ * It exists because the front camera is not a general answer for a long
524
+ * subject: on an 8 m quadruped a crop anchored on the tail root
525
+ * photographs the hind legs standing between the camera and the tail. A
526
+ * junction whose axis runs down the body's length is inspectable only
527
+ * from the side.
528
+ */
529
+ yaw?: number | undefined;
530
+ pose?: string | undefined;
531
+ };
532
+
533
+ export interface AssetPreviewShotSetDefinition {
534
+ /** The set's name (the CLI's `--shots <name>`), echoed in error messages. */
535
+ name: string;
536
+ /** Joints that must exist on the loaded GLB's OWN skeleton — zoom anchors
537
+ * plus a loud failure naming the missing joints (never a silent
538
+ * bounding-box fallback for a set that promised skeleton anchoring). */
539
+ requiredBones?: string[];
540
+ /** Optional clause appended to rig-requirement errors,
541
+ * e.g. "a Mixamo-named humanoid skeleton". */
542
+ rigRequirementHint?: string;
543
+ /** Named poses (bone rotations and morph influences applied to a disposable
544
+ * snapshot only); shots opt in via their `pose` field. */
545
+ poses?: Record<string, ShotSetPoseStep[]>;
546
+ shots: ShotSetShot[];
547
+ }
548
+
549
+ /**
550
+ * A shot the capture engine rendered but does not vouch for.
551
+ *
552
+ * `'empty-frame'` — the shot's frame contained no renderable geometry, so
553
+ * the PNG is background only. It is reported rather than thrown because one
554
+ * mis-aimed crop must not kill a 20-shot render; it is reported LOUDLY
555
+ * because a background tile on a contact sheet otherwise reads as coverage.
556
+ */
557
+ export interface AssetPreviewShotWarning {
558
+ label: string;
559
+ reason: 'empty-frame';
560
+ /** Already names the shot, its anchor joints and its pose — surfaces print
561
+ * this string rather than re-composing one. */
562
+ message: string;
563
+ bones?: string[];
564
+ pose?: string;
565
+ }
566
+
567
+ export interface LabeledShotSetCapture {
568
+ width: number;
569
+ height: number;
570
+ shots: Array<ViewportCapture & { label: string }>;
571
+ /** Empty when every shot framed geometry. Absent against an editor that
572
+ * predates the empty-frame guard. */
573
+ warnings: AssetPreviewShotWarning[];
574
+ contactSheet: ViewportCapture & { width: number; height: number };
575
+ }
576
+
577
+ export interface EditorState {
578
+ /**
579
+ * The Code-OSS workbench this session is running, or `null` when it is
580
+ * running none. The session's children are the session's to report, exactly
581
+ * as its run configurations are — so this is SERVER-computed on every read,
582
+ * never part of the browser-POSTed snapshot.
583
+ *
584
+ * `kind` is how the bytes were obtained: a `release` is an extracted
585
+ * `vscode-reh-web-*` package (its `BUILD.json` carries the commit), `sources`
586
+ * is a fork checkout (`git rev-parse HEAD` is the commit). `dir` is what the
587
+ * project's `.vgai/workbench.json` — or `vgai edit --workbench` — named.
588
+ * `product` is the product whose workbench half is overlaid on those bytes
589
+ * (P3): a workbench is built for ONE product, and the session refuses one
590
+ * built for another than this project's before it spawns.
591
+ * Absent against an older server that predates the field.
592
+ */
593
+ workbench?: {
594
+ kind: 'release' | 'sources';
595
+ dir: string;
596
+ commit: string;
597
+ product: string;
598
+ } | null;
599
+ /**
600
+ * The PRODUCT this session is serving — `@vgai/game-editor` or
601
+ * `@vgai/model-editor` — or `null` when it is serving none. SERVER-computed
602
+ * on every read, beside {@link workbench}, for the same reason: what a
603
+ * session is running is its own fact, not something the page reports about
604
+ * itself.
605
+ *
606
+ * `id` is the package name, `dir` where it resolved from, `version` what it
607
+ * is. Which product runs is never a switch (ARCHITECTURE-CORE §The target
608
+ * shape, rule 4) — it is what the project's dependencies resolved to — so
609
+ * this is a REPORT and never a thing to branch on. Absent against an older
610
+ * server that predates the field.
611
+ */
612
+ product?: { id: string; dir: string; version: string } | null;
613
+ playState: 'stopped' | 'playing' | 'paused';
614
+ /**
615
+ * Issue #175 — the REAL engine `GameLoop.liveness` behind the current play
616
+ * session, distinct from `playState` above (editor UI state — a store
617
+ * flag that never reflected whether the loop was actually ticking).
618
+ * `'loop-starved'` means the host loop has observed no recent rAF progress:
619
+ * either the current `document.hidden` gate deliberately parked it, or an
620
+ * armed visible-page callback has not arrived within the starvation
621
+ * interval. It is explicitly NOT a conclusion that the tab is hidden.
622
+ * `null` while not in play mode (no loop to report on) or against an older
623
+ * server that predates this field.
624
+ */
625
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null;
626
+ /**
627
+ * The connected editor tab's OWN reported visibility/focus
628
+ * (`command-listener.ts`'s `collectPresence`, straight off
629
+ * `document.visibilityState`/`document.hasFocus()`). This is the only field
630
+ * that distinguishes a usable session from a merely attached one:
631
+ * `connected` answers "is an SSE client holding the session open", which a
632
+ * BACKGROUNDED tab satisfies perfectly while the engine hidden-pauses its
633
+ * loop underneath. `null` when the page has no `document` at all; absent
634
+ * against an older server that predates the field.
635
+ *
636
+ * P21 — `reportedAt` is `Date.now()` in the TAB at the moment those two
637
+ * values were read. It is what makes this snapshot readable as a
638
+ * measurement rather than a fact: the tab re-POSTs state on
639
+ * visibilitychange/focus/blur, after commands and on store changes, so
640
+ * between those moments this ages, and a reader with no age has no way to
641
+ * tell a 40ms-old reading from a 40s-old one. Absent against an editor
642
+ * page that predates the field — never fabricated from the read time.
643
+ */
644
+ presence?: {
645
+ visibility: 'visible' | 'hidden' | 'prerender';
646
+ focused: boolean;
647
+ reportedAt?: number;
648
+ } | null;
649
+ /**
650
+ * The pending-restart reason when source changed while the game was
651
+ * RUNNING and the running session is now stale (e.g. an R3F entry-file
652
+ * write-back during play, a registry.ts edit). The editor's Restart button
653
+ * surfaces the same reason; one restart (`vgai play`, or the button)
654
+ * remounts every root from fresh source and clears it. `null` when the
655
+ * running session is fresh; absent against an older server that predates
656
+ * the field.
657
+ */
658
+ restartRequired?: string | null;
659
+ selectedEntityId: string | null;
660
+ selectedEntityIds: string[];
661
+ activeViewportTab: ViewportTab;
662
+ /** The actual active center document, including tool/source documents. */
663
+ activeDocumentId?: string | null;
664
+ /** The visible bottom utility, or null when that drawer is collapsed. */
665
+ activeUtilityId?: string | null;
666
+ /** The shared Analytics rail's current durable-session selection. */
667
+ gameplaySession?: {
668
+ selectedSessionId: string | null;
669
+ latestSessionId: string | null;
670
+ selectedStatus: 'live' | 'completed' | null;
671
+ cursorMs: number;
672
+ liveEdgeMs: number;
673
+ };
674
+ /** Every center document the editor currently has open, by stable registry id. */
675
+ openDocumentIds?: string[];
676
+ /** Live editor-owned WebGL renderers, split by resource owner. */
677
+ rendererResources?: {
678
+ hostLive: number;
679
+ interactive: { active: number; idle: number };
680
+ inspectorPreview: { active: number; idle: number };
681
+ };
682
+ activeTabKey: string;
683
+ showGrid: boolean;
684
+ showHelpers: boolean;
685
+ showStats: boolean;
686
+ shadingMode: ShadingMode;
687
+ helperVisibility: HelperVisibility;
688
+ transformMode: 'translate' | 'rotate' | 'scale';
689
+ transformSpace: 'world' | 'local';
690
+ snapEnabled: boolean;
691
+ entityCount: number;
692
+ savePath: string | null;
693
+ /** Live editor persistence state. Wait for `saved` before an external scene-file write. */
694
+ saveState: 'saved' | 'unsaved' | 'failed';
695
+ /** Current editor camera pose when the viewport has bound a camera. */
696
+ camera?: EditorCameraState;
697
+ /** Flattened live authoring hierarchy, useful for agent entity discovery. */
698
+ entities?: EditorEntitySummary[];
699
+ /**
700
+ * How many browser tabs are PRESENT for this session right now, read from
701
+ * the server's tab table (`server/tab-presence.ts`) rather than from a
702
+ * socket count — so a tab mid-reload still counts (it is beating), and a
703
+ * socket with no tab behind it does not.
704
+ *
705
+ * The rest of this object is the last snapshot a browser POSTed and
706
+ * persists even after every tab goes — so a `0` here means the other fields
707
+ * are stale cache and commands (`play`, `scene`, …) will refuse with the
708
+ * table's own reason. Added by the server on every `/__editor/state` read.
709
+ */
710
+ editorsConnected?: number;
711
+ /** Convenience: `editorsConnected > 0`. */
712
+ connected?: boolean;
713
+ /**
714
+ * ONE ROW PER PRESENT TAB — the whole table, because the owner's rule for
715
+ * this seam is "if they DO get disconnected, make it clear that it
716
+ * happened" and a single boolean can never say that.
717
+ *
718
+ * `lastBeatAgo` is the heartbeat age (null for a tab that cannot beat, e.g.
719
+ * one bridged through the share tunnel); a number climbing past a second or
720
+ * two is a gap in progress. `epochCount` counts page-loads, so a number
721
+ * that keeps rising is a reload loop. `channel: 'down'` with a fresh beat
722
+ * is a tab mid-reload — present, and briefly unable to take a command.
723
+ * `unresponsive` is the zombie: beating, but its page has never opened a
724
+ * command channel this page-load. Absent against an older server.
725
+ *
726
+ * `commandListener` is the standing verdict on the DOCUMENT, and it is a
727
+ * different question from all of the above: the control channel is opened by
728
+ * the tiny pre-React entry before any module loads, so a page that dies
729
+ * during boot beats, holds a channel, gets blessed, and executes nothing. It
730
+ * reads `'not attached'` for that page, `'silent since <t>'` for one whose
731
+ * listener stopped acknowledging relayed commands, and `'ready'` otherwise —
732
+ * each from a timestamp the server already stamps (the listener's own
733
+ * attach/detach report, and the command receipts). Absent against an older
734
+ * server, and absent for a tab the server cannot measure.
735
+ *
736
+ * `pageErrors` is the OTHER half of that verdict — WHY. Uncaught errors and
737
+ * unhandled rejections captured by the page shell's inline bootstrap, which
738
+ * runs before the module graph, so the boot failure that leaves no listener
739
+ * is exactly the one they explain. They come back on a plain GET and need no
740
+ * cooperation from the page beyond the handler itself. `[]` means the page
741
+ * reported none; absent means the server cannot measure this tab.
742
+ *
743
+ * `census` is the tab's RESOURCE PROFILE, sampled by the page every five
744
+ * seconds and carried on the heartbeat: what a browser-level renderer death
745
+ * would otherwise leave unexplained. It is `@volter/editor-sdk/tab-census`'s
746
+ * {@link RecordedTabCensus} — the census MINUS the `mountEpochs` guarantee,
747
+ * because this is a response and the server that answered it may be older
748
+ * than that field; every other absence (`heapUsedMB` off Chromium, renderer
749
+ * counts with no mounted adapter) is documented on the type itself.
750
+ * `censusAgeMs` says how stale the profile is — a hidden tab is not sampled.
751
+ * The census's `blender` block is the OTHER question it carries: how long the
752
+ * tab's Blender worker has been holding a call and how long this thread has
753
+ * stalled, absent in a tab with no Blender session.
754
+ */
755
+ tabs?: Array<{
756
+ /**
757
+ * WHAT IS TRUE OF THIS TAB, in one word — `ended`, `closed`, `reloading`,
758
+ * `crashed`, `suspended`, `hung`, `busy` or `present` — derived
759
+ * server-side by ONE function over the fields below
760
+ * (`server/tab-presence.ts`'s `tabState`), never re-derived by a reader.
761
+ *
762
+ * It exists because every other field on this row answers a NARROWER
763
+ * question than the one that gets asked. Measured 2026-09-17: one symptom
764
+ * ("the battery stopped") had four causes in one night — a renderer killed
765
+ * by a dev-server reload, a worker that never finished booting, a main
766
+ * thread wedged inside a 46 MB encode, and a twin call genuinely running
767
+ * for sixteen minutes — and each of them is a different instruction to
768
+ * whoever is reading. `stateMs` is THE number that goes with the word, and
769
+ * it is a different measurement per state (time since the goodbye, beat
770
+ * age, the gap that was resumed, census age, the outstanding call's age);
771
+ * `stateBecause` is the evidence in a sentence, so a reader never has to
772
+ * know which field the verdict came from. Absent against an older server.
773
+ */
774
+ state?:
775
+ | 'ended'
776
+ | 'closed'
777
+ | 'reloading'
778
+ | 'crashed'
779
+ | 'suspended'
780
+ | 'hung'
781
+ | 'busy'
782
+ | 'present';
783
+ stateMs?: number | null;
784
+ stateBecause?: string;
785
+ tabId8: string;
786
+ presentFor: number;
787
+ lastBeatAgo: number | null;
788
+ epochCount: number;
789
+ /** Age of the DOCUMENT running in this tab (this page-load), as opposed
790
+ * to `presentFor`, which is the age of the tab and survives its
791
+ * reloads. Absent against an older server. */
792
+ epochAgeMs?: number;
793
+ visibility: 'visible' | 'hidden';
794
+ route: 'project' | 'no-project' | 'unknown';
795
+ /**
796
+ * WHAT KIND OF PAGE this tab is: the editor's own page, or a Code-OSS
797
+ * workbench window running it through the frame (docs/CODE-OSS.md §Boot,
798
+ * DESKTOP). A reader prints it because "a VS Code window that beats" and
799
+ * "a browser tab that beats" are the same health and different places to
800
+ * look when they are not. Absent against an older server.
801
+ */
802
+ surface?: 'editor' | 'vscode';
803
+ blessed: boolean;
804
+ channel: 'open' | 'down';
805
+ unresponsive: boolean;
806
+ commandListener?: 'ready' | 'not attached' | (string & {});
807
+ pageErrors?: string[];
808
+ /**
809
+ * The control-plane generation handshake for each page load currently
810
+ * associated with this physical tab. Anything other than `aligned` is a
811
+ * lifecycle contradiction or a handshake still in progress.
812
+ */
813
+ controlLifecycles?: Array<{
814
+ status: 'aligned' | 'awaiting-heartbeat' | 'unconfirmed' | 'mismatch';
815
+ serverGeneration8: string;
816
+ connectionGeneration8: string;
817
+ pageGeneration8: string;
818
+ clientId8: string;
819
+ }>;
820
+ census?: RecordedTabCensus | null;
821
+ censusAgeMs?: number | null;
822
+ }>;
823
+ /**
824
+ * TABS THAT ARE GONE — the tab table's short departure memory, same row
825
+ * shape as {@link tabs} above.
826
+ *
827
+ * `ended`, `closed` and `crashed` are verdicts about a tab that is no longer present,
828
+ * so this is the only array they can appear in, and they are exactly the two
829
+ * answers the product could not give before: a tab whose beats stopped left
830
+ * the table and took its explanation with it. Kept separate from `tabs`
831
+ * because `editorsConnected` counts that one — a dead row inside it would
832
+ * make a crashed tab read as a connected one. Bounded by age and count
833
+ * (a memory, not a log; the session journal is the archive). Absent against
834
+ * an older server.
835
+ */
836
+ departedTabs?: EditorState['tabs'];
837
+ /**
838
+ * The auto-open runaway guard. `stopped` means this session opened
839
+ * `attempts` tabs, none of them ever appeared in the table, and it has
840
+ * stopped trying — the browser, not the editor, is what to check.
841
+ */
842
+ tabAutoOpen?: { attempts: number; stopped: boolean };
843
+ /**
844
+ * Epoch ms of the last genuine HTML page load this server served — i.e.
845
+ * when the editor tab last did a FULL document load (first open, reload,
846
+ * self-heal). Server-observed (`editor-server.ts`'s response-finish
847
+ * middleware), never browser-reported. `null` until the first page load.
848
+ *
849
+ * P20 reads this against {@link publicAssets} to answer "did bytes under
850
+ * `public/` change since the running document loaded", which is when
851
+ * module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
852
+ * three's loader caches) can still be serving the OLD bytes.
853
+ */
854
+ lastIndexRequestAt?: number | null;
855
+ /**
856
+ * P20 — what the server has OBSERVED land under this project's `public/`
857
+ * during this server lifetime, from the same chokidar watcher that
858
+ * broadcasts `assets-changed`. `lastChangedAt` is `null` when nothing has
859
+ * changed since the server started. Absent against an older server.
860
+ *
861
+ * This is a divergence signal, not a cache verdict: nothing here knows
862
+ * whether the running page actually holds a stale copy of those bytes,
863
+ * only that they changed after it loaded.
864
+ */
865
+ publicAssets?: {
866
+ lastChangedAt: number | null;
867
+ lastPath: string | null;
868
+ changedCount: number;
869
+ };
870
+ /**
871
+ * Epoch ms when the server last received a state POST from a browser tab —
872
+ * i.e. the age of the cached snapshot above. Omitted if no tab has ever
873
+ * posted state this server lifetime (or since the last project switch,
874
+ * which clears the cache). Present regardless of `connected`, but only
875
+ * meaningful for interpreting staleness when `connected` is `false`.
876
+ */
877
+ stateUpdatedAt?: number;
878
+ /**
879
+ * Validate-on-change (#103): per-file validation status for every
880
+ * Project `src/**` source / `vgai.project.json` the dev
881
+ * server has seen
882
+ * change since it booted (or since the last project switch). Server-
883
+ * computed — unlike the rest of `EditorState`, it is NOT part of the
884
+ * browser-POSTed snapshot, so it is always current. A file appears here
885
+ * ONLY while it is currently failing; a clean write removes its entry
886
+ * (absence means "not known to be invalid", not "never checked"). Always
887
+ * present (`{}` when nothing is failing) so `vgai status` consumers can
888
+ * read it unconditionally.
889
+ */
890
+ projectValidation?: Record<string, { errors: string[]; at: number }>;
891
+ /**
892
+ * PD-13: the WARNING half of the same server-computed validation pass —
893
+ * authoring-convention findings (the R3F00x codes, OID surface conflicts)
894
+ * that do not make a file invalid but do make it unauthorable. Same shape,
895
+ * same lifecycle and same freshness guarantee as `projectValidation` above:
896
+ * whole-project (not just the open document), keyed by project-relative
897
+ * path, present only while the file currently warns, `{}` when clean.
898
+ *
899
+ * The server has sent this since the R3F authoring diagnostics landed; it
900
+ * was missing from this interface, so every typed consumer — `vgai status`
901
+ * included — could only reach it through a cast. Declared here so a caller
902
+ * that wants to react to authoring warnings can see they exist.
903
+ */
904
+ projectWarnings?: Record<string, { warnings: string[]; at: number }>;
905
+ /**
906
+ * PD-14: whether the SOURCE half of the validation pass above is running at
907
+ * all. `'active'` is the normal state; `'awaiting-src'` means the project
908
+ * has no `src/` directory yet, so nothing under `src/` is being validated —
909
+ * an empty `projectValidation`/`projectWarnings` says nothing about source
910
+ * files while this reads `'awaiting-src'`. It is not terminal: the watcher
911
+ * is armed on the not-yet-existing path and flips to `'active'` (running
912
+ * the boot-equivalent scan) the moment `src/` appears, with no restart.
913
+ * `'no-project'` when no project is open. Server-computed, like its
914
+ * neighbors above.
915
+ */
916
+ sourceValidation?: 'active' | 'awaiting-src' | 'no-project';
917
+ /**
918
+ * The compatibility verdict between this editor and the project it serves —
919
+ * `null` when they agree, otherwise the refusal the browser renders when it
920
+ * declines to activate the project, with its recovery guidance.
921
+ *
922
+ * Server-computed per read like its neighbors above. It is here because the
923
+ * gate was previously reported ONLY in the browser: an editor started on an
924
+ * incompatible project serves happily (it activates nothing), so the tab
925
+ * showed "This project is pinned to @volter/editor-project X, but this editor is
926
+ * running Y" while `vgai status` reported a connected session with empty
927
+ * validation and `vgai play` timed out into a retry message about the tab
928
+ * reloading. An agent drives this editor through the CLI, so a gate visible
929
+ * only in pixels is invisible by construction.
930
+ */
931
+ projectCompatibility?: {
932
+ error: string;
933
+ recovery?: { kind: string; title: string; guidance: string; command?: string };
934
+ } | null;
935
+ /**
936
+ * #124: the absolute path of the project this editor server currently has
937
+ * open — server-computed (never part of the browser-POSTed snapshot,
938
+ * exactly like `projectValidation` above), so it is always current. Added
939
+ * so a watcher/relay holding only a port number (e.g. an agent that
940
+ * printed a `vgai edit` URL earlier and lost track of which project it
941
+ * belongs to) can identify which project that port serves without also
942
+ * reading the `~/.vgai/editor-sessions.json` registry file. `null` when no
943
+ * project is open (the in-repo "no project selected" default server
944
+ * state — mirrors `/__editor/project`'s own `{ project: null }` shape).
945
+ */
946
+ projectRoot?: string | null;
947
+ /**
948
+ * #124: the open project's declared name, alongside `projectRoot` above
949
+ * (`vgai.project.json`'s `name`). `null` when no project is open, or the open
950
+ * project has no readable manifest name.
951
+ */
952
+ projectName?: string | null;
953
+ /**
954
+ * The open project's ADAPTER, resolved (ARCHITECTURE-CORE §The editor
955
+ * protocol). `source` names WHOSE declaration is running: `'project'` = the
956
+ * project's own `vgai.adapter.ts` supplied the binding table (and it always
957
+ * outranks the registry); `'registry'` = the HOST's in-tree ingest registry
958
+ * supplied it, matched on this project's ingest root id, with `modulePath`
959
+ * naming the repo file — a binding the project did not ship, stated rather
960
+ * than inferred; `'native'` = it declared none and got `nativeAdapter()` —
961
+ * the declared native default, not a silent fallback.
962
+ *
963
+ * `null`/absent means NOBODY HAS LOOKED YET (no project open, or the load
964
+ * has not finished), which is deliberately distinct from a loaded adapter
965
+ * whose `scenes.entries` is empty — that is a real, gradable answer. A
966
+ * non-null `error` means the project's own module did NOT load and the
967
+ * table below is the native default standing in, with the failure named.
968
+ *
969
+ * Structurally declared here rather than imported from
970
+ * `@volter/editor-project/adapter/adapter-module` because this interface is the WIRE
971
+ * contract: everything in it has already been through JSON.
972
+ */
973
+ adapter?: {
974
+ source: 'project' | 'registry' | 'native';
975
+ modulePath: string | null;
976
+ regions: {
977
+ id: string;
978
+ surface: string;
979
+ projector: string;
980
+ dialect: string | null;
981
+ anchors: string[];
982
+ }[];
983
+ scenes: {
984
+ default: string | null;
985
+ entries: {
986
+ id: string;
987
+ label: string;
988
+ /** OPEN (ARCHITECTURE-CORE §The project model, "Documents, not
989
+ * scenes"): `scene` and `prefab` are the first two kinds; a page,
990
+ * a model, a shot, a take are kinds the same way. */
991
+ kind: string;
992
+ region: string | null;
993
+ authorable: boolean;
994
+ reach: { kind: string; [field: string]: unknown };
995
+ source?: { path: string; export?: string };
996
+ finder?: string;
997
+ }[];
998
+ };
999
+ notes: string[];
1000
+ error: string | null;
1001
+ } | null;
1002
+ }
1003
+
1004
+ export type ViewPreset = 'top' | 'front' | 'right' | 'perspective';
1005
+ export type ShadingMode =
1006
+ | 'solid'
1007
+ | 'clay'
1008
+ | 'unlit'
1009
+ | 'wireframe'
1010
+ | 'matcap'
1011
+ | 'normals'
1012
+ | 'overdraw';
1013
+
1014
+ export type TransformMode = 'translate' | 'rotate' | 'scale';
1015
+ export type TransformSpace = 'world' | 'local';
1016
+
1017
+ /**
1018
+ * Stable editor-owned workspace documents that may appear in a shareable
1019
+ * view. One runtime list owns both URL parsing and the public id type.
1020
+ *
1021
+ * This SDK cannot import the editor — the dependency runs the other way — so
1022
+ * nothing links this list to the documents the editor actually registers, and
1023
+ * an id added there is silently unaddressable here (a view carrying it
1024
+ * round-trips to nothing). The link is asserted from the side that CAN see
1025
+ * both: `packages/editor/test/editor-view-address-space.test.ts`. Adding a
1026
+ * document id means adding it here too.
1027
+ */
1028
+ export const EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS = [
1029
+ 'workspace:scene',
1030
+ 'workspace:canvas-scene',
1031
+ 'workspace:game',
1032
+ 'workspace:3d-components',
1033
+ 'workspace:2d-components',
1034
+ 'workspace:ui-components',
1035
+ 'account',
1036
+ 'project-tools',
1037
+ ] as const;
1038
+
1039
+ export type EditorViewWorkspaceDocumentId = (typeof EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS)[number];
1040
+
1041
+ /**
1042
+ * The editor's OWN bottom-drawer instruments, as named in a shareable view.
1043
+ * One runtime list owns both URL parsing and the public id type — the same
1044
+ * discipline as {@link EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS}, and for the same
1045
+ * reason: a separate hand-written union and parser allowlist drift silently
1046
+ * (`behavior` was in the union and missing from the allowlist, so a view
1047
+ * carrying it round-tripped to nothing).
1048
+ *
1049
+ * This must equal the editor's live `BUILT_IN_WORKSPACE_UTILITIES`
1050
+ * (`packages/editor/src/workspace-core-utilities.ts`). It had drifted to five
1051
+ * of thirteen — every id from `generations` onward was unaddressable in a
1052
+ * shared view. As above, the SDK cannot import the editor to derive this, so
1053
+ * `packages/editor/test/editor-view-address-space.test.ts` asserts the
1054
+ * equality from the side that sees both.
1055
+ */
1056
+ export const EDITOR_VIEW_BUILT_IN_UTILITY_IDS = [
1057
+ 'animation',
1058
+ 'behavior',
1059
+ 'generations',
1060
+ 'console',
1061
+ 'light-explorer',
1062
+ 'story-actions',
1063
+ 'story-interactions',
1064
+ 'story-accessibility',
1065
+ ] as const;
1066
+
1067
+ export type EditorViewBuiltInUtilityId = (typeof EDITOR_VIEW_BUILT_IN_UTILITY_IDS)[number];
1068
+
1069
+ /** The prefix a PROJECT-contributed utility's id carries. The editor's tool
1070
+ * loader namespaces every `workspace.utility` contribution as
1071
+ * `tool:<contribution-id>`, exactly as a contributed center document is
1072
+ * addressed by `{ kind: 'tool', id }`. */
1073
+ export const EDITOR_VIEW_TOOL_UTILITY_PREFIX = 'tool:';
1074
+
1075
+ /**
1076
+ * Which bottom-drawer utility a view reveals: one of the editor's own
1077
+ * instruments, or a utility the OPEN PROJECT contributes.
1078
+ *
1079
+ * The project half cannot be an enum — which tabs exist depends entirely on
1080
+ * the game that is open — so the address space admits the namespace and the
1081
+ * EDITOR validates the id against its live utility registry when the view is
1082
+ * presented, refusing an unregistered one by naming what IS registered.
1083
+ */
1084
+ export type EditorViewUtility =
1085
+ | EditorViewBuiltInUtilityId
1086
+ | `${typeof EDITOR_VIEW_TOOL_UTILITY_PREFIX}${string}`;
1087
+
1088
+ /** Whether `value` is addressable as a view's utility. Shape only — existence
1089
+ * is the editor's answer at present-time, not the URL's. */
1090
+ export function isEditorViewUtility(value: string): value is EditorViewUtility {
1091
+ return (
1092
+ (EDITOR_VIEW_BUILT_IN_UTILITY_IDS as readonly string[]).includes(value) ||
1093
+ (value.startsWith(EDITOR_VIEW_TOOL_UTILITY_PREFIX) &&
1094
+ value.length > EDITOR_VIEW_TOOL_UTILITY_PREFIX.length)
1095
+ );
1096
+ }
1097
+
1098
+ /**
1099
+ * A durable, intentionally small projection of what an editor is presenting.
1100
+ * This is not workspace persistence: panel sizes, transient tool state, and
1101
+ * authored document contents remain outside the URL.
1102
+ */
1103
+ export type EditorViewDocument =
1104
+ | { kind: 'scene'; path: string }
1105
+ | { kind: 'asset'; path: string; entityId?: never; assetKind?: AssetKind }
1106
+ | { kind: 'asset'; entityId: string; path?: never; assetKind?: 'model' }
1107
+ | { kind: 'tool'; id: string }
1108
+ /** A document the adapter's table lists (a model, a page) — `id` is the
1109
+ * table entry's id — opened in the editor registered for its kind. */
1110
+ | { kind: 'document'; id: string }
1111
+ | { kind: 'world'; id: string }
1112
+ | { kind: 'story'; modulePath: string; storyName: string; mode?: 'preview' | 'docs' }
1113
+ | { kind: 'project-tool'; name: string }
1114
+ | { kind: 'generation'; id: string }
1115
+ | {
1116
+ kind: 'workspace';
1117
+ id: EditorViewWorkspaceDocumentId;
1118
+ /**
1119
+ * For a COMPONENT BOARD document: the portable story frame to open on
1120
+ * — a story id, or a unique CSF export name or label (an ambiguous or
1121
+ * unknown value refuses loudly, listing candidates). Emitted back by
1122
+ * the board's own presentation so a captured view round-trips. This is
1123
+ * the design ledger's "board story selector": without it only the
1124
+ * derived default story could ever be addressed.
1125
+ */
1126
+ story?: string;
1127
+ };
1128
+
1129
+ /**
1130
+ * The document ADDRESS KINDS an `EditorView` can carry — the same published,
1131
+ * finite vocabulary `EDITOR_VIEW_KEYS` is for the view's own keys, and for the
1132
+ * same reason: a kind this list does not hold must refuse by name rather than
1133
+ * fall off the end of a switch. `{kind: 'model', path}` used to present `ok`,
1134
+ * move nothing, and echo the caller's own mistake back in `view.doc=undefined`.
1135
+ *
1136
+ * A document KIND (`model`, `page`) is not one of these: it is the kind of a
1137
+ * row in the project's own document table, and its address is
1138
+ * `{kind: 'document', id}` — "a document opens in the editor registered for its
1139
+ * KIND" (ARCHITECTURE-CORE). The refusal says exactly that, reading the kinds
1140
+ * from the project's resolved table rather than from a list written here.
1141
+ */
1142
+ export const EDITOR_VIEW_DOCUMENT_KINDS = [
1143
+ 'scene',
1144
+ 'asset',
1145
+ 'tool',
1146
+ 'document',
1147
+ 'world',
1148
+ 'story',
1149
+ 'project-tool',
1150
+ 'generation',
1151
+ 'workspace',
1152
+ ] as const satisfies ReadonlyArray<EditorViewDocument['kind']>;
1153
+
1154
+ /** Narrow an arbitrary string to one of {@link EDITOR_VIEW_DOCUMENT_KINDS}. */
1155
+ export function isEditorViewDocumentKind(value: string): value is EditorViewDocument['kind'] {
1156
+ return (EDITOR_VIEW_DOCUMENT_KINDS as readonly string[]).includes(value);
1157
+ }
1158
+
1159
+ export interface EditorView {
1160
+ version: 1;
1161
+ /** The workspace (a layout: `model`, `sculpt`, `game`, …) the view is in —
1162
+ * the host's own or a package's `workspace.layout` contribution. Presenting
1163
+ * one the open project does not offer REFUSES and names the vocabulary, the
1164
+ * same answer as `set-workspace`; `currentView` always reports it. */
1165
+ workspace?: string;
1166
+ /** The style bundle (`classic`, `glass`, `blender`, …) the chrome wears —
1167
+ * the host's own or a package's `workspace.style` contribution. Presenting
1168
+ * one the open project does not offer REFUSES and names the vocabulary,
1169
+ * the same answer as `set-style`; `currentView` reports it when the four
1170
+ * appearance axes match a bundle, and omits it for a custom mix. */
1171
+ style?: string;
1172
+ /**
1173
+ * The keymap (`vgai`, `blender`, …) whose bindings the chrome is printing
1174
+ * and dispatching — the editor's own or a package's `workspace.keymap`
1175
+ * contribution, as the project's adapter declares it or its settings
1176
+ * override it. REPORTED, never presented: `currentView` always answers it,
1177
+ * and `present-view` WARNS on a keymap it was handed rather than switching,
1178
+ * because which bindings a project uses is that project's declaration and a
1179
+ * person's preference, not a property of a shared link.
1180
+ */
1181
+ keymap?: string;
1182
+ /**
1183
+ * The static PANEL the dock is focused on — `hierarchy`, `asset-library`,
1184
+ * `inspector`, and whatever else the editor's panel registry holds. Shape
1185
+ * only here, exactly like `workspace`: the vocabulary is the open editor's
1186
+ * registry, so presenting a key it does not hold REFUSES and names the ones
1187
+ * it does. `currentView` reports it while a panel (rather than a document or
1188
+ * a drawer utility) holds the dock's focus.
1189
+ */
1190
+ panel?: string;
1191
+ document?: EditorViewDocument;
1192
+ selection?: { ids: string[]; focus?: boolean };
1193
+ viewport?: {
1194
+ camera?: ViewPreset | 'isometric' | EditorCameraState;
1195
+ diagnostic?: ShadingMode | 'uv' | 'vertex-colors' | 'bounds' | 'skeleton';
1196
+ frame?: 'document' | 'selection';
1197
+ grid?: boolean;
1198
+ };
1199
+ utility?: EditorViewUtility;
1200
+ }
1201
+
1202
+ /**
1203
+ * THE ADDRESS SPACE, AS DATA — every key {@link EditorView} carries, so the
1204
+ * presenter can REFUSE one it does not (`editor-view-presentation.ts`).
1205
+ *
1206
+ * It lives beside the interface because that is the only placement where a
1207
+ * drift is visible in one screen: adding a field above without adding its key
1208
+ * here is the whole failure mode, and the row order is the interface's.
1209
+ *
1210
+ * WHY IT EXISTS (found live, 2026-09-19, unit 17's proof run): a view with the
1211
+ * document's `kind` spelled at the TOP level — `{version: 1, kind: 'story',
1212
+ * modulePath, storyName}` instead of `{version: 1, document: {kind: 'story',
1213
+ * …}}` — presented `ok`, changed nothing, and ECHOED THE BOGUS KEY BACK in
1214
+ * `PresentedEditorView.view`, so the caller read its own mistake as
1215
+ * confirmation. Three `present` proofs were recorded against it before
1216
+ * `currentView()` showed the document had never moved. Every NAMED field here
1217
+ * already refuses by name (`style`, `workspace`, `panel`); an unnamed one was
1218
+ * the silent hole, which is the case CLAUDE.md's "unknown input must REJECT
1219
+ * LOUDLY rather than be partially read" is about.
1220
+ */
1221
+ export const EDITOR_VIEW_KEYS = [
1222
+ 'version',
1223
+ 'workspace',
1224
+ 'style',
1225
+ 'keymap',
1226
+ 'panel',
1227
+ 'document',
1228
+ 'selection',
1229
+ 'viewport',
1230
+ 'utility',
1231
+ ] as const satisfies ReadonlyArray<keyof EditorView>;
1232
+
1233
+ export interface PresentedEditorView {
1234
+ view: EditorView;
1235
+ /** Shareable URL for the durable projection that was applied. */
1236
+ url: string;
1237
+ /** Honest degradations; an unsupported requested view is never silent. */
1238
+ warnings: string[];
1239
+ }
1240
+
1241
+ /**
1242
+ * How big a capture comes back.
1243
+ *
1244
+ * A NUMBER is a square of that size, and square stays the default — an
1245
+ * unstaged look at a model is a square question. `{width, height}` is for the
1246
+ * shaped answer: a video-aspect frame that needs no crop afterwards, which is
1247
+ * what the model/module lanes' looks are actually for.
1248
+ *
1249
+ * Both are bounded by the EDITOR's own ceiling — 64..1024 per side, plus a
1250
+ * total no larger than a 1024 square. That is the relay budget, not a taste:
1251
+ * the pixels cross the editor relay as base64 JSON, and 1024 is where even
1252
+ * incompressible RGBA still fits its 50 MB request limit (`asset-preview.ts`'s
1253
+ * `MIN_SIZE`/`MAX_SIZE`). For more picture, take more views, not bigger ones.
1254
+ */
1255
+ export type CaptureDimensions = number | { readonly width: number; readonly height: number };
1256
+
1257
+ /** The pixels of the active center document, with enough provenance for an
1258
+ * agent to prove which user-visible subject it captured. Editor chrome is
1259
+ * deliberately excluded. */
1260
+ /** A photograph of the editor PAGE — every panel as the person sees it
1261
+ * (`capture-editor-chrome`; the door that lets a skin, a workspace or a
1262
+ * contributed panel be judged sighted through the product). */
1263
+ export interface EditorChromeCapture extends ViewportCapture {
1264
+ view: EditorView;
1265
+ /** The frame's own size, in OUTPUT pixels. */
1266
+ size: { width: number; height: number };
1267
+ /** Output pixels per CSS pixel — what one pixel of {@link size} is. */
1268
+ scale: number;
1269
+ layers: { canvases: number; domOverlays: number };
1270
+ flatness?: { degenerate: boolean; warning?: string };
1271
+ }
1272
+
1273
+ /** How many output pixels one CSS pixel of the editor page becomes; defaults
1274
+ * to the page's own `devicePixelRatio`, at most 4. */
1275
+ export interface EditorChromeCaptureOptions {
1276
+ readonly scale?: number;
1277
+ }
1278
+
1279
+ export interface ActiveDocumentCapture extends ViewportCapture {
1280
+ document: {
1281
+ id: string;
1282
+ title: string;
1283
+ kind: string;
1284
+ sourcePath?: string;
1285
+ rootId?: string;
1286
+ };
1287
+ view: EditorView;
1288
+ source: 'scene-viewport' | 'game-composite' | 'object3d-document' | 'document-composite';
1289
+ layers?: { canvases: number; domOverlays: number };
1290
+ }
1291
+
1292
+ /** Template ids accepted by the editor's create-project endpoint. */
1293
+ export type ProjectTemplate = 'default' | '2d' | 'react' | 'example';
1294
+
1295
+ export interface ProjectInfo {
1296
+ path: string;
1297
+ config: { name: string; [key: string]: unknown };
1298
+ }
1299
+
1300
+ export interface RecentProject {
1301
+ name: string;
1302
+ path: string;
1303
+ lastOpened: string;
1304
+ thumbnail?: string;
1305
+ }
1306
+
1307
+ export type ProjectToolOutcome =
1308
+ | { ok: true; data: unknown; generation?: GenerationJob; generationWarning?: string }
1309
+ | {
1310
+ ok: false;
1311
+ error: {
1312
+ code: string;
1313
+ message: string;
1314
+ data?: unknown;
1315
+ issues?: Array<{ path?: PropertyKey[]; message?: string; [key: string]: unknown }>;
1316
+ };
1317
+ };
1318
+
1319
+ import type { GenerationJob } from '@volter/editor-sdk/generations';
1320
+ import type { RecordedTabCensus } from '@volter/editor-sdk/tab-census';
1321
+
1322
+ export type {
1323
+ ProjectToolCatalog,
1324
+ ProjectToolCatalogEntry,
1325
+ ProjectToolContribution,
1326
+ ToolContributionPoint,
1327
+ } from '@volter/editor-sdk/project-tool-catalog';
1328
+
1329
+ // ---------------------------------------------------------------------------
1330
+ // Inspection — the serialized inspection subject (`EditorClient.inspect`)
1331
+ // ---------------------------------------------------------------------------
1332
+ //
1333
+ // The wire mirror of the editor's own `SerializedInspectionSubject`
1334
+ // (`packages/editor/src/inspection/serialize.ts`, which owns the contract and
1335
+ // carries the reasoning). `command-listener.ts` annotates its `inspect`
1336
+ // payload with this type, so `tsc` checks the two sides against each other on
1337
+ // every build rather than letting them drift silently.
1338
+
1339
+ /** Where the inspector's subject lives; `asset-lab` is an open asset
1340
+ * document — the three paradigm scoped to a subtree, inspected in the same
1341
+ * box as the scene. */
1342
+ export type InspectionSurface = 'three' | 'canvas' | 'dom' | 'asset-lab';
1343
+
1344
+ /** Which LAYOUT the one inspector box is in: the compact box over the
1345
+ * viewport, the same sections stacked in the dock column, or that column
1346
+ * with the sections tabbed behind a vertical rail (`properties`). */
1347
+ export type InspectionPresentationKind = 'card' | 'column' | 'properties';
1348
+
1349
+ /** One inspected field: a stable scriptable `path` and the value at it. */
1350
+ export interface InspectedField {
1351
+ path: string;
1352
+ label: string;
1353
+ type: 'string' | 'number' | 'boolean' | 'vec3' | 'color' | 'enum' | 'asset' | 'json';
1354
+ /** Absent when nothing is at that address, or when `mixed` is set. */
1355
+ value?: unknown;
1356
+ /** The inspected subjects disagree about this field. */
1357
+ mixed?: true;
1358
+ /** The value shown is the declared default — the document does not carry it. */
1359
+ defaulted?: boolean;
1360
+ readonly?: boolean;
1361
+ /** The same reason shown by the Inspector and returned by a refused write. */
1362
+ readonlyReason?: string;
1363
+ resettable?: boolean;
1364
+ revertsTo?: string;
1365
+ group?: string;
1366
+ options?: readonly unknown[];
1367
+ }
1368
+
1369
+ /** A section's content. Custom RENDERING remains opaque — the wire never
1370
+ * introspects React — while any ordinary descriptor channel that chrome owns
1371
+ * IS a `fields` body here, including its write-refusal reasons: nothing is
1372
+ * rendered on this wire, so naming chrome a reader cannot see while hiding
1373
+ * the fields it can use is the wrong half. (Measured: the whole react/DOM
1374
+ * lane draws its own widgets over the style descriptors, so every one of its
1375
+ * sections reported `custom` and `inspect()` enumerated zero fields for a DOM
1376
+ * element.) The two opaque kinds are distinguished because "this subject has
1377
+ * a live preview" is a real fact about it: `custom` is a contributed block
1378
+ * with no descriptor channel of its own, `preview` is the subject's own
1379
+ * square view of itself.
1380
+ *
1381
+ * A custom body carries `data` when it can say what it DISPLAYS — the keys
1382
+ * are the section's own vocabulary, not a shared schema. The shipped case is
1383
+ * `transform`: `{position, rotation, scale}`, three numbers each, with
1384
+ * rotation in Euler XYZ DEGREES exactly as the rotation inputs show it (the
1385
+ * quaternion behind them is not on this wire). */
1386
+ export type InspectedSectionBody =
1387
+ | { kind: 'fields'; fields: readonly InspectedField[] }
1388
+ | {
1389
+ kind: 'custom';
1390
+ id: string;
1391
+ title: string;
1392
+ data?: Record<string, unknown>;
1393
+ }
1394
+ | { kind: 'preview'; id: string; title: string };
1395
+
1396
+ export interface InspectedSection {
1397
+ id: string;
1398
+ title: string;
1399
+ order: number;
1400
+ description?: string;
1401
+ body: InspectedSectionBody;
1402
+ }
1403
+
1404
+ /** A verb on the subject (the visibility eye, the Asset Editor jump). */
1405
+ export interface InspectedAction {
1406
+ id: string;
1407
+ title: string;
1408
+ label?: string;
1409
+ /** Toggle state, for verbs that have one — how visibility is read. */
1410
+ pressed?: boolean;
1411
+ disabled?: boolean;
1412
+ }
1413
+
1414
+ export interface InspectedSubjectLink {
1415
+ id: string;
1416
+ title: string;
1417
+ }
1418
+
1419
+ /** The whole inspection subject, as data — what a human sees in the
1420
+ * inspector, for an agent (`vgai eval 'editor.inspect()'`). */
1421
+ export interface InspectedSubject {
1422
+ id: string;
1423
+ title: string;
1424
+ kindLabel?: string;
1425
+ /** The quiet line a subject with nothing to edit explains itself with. */
1426
+ hint?: string;
1427
+ presentation: {
1428
+ preferred: InspectionPresentationKind;
1429
+ resolved?: InspectionPresentationKind;
1430
+ surface?: InspectionSurface;
1431
+ };
1432
+ quickActions: readonly InspectedAction[];
1433
+ /** Agent-visible counterparts of the inspector's related-document buttons. */
1434
+ related: readonly InspectedSubjectLink[];
1435
+ /** Already in display order. */
1436
+ sections: readonly InspectedSection[];
1437
+ }
1438
+
1439
+ /** NOTHING is being inspected: the inspector is unmounted, so the honest
1440
+ * answer is not an empty subject but the absence of one. Distinct from a
1441
+ * missing reply, which means nobody answered
1442
+ * (`editor.inspection.get`'s `INSPECTION_UNAVAILABLE`). */
1443
+ export interface InspectedNothing {
1444
+ none: true;
1445
+ }
1446
+
1447
+ /** What `editor.inspect()` answers: the subject showing, or nothing at all.
1448
+ * Narrow with `'none' in result`. */
1449
+ export type InspectedInspection = InspectedSubject | InspectedNothing;
1450
+
1451
+ /**
1452
+ * WHERE THIS WRITE WENT — carried by every `editor.setField()` ack.
1453
+ *
1454
+ * A write with no persistence route still succeeds: it lands on the live
1455
+ * object and journals live-only, exactly as designed. Without this the ack was
1456
+ * indistinguishable from one that reached a file, so a caller could only find
1457
+ * out by diffing the tree — and a healthy consent-off session read as a silent
1458
+ * no-op. `persisted: false` with `destination: "live-only (not saved)"` is the
1459
+ * honest floor: never silence, and never a fabricated file name.
1460
+ *
1461
+ * IT IS PER-EDIT, produced by the component that performed the write and
1462
+ * returned through the editor's persistence pipe — never a property of the
1463
+ * session, the surface or the adapter. A composite holding a live-only three
1464
+ * root beside a source-backed DOM root has no single true answer, and the
1465
+ * adapter-wide one it used to give was the DOM root's (measured on the
1466
+ * vendored racing game: a three-root edit acked `persisted: true` against a
1467
+ * file it never touched). The ack is also AWAITED: it resolves after the bytes
1468
+ * have landed, so a caller holding it can diff the tree immediately.
1469
+ */
1470
+ export interface InspectedWriteDestination {
1471
+ /** Where THIS edit's bytes landed, in the writer's own words — a source
1472
+ * file, the game's own JSX, or a named non-target like
1473
+ * `"live-only (not saved)"`. */
1474
+ destination: string;
1475
+ /** Whether a byte actually moved for THIS edit. */
1476
+ persisted: boolean;
1477
+ }
1478
+
1479
+ /** What `editor.setField()` answers: the subject after the write, plus where
1480
+ * the write went. */
1481
+ export interface InspectedFieldWrite {
1482
+ subject: InspectedInspection;
1483
+ write: InspectedWriteDestination;
1484
+ }
1485
+
1486
+ /**
1487
+ * The STRUCTURE verbs — the hierarchy context menu's own ops, addressable.
1488
+ *
1489
+ * The names are the menu's, not the provider's, because the menu is the
1490
+ * surface a human uses and an agent is doing the same thing through a
1491
+ * different door (`delete` covers the provider's `remove`/`removeMany`: a
1492
+ * multi-id delete is ONE undoable op when the adapter can batch it).
1493
+ */
1494
+ export type StructureOp =
1495
+ | 'create'
1496
+ | 'delete'
1497
+ | 'duplicate'
1498
+ | 'reparent'
1499
+ | 'reorder'
1500
+ | 'wrap'
1501
+ | 'unwrap'
1502
+ | 'group'
1503
+ | 'ungroup'
1504
+ | 'copy'
1505
+ | 'cut'
1506
+ | 'paste';
1507
+
1508
+ /** Arguments for one {@link StructureOp}. Everything is optional: `id`/`ids`
1509
+ * default to the current selection, the menu's own subject. */
1510
+ export interface StructureOpOptions {
1511
+ id?: string;
1512
+ ids?: readonly string[];
1513
+ /** `create`: which creatable kind (see the adapter's `creatableKinds`). */
1514
+ kind?: string;
1515
+ /** `create`/`reparent`/`paste`: the destination; `null`/absent = document root. */
1516
+ parentId?: string;
1517
+ /** `reorder`: move immediately before this sibling; absent = to the end. */
1518
+ beforeSiblingId?: string;
1519
+ /** `wrap`: the wrapper tag; absent = the adapter's own default. */
1520
+ tag?: string;
1521
+ }
1522
+
1523
+ /** What one structure op answers. `write` is the same per-edit ack
1524
+ * `editor.setField()` carries — `persisted: false` means the tree moved and
1525
+ * no byte did. `id`/`ids` name what the op produced, when it produces one. */
1526
+ export interface StructureOpResult {
1527
+ id?: string | null;
1528
+ ids?: readonly string[];
1529
+ write?: InspectedWriteDestination;
1530
+ /** `copy` only: whether the clipboard actually took the payload. */
1531
+ copied?: boolean;
1532
+ }
1533
+
1534
+ // ---------------------------------------------------------------- hierarchy
1535
+ //
1536
+ // The wire mirror of the editor's own `SerializedHierarchyPanel`
1537
+ // (`packages/editor/src/hierarchy-panel-view.ts`, which owns the contract and
1538
+ // carries the reasoning). `command-listener.ts` annotates its `hierarchy`
1539
+ // payload with this type, so `tsc` checks the two sides against each other on
1540
+ // every build.
1541
+ //
1542
+ // This is NOT `EditorState.entities`: that facet is the raw adapter tree, with
1543
+ // no marks, no internals folding and no document promotion. This one is what
1544
+ // the hierarchy PANEL rendered — the rows a human is looking at.
1545
+
1546
+ /** One row of the hierarchy panel, as data. */
1547
+ export interface InspectedHierarchyRow {
1548
+ id: string;
1549
+ label: string;
1550
+ /** The dim type suffix the row prints (`Coin1 ·Coin`). */
1551
+ typeLabel?: string;
1552
+ role?: string;
1553
+ depth: number;
1554
+ /** Children the row's view has — what opening the caret reveals. Folded
1555
+ * implementation children are NOT counted here. */
1556
+ childCount: number;
1557
+ /** Children folded away as implementation, behind "Reveal Internals". */
1558
+ internalChildCount: number;
1559
+ /** Whether the panel renders a disclosure control. A row with children of
1560
+ * any kind and `expandable: false` is a subtree the UI cannot reach. */
1561
+ expandable: boolean;
1562
+ expanded?: boolean;
1563
+ internal?: true;
1564
+ componentRoot?: true;
1565
+ /** A synthetic "… N more" cap stub rather than a real node. */
1566
+ more?: { hidden: number };
1567
+ children?: readonly InspectedHierarchyRow[];
1568
+ }
1569
+
1570
+ /** What `editor.hierarchy()` answers. */
1571
+ export interface InspectedHierarchy {
1572
+ rowCount: number;
1573
+ /** The slice in the DOM; a smaller span than `rowCount` means the rest is
1574
+ * scrolled out, not absent. */
1575
+ window: { start: number; end: number };
1576
+ search?: string;
1577
+ scopeId?: string;
1578
+ playState: string;
1579
+ activeViewportTab: string;
1580
+ roots: readonly InspectedHierarchyRow[];
1581
+ }