@volter/editor-react 0.5.211 → 0.5.212

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.
@@ -1,74 +1,4 @@
1
- /**
2
- * Vite plugin: live OID instrumentation + source-write endpoints (C1/C2/C7/C8).
3
- *
4
- * The visual-edit "trick" made live for hand-authored React UI components: at dev
5
- * time it stamps `data-oid` on every JSX element in files matched by `include` (a
6
- * predicate, so the rest of the editor build is untouched), builds an OID→source
7
- * index, and serves:
8
- * GET /__ui-source/index → the whole OID index
9
- * POST /__ui-source/write {oid,prop,value} → surgical inline-style edit to source
10
- * POST /__ui-source/struct {oid,op} → structural source edit (delete)
11
- * Edits run the SAME `writer.ts` used in unit tests, server-side, then Vite HMR
12
- * reloads. `data-oid` exists only in transform output, never on disk.
13
- *
14
- * `include` widening (T6.2 slice 2): the original
15
- * scope was ONLY `ui-editor/editable-components/*.tsx` (the visual-edit fixture dir,
16
- * for `UIAuthoringAdapter`/`SourceEditPanel`). A react WORLD's entry graph is a real
17
- * project's `.tsx` source — the mounted template project (`packages/game-editor/template/
18
- * src/**`) or an externally-scaffolded project directory reached via the SAME
19
- * dev-server `fs.allow` pipeline `server/dev.ts` already builds for project scripts
20
- * (T3.3) — so `ReactRootAuthoringAdapter` needs those files instrumented too.
21
- * `defaultProjectScopeInclude` widens to PROJECT scope, not repo-global: any `.tsx`
22
- * file outside `node_modules`, EXCEPT the Volter tooling/engine source trees this repo
23
- * itself is built from (`packages/engine/src`, `packages/create-volter-project`,
24
- * `packages/volter-cli`, `packages/sdk`, and `packages/editor/src` generally —
25
- * carving OUT `ui-editor/editable-components` so the existing UI-edit-mode surface
26
- * keeps working unchanged). This does NOT depend on which project happens to be
27
- * open — `fs.allow` already bounds what Vite can even reach, and this predicate
28
- * additionally keeps the Volter app's OWN react source un-instrumented.
29
- *
30
- * Vendored trees: a vendored GAME's source is STAMPED; every other `/vendor/`
31
- * tree is not. `data-oid` stamping is what makes a `.tsx` file addressable —
32
- * and, through `/__ui-source/write`, writable — so this predicate is where the
33
- * question "may a vendored game be authored?" is actually decided.
34
- *
35
- * It used to answer no, for every path under a `/vendor/` segment, under the
36
- * never-modify-vendored-source rule. The owner's code-or-data ruling
37
- * superseded that rule (ARCHITECTURE-CORE §Editor "Every edit edits the game's
38
- * own CODE or DATA", §Rules "there is no unwritable base"): ownership decides
39
- * the RECORDER, not whether the edit may happen. A user's folder records in
40
- * their git; a repo-vendored copy records in its own `UPSTREAM.lock`, written
41
- * in the SAME gesture as the file by `server/vendored-lock-recorder.ts` and
42
- * routed through {@link writeEditableSource} below. `verify-unaltered.mjs`'s
43
- * bar was never zero diff — it is zero UNRECORDED diff — so it stays green
44
- * with an authored edit present, as a patch that reverse-applies to upstream.
45
- *
46
- * What stays excluded is everything under `/vendor/` that is a DEPENDENCY
47
- * rather than a game: `packages/threejs-runtime/vendor/realism-effects` (a vendored
48
- * library) and the import analyzers' `vendor/` API dumps. See
49
- * {@link VENDORED_GAME_SRC_RE} for the measured characterization behind that
50
- * split.
51
- *
52
- * D-Y4 (wave 2, slice S3) —
53
- * the write-back ban becomes IDENTITY-scoped, not just location-scoped: the
54
- * `/vendor/` regex above only protects the repo's OWN vendored trees, but
55
- * wave 2 lets an `ingest-react` world live in ANY external, user-owned
56
- * folder (`volter-game-editor edit <folder>`) — an ingested-as-is foreign game there would
57
- * otherwise be silently stamped/writable, reopening the never-modify-game-
58
- * source rule by location rather than by identity. `nearestManifestExcludesIngestReact`
59
- * walks up from a candidate `.tsx`'s own directory to the NEAREST
60
- * `volter.project.json`; if that manifest declares an `{ ingest }` react world,
61
- * the whole manifest folder's subtree is excluded — exact, not coarse:
62
- * `ingest-react` is single-world by construction (`binding-resolver.ts`'s
63
- * `MULTI_WORLD_ALLOWED_IDENTITIES` exclusion), so "the manifest folder" IS
64
- * the foreign game's whole tree, and D-N8 ships no authoring for it anyway —
65
- * excluding it from stamping loses nothing. Cached per directory (hit AND
66
- * miss, R-Y3) so a project with no manifest anywhere up the tree pays the
67
- * walk cost once, ever. Malformed JSON at the manifest path is treated as
68
- * "no exclusion" (never crash the transform hook over a broken file); a
69
- * genuine fs error OTHER than "not found" (e.g. a permission error) is not
70
- * swallowed — it propagates, same honesty bar the rest of this file holds.
71
- */
1
+ /** Vite source authoring for project React components. JSX receives data-oid attributes only in transform output; the source index and write routes apply recorded edits to the project files. Tooling and dependency source stay outside this scope. */
72
2
 
73
3
  import { createHash } from 'node:crypto';
74
4
  import { createRequire } from 'node:module';
@@ -82,7 +12,6 @@ import {
82
12
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
83
13
  import { Worker } from 'node:worker_threads';
84
14
  import type { Plugin, ViteDevServer } from 'vite';
85
- import { resolveManifestPath } from '@volter/project/manifest/locate';
86
15
  import {
87
16
  type ProjectServingServices,
88
17
  SOURCE_WRITE_ROUTES_PLUGIN,
@@ -94,7 +23,6 @@ import {
94
23
  type OidEntry,
95
24
  OidStore,
96
25
  oidAttributeForSurface,
97
- type R3fComponentContract,
98
26
  transformSource,
99
27
  } from '../src/source/oid-transform';
100
28
  import { planDeleteStory, planRenameStory, planSaveStory } from '../src/source/plan-csf-story';
@@ -107,15 +35,6 @@ import {
107
35
  planSourceEdit,
108
36
  sourceEditFiles,
109
37
  } from '../src/source/plan-source-edit';
110
- import {
111
- type EntryDiagnosticSelector,
112
- fileDiagnosticJoin,
113
- } from '../src/source/r3f-diagnostic-index';
114
- import {
115
- importedR3fContracts,
116
- r3fAuthoringDiagnostics,
117
- visibleR3fContracts,
118
- } from '../src/source/r3f-project-contracts';
119
38
  import type { SourceEditRequest } from '../src/source/source-edit-request';
120
39
  import {
121
40
  detectUtilityClassSupport,
@@ -127,11 +46,8 @@ import {
127
46
  addClassNameToken,
128
47
  collectLiteralInlineStyles,
129
48
  cssCamelToKebab,
130
- deleteElements,
131
49
  editTextContent,
132
50
  findTagEnd,
133
- getEditableText,
134
- groupSiblingElements,
135
51
  removeClassNameToken,
136
52
  removeInlineStyle,
137
53
  removePropAttribute,
@@ -140,14 +56,8 @@ import {
140
56
  writePropChange,
141
57
  } from '../src/source/writer';
142
58
 
143
- // `applyStyleWriteRequest` and `oidAttributeForSurface` live in browser-safe
144
- // modules (`plan-source-edit.ts` / `oid-transform.ts`), because a PAGE reaches
145
- // the same transform; re-exported here so this file stays the stable import
146
- // site for both.
147
- export { applyStyleWriteRequest, oidAttributeForSurface };
148
-
149
59
  const NODE_MODULES_RE = /\/node_modules\//;
150
- const TOOLING_SRC_RE = /\/packages\/(engine\/src|create-volter-project|volter-cli)\//;
60
+ const TOOLING_SRC_RE = /\/packages\/(blender-engine|cyclotron|live|play|project)\/(src|node)\//;
151
61
  /** The SDK's own folder, where a workspace keeps its source: tooling, never a game's UI. Named by
152
62
  * where `@volter/sdk` resolves from here (its entry module is `src/index.ts`), with forward
153
63
  * slashes; `null` when it does not resolve. A project's own `packages/sdk` is not this folder. */
@@ -171,101 +81,22 @@ const foldCase = (file: string): string => (process.platform === 'win32' ? file.
171
81
  function isToolingSource(clean: string): boolean {
172
82
  return TOOLING_SRC_RE.test(clean) || (SDK_ROOT !== null && foldCase(clean).startsWith(foldCase(SDK_ROOT)));
173
83
  }
174
- const EDITOR_SRC_RE = /\/packages\/editor\/src\//;
175
- const EDITABLE_COMPONENTS_RE = /\/ui-editor\/editable-components\/.*\.tsx$/;
176
- /** Track N, D-N4 item 2 — see the module doc comment's "Vendored-tree exclusion". */
84
+ const EDITOR_SRC_RE = /\/packages\/editor-(core|blender|threejs|react|ui)\/(src|contributions)\//;
85
+ /** Dependency source inside vendor trees is excluded from authoring. */
177
86
  const VENDOR_RE = /\/vendor\//;
178
- /**
179
- * A vendored GAME's own source (`…/vendor/games/<id>/…`) — the ONE tree under
180
- * a `/vendor/` segment that is a game rather than a dependency.
181
- *
182
- * The distinction is what {@link defaultProjectScopeInclude} needs and the
183
- * blanket `VENDOR_RE` could not express. Characterized before it was carved,
184
- * because "what else does this regex protect?" is the question a scope change
185
- * has to answer: under every `/vendor/` root in this repo, the only `.tsx` at
186
- * all is `vendor/games/racing-game`'s 35 files. The rest is
187
- * `packages/threejs-runtime/vendor/realism-effects` (a vendored LIBRARY — built `dist`
188
- * JS and shaders, a dependency we happen to ship in-tree) and the two import
189
- * analyzers' `vendor/` API dumps (`packages/gd-analyze`,
190
- * `packages/rbx-analyze` — JSON only). None of those is a game, none is
191
- * authorable, and all of them stay excluded here.
192
- *
193
- * The Godot lane is ARCHIVED off main — `git fetch origin archive/godot-lane`, tag `archive/godot-lane-2026-09-19`.
194
- */
87
+ /** Project game source may be authored inside a vendor tree; dependency source is excluded. */
195
88
  const VENDORED_GAME_SRC_RE = /\/vendor\/games\/[^/]+\//;
196
89
 
197
- /** True if a parsed `volter.project.json` body declares at least one
198
- * React root with an `{ ingest }` adapter — the `{ ingest }` shape alone is
199
- * the answer, because every ingest-react root is D-N8 no-authoring.
200
- * Defensive against
201
- * any shape (never trusts the manifest is even an object) — this is a
202
- * best-effort identity probe for a WRITE-BACK BAN, not manifest validation
203
- * (the real Zod schema/loader is `@volter/project/manifest/load`, not reachable
204
- * from this vite-config-time, dependency-light file by design). */
205
- function manifestDeclaresIngestReactWorld(parsed: unknown): boolean {
206
- if (!parsed || typeof parsed !== 'object') return false;
207
- const roots = (parsed as { roots?: unknown }).roots;
208
- if (!Array.isArray(roots)) return false;
209
- return roots.some((entry) => {
210
- if (!entry || typeof entry !== 'object') return false;
211
- const root = entry as { adapter?: unknown };
212
- const { adapter } = root;
213
- return (
214
- !!adapter &&
215
- typeof adapter === 'object' &&
216
- (adapter as { surface?: unknown }).surface === 'dom' &&
217
- 'ingest' in adapter
218
- );
219
- });
220
- }
221
-
222
- /** Cache: absolute directory -> whether ITS OWN ancestor walk (starting AT
223
- * this directory) hit an ingest-react-declaring manifest. Cached both ways
224
- * (hit and miss, R-Y3) — see the module doc comment's D-Y4 section. */
225
- const manifestAncestorExclusionCache = new Map<string, boolean>();
226
-
227
- function nearestManifestExcludesIngestReact(dir: string): boolean {
228
- const cached = manifestAncestorExclusionCache.get(dir);
229
- if (cached !== undefined) return cached;
230
-
231
- const manifestPath = resolveManifestPath(dir);
232
- let raw: string | undefined;
233
- try {
234
- raw = readFileSync(manifestPath, 'utf8');
235
- } catch (err) {
236
- // ENOENT (no manifest at this level) -> keep walking up. Any OTHER fs
237
- // error (e.g. EACCES) is a real environment problem, not "no manifest
238
- // here" — never silently swallowed.
239
- if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
240
- }
241
-
242
- let result: boolean;
243
- if (raw === undefined) {
244
- const parent = dirname(dir);
245
- result = parent === dir ? false : nearestManifestExcludesIngestReact(parent);
246
- } else {
247
- try {
248
- result = manifestDeclaresIngestReactWorld(JSON.parse(raw));
249
- } catch {
250
- // Malformed JSON: honest "no exclusion" (stamping stays on) — never
251
- // crash the transform hook over a broken manifest file.
252
- result = false;
253
- }
254
- }
255
- manifestAncestorExclusionCache.set(dir, result);
256
- return result;
257
- }
258
-
259
90
  /**
260
- * Cap 2 (React visual-edit parity): the scope guard for the `/__ui-source/css` endpoint.
91
+ * Scope guard for the `/__ui-source/css` endpoint.
261
92
  * A CSS file path (from a stylesheet's `data-vite-dev-id`) is editable iff it is a
262
- * first-party PROJECT `.css` file — same carve-outs as `defaultProjectScopeInclude`
263
- * (never node_modules, vendored trees, the Volter tooling/engine source, or the editor's
264
- * own source). Paths are normalized to forward slashes so a Windows dev-id matches. This
93
+ * first-party PROJECT `.css` file outside node_modules, every vendor tree, the Volter
94
+ * tooling/engine source and the editor's own source. JSX stamping separately permits project
95
+ * game source in vendor trees. Paths are normalized to forward slashes so a Windows dev-id matches. This
265
96
  * is the CSS analogue of the `.tsx` stamping scope: an out-of-scope path must not become
266
97
  * writable through a client-supplied `file`.
267
98
  */
268
- export function isEditableCssFile(id: string): boolean {
99
+ function isEditableCssFile(id: string): boolean {
269
100
  const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
270
101
  if (!clean.endsWith('.css')) return false;
271
102
  if (NODE_MODULES_RE.test(clean)) return false;
@@ -275,11 +106,7 @@ export function isEditableCssFile(id: string): boolean {
275
106
  return true;
276
107
  }
277
108
 
278
- /** D-1 (Phase D, spec27 §2): sha256 hex digest — the checksum guard the
279
- * `/__ui-source/restore` endpoint (below) uses to refuse a stale write. Only
280
- * ever computed SERVER-SIDE: the client (`ReactRootAuthoringAdapter`) never
281
- * hashes anything itself, it only echoes back a digest this server already
282
- * handed it in a prior `writeStruct`/`restoreSource` response. */
109
+ /** Server-computed SHA-256 for guarded whole-file source writes. */
283
110
  function sha256(text: string): string {
284
111
  return createHash('sha256').update(text, 'utf8').digest('hex');
285
112
  }
@@ -294,17 +121,9 @@ function sourceResourcePath(file: string, projectRoot: string): string {
294
121
  return `.volter/external-source/${identity}/${basename(file)}`;
295
122
  }
296
123
 
297
- /**
298
- * Utility-class support is a property of the PACKAGE that owns the edited file,
299
- * not of the editor session — an ingested game's HUD lives in the game's own
300
- * tree, with its own `package.json` and its own build, and it is THAT build
301
- * that would have to compile a `bg-[#059669]`. So the probe walks up from the
302
- * file to the nearest enclosing `package.json` (falling back to the served
303
- * project root) and reads the evidence `utility-class-support.ts` names.
304
- *
305
- * Cached per resolved package directory, like `propResolvers` above: the walk
306
- * and the reads happen once per package per dev-server lifetime.
307
- */
124
+ /** Cache utility-class evidence by the package owning the edited file. The
125
+ * nearest package.json, or the served project root, determines which build
126
+ * must compile its authored classes. */
308
127
  const utilityClassSupport = new Map<string, UtilityClassSupport>();
309
128
 
310
129
  /** The nearest ancestor of `file` holding a `package.json`, else `fallback`. */
@@ -383,109 +202,28 @@ function declaredPropsOf(
383
202
  return propsByTag.get(key);
384
203
  }
385
204
 
386
- /** Everything one project file contributes to an index read, resolved from its
387
- * CURRENT bytes exactly once per read. */
388
- interface IndexFileAnalysis {
389
- /** Component contracts visible in this module — `null` for a file this
390
- * server does not stamp as R3F (or could not read). */
391
- readonly contracts: ReadonlyMap<string, R3fComponentContract> | null;
392
- /** H5 — which of this file's authorability diagnostics pertain to a given
393
- * entry of this file. Inert for a non-R3F/unreadable file. */
394
- readonly selectDiagnostics: EntryDiagnosticSelector;
395
- }
396
-
397
- const INERT_FILE_ANALYSIS: IndexFileAnalysis = {
398
- contracts: null,
399
- selectDiagnostics: () => undefined,
400
- };
401
-
402
- /** The index grouped by file, each bucket keyed by oid — the shape
403
- * `fileDiagnosticJoin` needs so an entry's `parentOid` resolves. */
404
- function indexEntriesByFile(store: OidStore): Map<string, Map<string, OidEntry>> {
405
- const byFile = new Map<string, Map<string, OidEntry>>();
406
- for (const [oid, entry] of store.index) {
407
- const bucket = byFile.get(entry.file);
408
- if (bucket) bucket.set(oid, entry);
409
- else byFile.set(entry.file, new Map([[oid, entry]]));
410
- }
411
- return byFile;
412
- }
413
-
414
- /**
415
- * Read one file's current bytes and derive everything an index read wants from
416
- * it. Unreadable (deleted/renamed since it was transformed) or not stamped as
417
- * R3F ⇒ the inert analysis: its entries ship exactly as recorded, which is a
418
- * degradation, never a failure of the whole index read.
419
- *
420
- * COST, and why it went DOWN rather than up when H5 landed: this runs at most
421
- * once per file per index read (the caller memoizes), whereas the previous
422
- * shape re-read and re-surface-resolved a file for EVERY capitalized-tag entry
423
- * whose surface turned out not to be `userData-oid`. H5 adds one
424
- * `r3fAuthoringDiagnostics` pass per R3F file — the same order of work as the
425
- * `visibleR3fContracts` pass beside it, and it early-returns for any file
426
- * without an R3F reconciler import.
427
- */
428
- function analyzeIndexFile(
429
- file: string,
430
- fileEntries: ReadonlyMap<string, OidEntry>,
431
- moduleGraph: unknown,
432
- ): IndexFileAnalysis {
205
+ /** Whether the current source belongs to a stage region. Unreadable files
206
+ * keep their recorded index entries without declared-prop enrichment. */
207
+ function isStageIndexFile(file: string, moduleGraph: unknown): boolean {
433
208
  let source: string;
434
209
  try {
435
210
  source = readFileSync(file, 'utf8');
436
211
  } catch {
437
- return INERT_FILE_ANALYSIS;
438
- }
439
- // The SAME decision the transform hook stamped with (task #45) — not a
440
- // second derivation that could disagree with it.
441
- if (serving().surfaceOf(file, source, moduleGraph).attribute !== 'userData-oid') {
442
- return INERT_FILE_ANALYSIS;
212
+ return false;
443
213
  }
444
- return {
445
- contracts: visibleR3fContracts(source, file),
446
- // The surface check above already established this file renders through
447
- // the R3F reconciler, which is a stronger answer than the source-text
448
- // sniff the analyzer falls back on — and the one a project that imports
449
- // its prop shape from a shared module can actually pass.
450
- selectDiagnostics: fileDiagnosticJoin(
451
- fileEntries,
452
- r3fAuthoringDiagnostics(source, file, { knownThreeSurface: true }),
453
- ),
454
- };
214
+ return oidAttributeForSurface(serving().surfaceOf(file, source, moduleGraph).surface) === 'userData-oid';
455
215
  }
456
216
 
457
- /**
458
- * One recorded entry plus everything the current bytes add to it: its
459
- * component's authoring contract, its declared props, and (H5) the
460
- * authorability diagnostics that pertain to it. Returns the entry ITSELF when
461
- * there is nothing to add, so an unenriched index read allocates nothing.
462
- *
463
- * Note that the diagnostic lookup runs for EVERY entry, including a lowercase
464
- * host tag: R3F002/3/5 are ABOUT a component definition, and a definition's
465
- * root is usually an ordinary `<group>`/`<mesh>` — that entry is precisely the
466
- * one an instance row reaches through its boundary object's own `userData.oid`.
467
- */
217
+ /** Add declared props to a source index entry when the resolver has them. */
468
218
  function enrichIndexEntry(
469
219
  entry: OidEntry,
470
- analysis: IndexFileAnalysis,
220
+ stage: boolean,
471
221
  propsByTag: ReadonlyMap<string, ComponentPropSpec[]>,
472
222
  ): OidEntry {
473
- const diagnostics = analysis.selectDiagnostics(entry);
474
- const custom = analysis.contracts !== null && /^[A-Z]/.test(entry.tag);
475
- const contract = custom ? analysis.contracts?.get(entry.tag) : undefined;
223
+ const custom = stage && /^[A-Z]/.test(entry.tag);
476
224
  const props = custom ? declaredPropsOf(propsByTag, entry.file, entry.tag) : undefined;
477
- // `props !== undefined` means the resolver ANSWERED for this tag — an empty
478
- // answer included. The flag is what lets the inspector's fallback tell a
479
- // pending cold resolver from a genuine absence (see OidEntry.propsResolved).
480
- const propsResolved = props !== undefined;
481
- if (!contract && !props?.length && !diagnostics && !propsResolved) return entry;
482
- return {
483
- ...entry,
484
- ...(contract ? { r3fAuthoring: contract } : {}),
485
- ...(props?.length ? { props } : {}),
486
- ...(propsResolved ? { propsResolved: true as const } : {}),
487
- ...(diagnostics ? { diagnostics } : {}),
488
- };
225
+ if (!props?.length) return entry;
226
+ return { ...entry, props };
489
227
  }
490
228
 
491
229
  function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
@@ -496,38 +234,31 @@ function snapshotRawOidIndex(store: OidStore): Record<string, unknown> {
496
234
 
497
235
  function buildEnrichedOidIndex(
498
236
  store: OidStore,
499
- devServer?: ViteDevServer,
237
+ devServer: ViteDevServer,
500
238
  propsByTag: ReadonlyMap<string, ComponentPropSpec[]> = new Map(),
501
239
  ): Record<string, unknown> {
502
- const moduleGraph = devServer?.moduleGraph;
503
- const entriesByFile = indexEntriesByFile(store);
504
-
505
- const analyses = new Map<string, IndexFileAnalysis>();
506
- const analysisFor = (file: string): IndexFileAnalysis => {
507
- const cached = analyses.get(file);
508
- if (cached) return cached;
509
- const analysis = analyzeIndexFile(file, entriesByFile.get(file) ?? new Map(), moduleGraph);
510
- analyses.set(file, analysis);
511
- return analysis;
240
+ const moduleGraph = devServer.moduleGraph;
241
+
242
+ const stageFiles = new Map<string, boolean>();
243
+ const stageFor = (file: string): boolean => {
244
+ const cached = stageFiles.get(file);
245
+ if (cached !== undefined) return cached;
246
+ const stage = isStageIndexFile(file, moduleGraph);
247
+ stageFiles.set(file, stage);
248
+ return stage;
512
249
  };
513
250
 
514
251
  const current: Record<string, unknown> = {};
515
252
  for (const [oid, entry] of store.index) {
516
- current[oid] = enrichIndexEntry(entry, analysisFor(entry.file), propsByTag);
253
+ current[oid] = enrichIndexEntry(entry, stageFor(entry.file), propsByTag);
517
254
  }
518
255
  return current;
519
256
  }
520
257
 
521
- /**
522
- * Props/tag/diagnostic enrich is the measured 2.6 s cold cost (repeat 391 ms)
523
- * of first design attach: `ComponentPropResolver` builds a `ts.Program`. The Scene
524
- * can render without it — oids, file/line, and tags are already on the raw
525
- * stamp. Blocking GET /__ui-source/index on enrich froze the Vite thread
526
- * (and every module load) for those seconds. Serve enrichment only for an
527
- * unchanged source index; otherwise serve current stamps while it refreshes.
528
- */
258
+ /** Resolve declared props outside the index request. Return current raw
259
+ * stamps while enrichment runs; cached enrichment must match the source index. */
529
260
  let lastEnrichedIndex: {
530
- root: string | undefined;
261
+ root: string;
531
262
  sourceIndex: ReadonlyMap<string, OidEntry>;
532
263
  index: Record<string, unknown>;
533
264
  } | null = null;
@@ -543,7 +274,7 @@ const pendingPropRequests = new Map<
543
274
  >();
544
275
 
545
276
  function componentPropWorkerUrl(): URL {
546
- // The serving module runs built (`dist-node/serving.mjs`), beside the worker built with it.
277
+ // The serving module runs built, beside the worker built with it.
547
278
  return new URL('./component-prop-worker.mjs', import.meta.url);
548
279
  }
549
280
 
@@ -557,16 +288,15 @@ function declaredPropWorker(): Worker {
557
288
  const worker = new Worker(componentPropWorkerUrl(), { name: 'volter-component-props' });
558
289
  worker.on(
559
290
  'message',
560
- (message: { id?: unknown; props?: Record<string, ComponentPropSpec[]>; error?: unknown }) => {
561
- if (typeof message.id !== 'number') return;
291
+ (message: { id: number; props: Record<string, ComponentPropSpec[]> } | { id: number; error: string }) => {
562
292
  const pending = pendingPropRequests.get(message.id);
563
293
  if (!pending) return;
564
294
  pendingPropRequests.delete(message.id);
565
- if (typeof message.error === 'string') {
295
+ if ('error' in message) {
566
296
  pending.reject(new Error(message.error));
567
297
  return;
568
298
  }
569
- pending.resolve(new Map(Object.entries(message.props ?? {})));
299
+ pending.resolve(new Map(Object.entries(message.props)));
570
300
  },
571
301
  );
572
302
  worker.on('error', (error: unknown) => {
@@ -596,10 +326,10 @@ function propRequestsFor(store: OidStore): Array<{ key: string; file: string; ta
596
326
  }
597
327
 
598
328
  function resolveDeclaredProps(
599
- projectRoot: string | undefined,
329
+ projectRoot: string,
600
330
  requests: Array<{ key: string; file: string; tag: string; definition?: true }>,
601
331
  ): Promise<ReadonlyMap<string, ComponentPropSpec[]>> {
602
- if (!projectRoot || requests.length === 0) {
332
+ if (requests.length === 0) {
603
333
  return new Promise((resolveRequest) => setImmediate(() => resolveRequest(new Map())));
604
334
  }
605
335
  const id = ++propRequestId;
@@ -611,8 +341,8 @@ function resolveDeclaredProps(
611
341
 
612
342
  function scheduleOidIndexEnrich(
613
343
  store: OidStore,
614
- projectRoot?: string,
615
- devServer?: ViteDevServer,
344
+ projectRoot: string,
345
+ devServer: ViteDevServer,
616
346
  ): void {
617
347
  if (enrichQueued) return;
618
348
  enrichQueued = true;
@@ -627,9 +357,7 @@ function scheduleOidIndexEnrich(
627
357
  };
628
358
  })
629
359
  .catch((error) => {
630
- // A failed semantic resolver never removes source authoring. Contracts
631
- // and diagnostics still enrich on the server thread; declared props
632
- // degrade to the attributes physically present in source.
360
+ // Keep source-attribute inspection available when semantic props fail.
633
361
  process.stderr.write(
634
362
  `[ui-oid] Declared-prop analysis unavailable; using source attributes: ${String(error)}\n`,
635
363
  );
@@ -646,8 +374,8 @@ function scheduleOidIndexEnrich(
646
374
 
647
375
  function currentOidIndex(
648
376
  store: OidStore,
649
- projectRoot?: string,
650
- devServer?: ViteDevServer,
377
+ projectRoot: string,
378
+ devServer: ViteDevServer,
651
379
  ): Record<string, unknown> {
652
380
  scheduleOidIndexEnrich(store, projectRoot, devServer);
653
381
  const cached = lastEnrichedIndex;
@@ -664,58 +392,6 @@ function currentOidIndex(
664
392
  return snapshotRawOidIndex(store);
665
393
  }
666
394
 
667
- /** Test-only: wait for the in-flight background enrich, if any. */
668
- export function __flushOidIndexEnrichForTest(): Promise<void> {
669
- return new Promise((resolve) => {
670
- const finish = (): void => {
671
- if (!enrichQueued) {
672
- resolve();
673
- return;
674
- }
675
- setImmediate(finish);
676
- };
677
- setImmediate(finish);
678
- });
679
- }
680
-
681
- /** Test-only reset. */
682
- export function __resetOidIndexEnrichForTest(): void {
683
- lastEnrichedIndex = null;
684
- enrichQueued = false;
685
- if (propWorker) {
686
- void propWorker.terminate();
687
- propWorker = null;
688
- }
689
- rejectPendingPropRequests(new Error('Declared-prop worker reset.'));
690
- }
691
-
692
- /** Test-only: whether the last GET returned without waiting for enrich. */
693
- export function __oidIndexEnrichStateForTest(): { queued: boolean; hasEnriched: boolean } {
694
- return { queued: enrichQueued, hasEnriched: lastEnrichedIndex !== null };
695
- }
696
-
697
- /**
698
- * THE write point for every `/__ui-source/*` handler — and the reason a
699
- * vendored game's source can be stamped without reopening silent drift.
700
- *
701
- * A source write lands in one of two recorders, decided by WHERE the file is,
702
- * never by what the handler was doing:
703
- *
704
- * - inside a repo-vendored game → `writeRecordedVendoredFile`, which moves the
705
- * file AND its `UPSTREAM.lock` entry as ONE journalled operation, so
706
- * `verify-unaltered.mjs` is green with the edit present (the edit becomes a
707
- * recorded patch that reverse-applies to upstream). Undo needs no special
708
- * case: it comes back through here with the previous bytes, the lock
709
- * reconciles from the bytes alone, and the edit that restores upstream's own
710
- * content deletes the patch entry again.
711
- * - anywhere else → the plain `writeFileSync` this file always did, because a
712
- * user's own project records in the user's own git.
713
- *
714
- * Centralised deliberately. Nine handlers in this file write source; routing
715
- * per-handler would mean nine chances to add a tenth that forgets, and a
716
- * forgotten one is not a visible bug — it is a vendored tree that quietly
717
- * stops matching its lock.
718
- */
719
395
  /** The kit's server capabilities this plugin was constructed with
720
396
  * (`@volter/sdk/session/project-serving`). */
721
397
  let servingServices: ProjectServingServices | null = null;
@@ -725,46 +401,29 @@ function serving(): ProjectServingServices {
725
401
  return servingServices;
726
402
  }
727
403
 
728
- /**
729
- * THE read point for every `/__ui-source/*` handler: a crashed vendored write is rolled forward
730
- * before its file is read, so no handler plans against bytes the recorder is about to replace.
731
- * Welded to the read, the way the recorder is welded to the write, so no handler can forget it.
732
- */
733
- function readSettledSource(file: string): string {
734
- serving().settleSource(file);
404
+ /** THE read point for every `/__ui-source/*` handler. */
405
+ function readSource(file: string): string {
735
406
  return readFileSync(file, 'utf8');
736
407
  }
737
408
 
738
409
  /**
739
- * THE write point for every `/__ui-source/*` handler: the kit's own write (a vendored file is
740
- * recorded against its lock, and the served modules it replaces are invalidated, so the next
741
- * mount reads the new bytes rather than a transform the dev server still holds).
410
+ * THE write point for every `/__ui-source/*` handler: the kit's own write (the served modules
411
+ * it replaces are invalidated, so the next mount reads the new bytes rather than a transform
412
+ * the dev server still holds).
742
413
  */
743
414
  function writeEditableSource(file: string, code: string): void {
744
415
  serving().writeSource(file, code);
745
416
  }
746
417
 
747
- /** Default `include` predicate (see the module doc comment above for the reasoning). */
748
- export function defaultProjectScopeInclude(id: string): boolean {
418
+ /** Project source scope for JSX stamping. */
419
+ function defaultProjectScopeInclude(id: string): boolean {
749
420
  const clean = (id.split('?')[0] ?? id).replace(/\\/g, '/');
750
421
  if (!clean.endsWith('.tsx')) return false;
751
422
  if (NODE_MODULES_RE.test(clean)) return false;
752
- // A vendored GAME's source IS stamped; every other `/vendor/` tree is not.
753
- // The old blanket exclusion was written under the never-modify-vendored-
754
- // source rule, which the owner's code-or-data ruling superseded
755
- // (ARCHITECTURE-CORE §Editor "Every edit edits the game's own CODE or DATA",
756
- // §Rules "there is no unwritable base"): ownership no longer decides WHETHER
757
- // an edit may be written, only WHO RECORDS it. For a repo-vendored copy the
758
- // recorder is its own `UPSTREAM.lock`, written in the same gesture as the
759
- // file (`server/vendored-lock-recorder.ts`), so the zero-UNRECORDED-diff bar
760
- // holds WITH the edit present rather than by forbidding the edit. Stamping is
761
- // what makes the game's own TSX literals addressable by the gizmo and the
762
- // inspector; refusing it here is what used to make a vendored game
763
- // permanently unauthorable.
423
+ // A vendored project records edits with its own source; dependencies remain excluded.
764
424
  if (VENDOR_RE.test(clean) && !VENDORED_GAME_SRC_RE.test(clean)) return false;
765
425
  if (isToolingSource(clean)) return false;
766
- if (EDITOR_SRC_RE.test(clean) && !EDITABLE_COMPONENTS_RE.test(clean)) return false;
767
- if (nearestManifestExcludesIngestReact(dirname(clean))) return false;
426
+ if (EDITOR_SRC_RE.test(clean)) return false;
768
427
  return true;
769
428
  }
770
429
 
@@ -775,13 +434,12 @@ function resolveEditableSourceFile(id: string, projectRoot: string): string | nu
775
434
 
776
435
  // Whole-file transactions also serve project-owned model source. This is
777
436
  // deliberately narrower than a general project-file API: TypeScript below
778
- // this project's src/ tree, never vendored or an ingest-react identity.
437
+ // this project's src/ tree, with vendored dependencies excluded.
779
438
  const projectPath = relative(projectRoot, candidate).replace(/\\/g, '/');
780
439
  if (
781
440
  projectPath.startsWith('src/') &&
782
441
  (projectPath.endsWith('.ts') || projectPath.endsWith('.tsx')) &&
783
- !VENDOR_RE.test(candidate) &&
784
- !nearestManifestExcludesIngestReact(dirname(candidate))
442
+ !VENDOR_RE.test(candidate)
785
443
  ) {
786
444
  return candidate;
787
445
  }
@@ -806,7 +464,7 @@ function isCanonicalProjectSourcePath(projectRoot: string, file: string): boolea
806
464
  }
807
465
 
808
466
  /** The JSON response a source-endpoint handler produces (status defaults to 200). */
809
- export interface HandlerResult {
467
+ interface HandlerResult {
810
468
  status?: number;
811
469
  body: unknown;
812
470
  }
@@ -837,20 +495,12 @@ function preparedSource(
837
495
  };
838
496
  }
839
497
 
840
- /**
841
- * Pure source edit preparation: computes exact bytes and hashes, never writes.
842
- *
843
- * The edit itself is planned by the tier-agnostic `planSourceEdit`
844
- * (`src/ui-source/plan-source-edit.ts`) — this handler only supplies what is
845
- * genuinely server-side: the `node:fs` reads, the css scope guard, sha256, and
846
- * the project-relative resource path. The hosted browser backend supplies its
847
- * own equivalents around the SAME planner, which is what keeps the two tiers
848
- * from drifting into different edit semantics.
849
- */
850
- export function handlePrepare(
498
+ /** Prepare source bytes and hashes with ../src/source/plan-source-edit.ts.
499
+ * This handler owns file reads, CSS scope validation and project resource paths. */
500
+ function handlePrepare(
851
501
  store: OidStore,
852
502
  body: Record<string, unknown>,
853
- projectRoot = process.cwd(),
503
+ projectRoot: string,
854
504
  ): HandlerResult {
855
505
  const request = body as unknown as SourceEditRequest;
856
506
  if (request.kind === 'css' && (!request.file || !isEditableCssFile(request.file))) {
@@ -863,7 +513,7 @@ export function handlePrepare(
863
513
  const sources = new Map<string, string>();
864
514
  const files = sourceEditFiles(request, resolveOid);
865
515
  for (const file of files) {
866
- sources.set(file, readSettledSource(file));
516
+ sources.set(file, readSource(file));
867
517
  }
868
518
  // The class-vs-inline gate is a question about the EDITED FILE's own project,
869
519
  // so it is answered here (the tier that has `node:fs`) and handed to the pure
@@ -878,16 +528,16 @@ export function handlePrepare(
878
528
  return preparedSource(plan.file, plan.prevSource, plan.newSource, plan.result, projectRoot);
879
529
  }
880
530
 
881
- export function handleSourceRead(
531
+ function handleSourceRead(
882
532
  body: Record<string, unknown>,
883
- projectRoot = process.cwd(),
533
+ projectRoot: string,
884
534
  ): HandlerResult {
885
535
  const file = body['file'];
886
536
  const resolved = typeof file === 'string' ? resolveEditableSourceFile(file, projectRoot) : null;
887
537
  if (!resolved) {
888
538
  return { status: 403, body: { error: 'file out of editable scope' } };
889
539
  }
890
- const source = readSettledSource(resolved);
540
+ const source = readSource(resolved);
891
541
  return {
892
542
  body: {
893
543
  source,
@@ -897,9 +547,9 @@ export function handleSourceRead(
897
547
  };
898
548
  }
899
549
 
900
- export function handleSourceApply(
550
+ function handleSourceApply(
901
551
  body: Record<string, unknown>,
902
- projectRoot = process.cwd(),
552
+ projectRoot: string,
903
553
  ): HandlerResult {
904
554
  const restored = handleRestore(body, projectRoot);
905
555
  const value = restored.body as { restored: boolean; sha?: string; error?: string };
@@ -909,14 +559,11 @@ export function handleSourceApply(
909
559
  };
910
560
  }
911
561
 
912
- /** POST `/__ui-source/write` — surgical inline-style/className edit (F5 routing).
913
- * D-A3 (wave 13): a `value: null` body is the REMOVAL sentinel — routed via
914
- * `applyStyleWriteRequest` to `removeInlineStyle` (append-aware undo); `appended`
915
- * is forwarded so the client's undo can remove vs. restore. */
562
+ /** Surgical style/className write. A null value removes an inline property. */
916
563
  function handleWrite(
917
564
  store: OidStore,
918
565
  body: Record<string, unknown>,
919
- projectRoot = process.cwd(),
566
+ projectRoot: string,
920
567
  ): HandlerResult {
921
568
  // A numeric `value` stays a NUMBER all the way to the writer — the JSX then
922
569
  // carries a bare `12` (React px-ifies it) instead of `'12'`, which React
@@ -928,7 +575,7 @@ function handleWrite(
928
575
  };
929
576
  const entry = store.index.get(oid);
930
577
  if (!entry) return { body: { changed: false, error: 'unknown oid' } };
931
- const src = readSettledSource(entry.file);
578
+ const src = readSource(entry.file);
932
579
  const off = lineColToOffset(src, entry.line, entry.col);
933
580
  const result = applyStyleWriteRequest(
934
581
  src,
@@ -943,7 +590,6 @@ function handleWrite(
943
590
  changed: result.changed,
944
591
  dynamic: result.dynamic,
945
592
  route: result.route,
946
- appended: result.appended,
947
593
  ...(result.tokenRef ? { tokenRef: result.tokenRef } : {}),
948
594
  ...(result.error ? { error: result.error } : {}),
949
595
  file: entry.file,
@@ -951,20 +597,9 @@ function handleWrite(
951
597
  };
952
598
  }
953
599
 
954
- /**
955
- * POST `/__ui-source/css` — Cap 2 surgical CSS-FILE edit. The client (adapter) resolves
956
- * the target rule from live matched-rule data (`inspect.ts` getMatchedCssRules) and sends
957
- * the rule's source file + selector; this edits that file in place. The file path is
958
- * client-supplied, so it MUST pass the first-party CSS scope guard before any read/write
959
- * — an out-of-scope path is rejected, not touched. A selector not present in source is
960
- * generated CSS (`generated: true`); the client re-routes to class/inline.
961
- *
962
- * Exported (unlike its sibling handlers above, which close over the live
963
- * `OidStore`) so it's directly unit-testable without a dev server — it's pure
964
- * body-in/HandlerResult-out plus real fs I/O on a client-supplied, scope-
965
- * guarded path, no store/session state involved.
966
- */
967
- export function handleCss(
600
+ /** Edit a client-selected first-party CSS rule after the scope guard.
601
+ * Selectors absent from source report generated CSS for caller rerouting. */
602
+ function handleCss(
968
603
  body: Record<string, unknown>,
969
604
  ): HandlerResult {
970
605
  const { file, selector, prop, value, media } = body as {
@@ -978,7 +613,7 @@ export function handleCss(
978
613
  return { body: { changed: false, error: 'css file out of editable scope' } };
979
614
  }
980
615
  const cleanFile = file.split('?')[0] ?? file;
981
- const src = readSettledSource(cleanFile);
616
+ const src = readSource(cleanFile);
982
617
  // `media` scopes the edit to a breakpoint: the rule inside `@media <media>`
983
618
  // (block and rule both created on demand — a breakpoint's first edit IS
984
619
  // what mints it). The base-path `generated` probe does not apply: the
@@ -993,9 +628,8 @@ export function handleCss(
993
628
  }
994
629
 
995
630
  /**
996
- * POST `/__ui-source/text` — Cap 3 text-content edit. Replaces a leaf element's pure-text
997
- * body in place (guarded: refuses a body with `{expression}` or child elements). Returns
998
- * `prevText` (the editable text before the edit) for the client's undo inverse.
631
+ * POST `/__ui-source/text` — text-content edit. Replaces a leaf element's pure-text
632
+ * body in place, refusing a body with expressions or child elements.
999
633
  */
1000
634
  function handleText(
1001
635
  store: OidStore,
@@ -1004,18 +638,17 @@ function handleText(
1004
638
  const { oid, text } = body as { oid: string; text: string };
1005
639
  const entry = store.index.get(oid);
1006
640
  if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1007
- const src = readSettledSource(entry.file);
641
+ const src = readSource(entry.file);
1008
642
  const off = lineColToOffset(src, entry.line, entry.col);
1009
- const prevText = getEditableText(src, off);
1010
643
  const result = editTextContent(src, off, text);
1011
644
  if (result.changed) writeEditableSource(entry.file, result.code);
1012
645
  return {
1013
- body: { changed: result.changed, dynamic: result.dynamic, prevText, file: entry.file },
646
+ body: { changed: result.changed, dynamic: result.dynamic, file: entry.file },
1014
647
  };
1015
648
  }
1016
649
 
1017
650
  /**
1018
- * POST `/__ui-source/prop` — Cap 4 component prop / attribute edit at the call site. The
651
+ * POST `/__ui-source/prop` — component prop / attribute edit at the call site. The
1019
652
  * client sends the CALL-SITE oid (resolved from the fiber, `inspect.ts` getCallSiteOid)
1020
653
  * whose index entry points at the `<Component …>` tag. Guards a dynamic (non-literal) prop
1021
654
  * and the event-handler / structural props.
@@ -1027,7 +660,7 @@ function handleProp(
1027
660
  const { oid, prop, value } = body as { oid: string; prop: string; value: string | null };
1028
661
  const entry = store.index.get(oid);
1029
662
  if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1030
- const src = readSettledSource(entry.file);
663
+ const src = readSource(entry.file);
1031
664
  const off = lineColToOffset(src, entry.line, entry.col);
1032
665
  // A dotted prop is one member of an object-literal prop (`params.threshold`): the index names
1033
666
  // members that way (`authoredProps`), and the member writer rewrites that literal alone.
@@ -1036,9 +669,7 @@ function handleProp(
1036
669
  if (member.changed) writeEditableSource(entry.file, member.code);
1037
670
  return { body: { changed: member.changed, dynamic: member.dynamic, file: entry.file } };
1038
671
  }
1039
- // `value: null` is the REMOVAL sentinel, the same shape `/__ui-source/write`
1040
- // uses for `removeStyle` (D-A3). Removing a prop attribute is how the
1041
- // inspector reverts a prop to the component's declared default.
672
+ // A null value removes the prop so its component default can take effect.
1042
673
  if (value === null) {
1043
674
  const removed = removePropAttribute(src, off, prop);
1044
675
  if (removed.changed) writeEditableSource(entry.file, removed.code);
@@ -1046,53 +677,39 @@ function handleProp(
1046
677
  }
1047
678
  const result = writePropChange(src, off, prop, value, {
1048
679
  addIfMissing: body['addIfMissing'] === true,
1049
- // R2: opt-in scalar→tuple shape
1050
- // upgrade — see writePropChange's own doc comment. Absent/false on every
1051
- // pre-existing caller, so react-dom prop writes are byte-identical.
680
+ // Forward explicit scalar-to-tuple permission to the source writer.
1052
681
  allowShapeUpgrade: body['allowShapeUpgrade'] === true,
1053
682
  });
1054
683
  if (result.changed) writeEditableSource(entry.file, result.code);
1055
684
  return { body: { changed: result.changed, dynamic: result.dynamic, file: entry.file } };
1056
685
  }
1057
686
 
1058
- /**
1059
- * POST `/__ui-source/struct` — Cap 5 structural source edits. Ops: `delete`, `duplicate`,
1060
- * `wrap` (+wrapperTag), `unwrap`, `create` (insert a `<tag/>` child of `oid`),
1061
- * `create-sibling` (insert the `snippet` immediately AFTER `oid` — how a top-level element
1062
- * is added to a world whose root JSX is an OID-less fragment), `reorder`
1063
- * (move `oid` before `targetOid`, or to the end of `parentOid`), and `reparent` (move `oid`
1064
- * into `parentOid`). An insert may carry `ensureImport` when its snippet names a component.
1065
- * Multi-oid ops resolve every oid to an offset in the SAME file (a
1066
- * cross-file move is refused) — OID-resolved, immune to index races.
1067
- *
1068
- * D-1 (Phase D, spec27 §2 "known limitation to schedule, not hide"): when the op actually
1069
- * changes the file, the response also carries the WHOLE file's `prevSource`/`newSource`
1070
- * (before/after this write) and their `prevSha`/`newSha` (sha256 digests, computed here —
1071
- * the client never hashes anything itself). `ReactRootAuthoringAdapter.structOp` uses
1072
- * these to push a checksum-guarded whole-file-snapshot undo/redo entry — the sound inverse
1073
- * for a structural op (a per-OID inverse is unsound: OIDs are content-signature keyed,
1074
- * reorder/delete reassign occurrence indices, and delete has no inverse payload). Exported
1075
- * (like `handleCss`) so it's directly unit-testable without a running dev server.
1076
- *
1077
- * The op switch itself lives in `planSourceEdit` and NOWHERE else. It used to be written
1078
- * out a second time here, and the two copies drifted the moment `reparent` grew rules of
1079
- * its own (R1–R4): one caller routed through the planner and got them, this
1080
- * one did not. Everything that remains below is what is genuinely server-side — the
1081
- * `node:fs` read/write, sha256, and the response envelope.
1082
- */
1083
- export function handleStruct(
687
+ /** Plan a structural source edit, then apply its complete next-file bytes.
688
+ * The response includes before/after source and SHA values for guarded history.
689
+ * Scope, transform, and destination rules live in the shared source planner. */
690
+ function handleStruct(
1084
691
  store: OidStore,
1085
692
  body: Record<string, unknown>,
693
+ kind: 'struct' | 'struct-many',
1086
694
  ): HandlerResult {
1087
- const { oid, op } = body as { oid?: unknown; op?: unknown };
1088
- if (typeof oid !== 'string' || typeof op !== 'string') {
1089
- return { body: { changed: false, error: 'struct requests need an oid and an op' } };
695
+ const { oid, oids, op } = body;
696
+ if (kind === 'struct') {
697
+ if (typeof oid !== 'string' || typeof op !== 'string') {
698
+ return { body: { changed: false, error: 'struct requests need an oid and an op' } };
699
+ }
700
+ } else {
701
+ if (op !== 'delete' && op !== 'group') {
702
+ return { body: { changed: false, error: `unsupported batch op: ${op}` } };
703
+ }
704
+ if (!Array.isArray(oids) || oids.length === 0) {
705
+ return { body: { changed: false, error: 'oids must be a non-empty array' } };
706
+ }
1090
707
  }
1091
- const request = { ...body, kind: 'struct', oid, op } as unknown as SourceEditRequest;
708
+ const request = { ...body, kind } as unknown as SourceEditRequest;
1092
709
  const resolveOid = (id: string): OidEntry | undefined => store.index.get(id);
1093
710
  const sources = new Map<string, string>();
1094
711
  for (const file of sourceEditFiles(request, resolveOid)) {
1095
- sources.set(file, readSettledSource(file));
712
+ sources.set(file, readSource(file));
1096
713
  }
1097
714
  const plan = planSourceEdit(request, sources, resolveOid);
1098
715
  if (plan.file === null) return { body: { changed: false, ...plan.result } };
@@ -1112,96 +729,13 @@ export function handleStruct(
1112
729
  };
1113
730
  }
1114
731
 
1115
- /**
1116
- * POST `/__ui-source/struct-many` — one source snapshot and one history boundary
1117
- * for structural operations involving several OIDs. `group` wraps source siblings
1118
- * beneath an ordinary R3F group. `delete` retains the delete-order-residual fix
1119
- * (50f90a6d): it resolves EVERY `oid` in `oids` to an offset against ONE shared
1120
- * `readFileSync` snapshot, then applies them via `deleteElements` (`writer.ts`) —
1121
- * highest offset first, so
1122
- * no still-queued target's offset is ever shifted out from under it, REGARDLESS of
1123
- * what order the caller listed `oids` in. This is what makes the batch immune to the
1124
- * delete-order-residual: `deleteSelection` (`editor-hotkeys.ts`)'s per-id fallback
1125
- * loop derives its delete order from the CALLER's own hierarchy walk (DOM order for
1126
- * `ReactRootAuthoringAdapter` — see `walkOidTree`), which can diverge from true
1127
- * SOURCE order when a component's live DOM child order is a runtime permutation of
1128
- * its JSX (e.g. `{[...els].reverse()}`); this endpoint never consults that walk order
1129
- * at all, only each oid's own registered `{line, col}` and ONE shared file read, so
1130
- * caller order is irrelevant to correctness. Explicitly NOT a re-scan/re-transform of
1131
- * the file per oid (see `deleteElements`'s doc comment for why that would be UNSOUND
1132
- * here — a live re-transform mid-batch reassigns occurrence-index-based oid identity
1133
- * out from under the very oids this batch is still trying to resolve). One write, one
1134
- * `prevSource`/`newSource` pair — one undo entry for the whole batch (a bonus over
1135
- * the N-entry per-id fallback, not the point of this fix, but free from reusing
1136
- * `pushStructUndo`'s existing whole-file-snapshot inverse unchanged).
1137
- */
1138
- export function handleStructMany(
732
+ /** Plan a component fork, collision-check the new file, and write it.
733
+ * Return the callsite edit unwritten for project history; undo retains the
734
+ * new module and restores the previous callsite. */
735
+ function handleForkComponent(
1139
736
  store: OidStore,
1140
737
  body: Record<string, unknown>,
1141
- ): HandlerResult {
1142
- const { oids, op, wrapperTag } = body as {
1143
- oids?: unknown;
1144
- op?: string;
1145
- wrapperTag?: string;
1146
- };
1147
- if (op !== 'delete' && op !== 'group') {
1148
- return { body: { changed: false, error: `unsupported batch op: ${op}` } };
1149
- }
1150
- if (!Array.isArray(oids) || oids.length === 0) {
1151
- return { body: { changed: false, error: 'oids must be a non-empty array' } };
1152
- }
1153
- const uniqueOids = [...new Set(oids as string[])];
1154
- const entries = uniqueOids.map((oid) => ({ oid, entry: store.index.get(oid) }));
1155
- const missing = entries.find((e) => !e.entry);
1156
- if (missing) return { body: { changed: false, error: `unknown oid: ${missing.oid}` } };
1157
- const file = entries[0]!.entry!.file;
1158
- const crossFile = entries.find((e) => e.entry!.file !== file);
1159
- if (crossFile) return { body: { changed: false, error: 'cannot batch-edit across files' } };
1160
-
1161
- const src = readSettledSource(file); // ONE snapshot every offset below resolves against
1162
- const offsets = entries.map((e) => lineColToOffset(src, e.entry!.line, e.entry!.col));
1163
- const result =
1164
- op === 'group' ? groupSiblingElements(src, offsets, wrapperTag) : deleteElements(src, offsets);
1165
- if (!result.changed) return { body: { changed: false, file } };
1166
- const prevSource = src;
1167
- const newSource = result.code;
1168
- writeEditableSource(file, newSource);
1169
- return {
1170
- body: {
1171
- changed: true,
1172
- file,
1173
- prevSource,
1174
- newSource,
1175
- prevSha: sha256(prevSource),
1176
- newSha: sha256(newSource),
1177
- },
1178
- };
1179
- }
1180
-
1181
- /**
1182
- * POST `/__ui-source/fork-component` — H7 "Fork Component…" (wave 6).
1183
- *
1184
- * The ONE thing this endpoint does that the client cannot: create a file. Every
1185
- * other `/__ui-source/*` write targets a file the OID index already knows, and
1186
- * the whole-file `apply`/`restore` pair is checksum-guarded against a file that
1187
- * exists — there is no create path through the client write seam at all. So the
1188
- * new module is written HERE with `node:fs`, and the CALLSITE edit is handed
1189
- * back to the client unwritten, so it can go through project history like every
1190
- * other source write (the fork is undoable at the callsite; the new file, being
1191
- * outside history, survives an undo as an inert module nothing imports).
1192
- *
1193
- * ORDER AND ATOMICITY. `planComponentFork` computes BOTH edits from both files'
1194
- * current bytes before anything is written, and refuses — with a sentence — on
1195
- * every failure it can see (definition not found, tag no longer matches, name
1196
- * or file collision). Only then is the new file written, and only if nothing is
1197
- * at that path already: `existsSync` is checked here as well as in the planner,
1198
- * because the planner reads a directory LISTING the caller supplied and this is
1199
- * the last moment before the write.
1200
- */
1201
- export function handleForkComponent(
1202
- store: OidStore,
1203
- body: Record<string, unknown>,
1204
- projectRoot = process.cwd(),
738
+ projectRoot: string,
1205
739
  ): HandlerResult {
1206
740
  const { oid, definitionOid, name } = body as {
1207
741
  oid?: string;
@@ -1223,11 +757,11 @@ export function handleForkComponent(
1223
757
  }
1224
758
  const planned = planComponentFork({
1225
759
  callsiteFile: callsite.file,
1226
- callsiteSource: readSettledSource(callsite.file),
760
+ callsiteSource: readSource(callsite.file),
1227
761
  callsiteLine: callsite.line,
1228
762
  callsiteCol: callsite.col,
1229
763
  definitionFile: definition.file,
1230
- definitionSource: readSettledSource(definition.file),
764
+ definitionSource: readSource(definition.file),
1231
765
  tag: callsite.tag,
1232
766
  nameSeed: typeof name === 'string' ? name : undefined,
1233
767
  siblingFiles,
@@ -1251,39 +785,23 @@ export function handleForkComponent(
1251
785
  newName: plan.newName,
1252
786
  tag: plan.tag,
1253
787
  newFile,
1254
- newResourcePath: sourceResourcePath(newFile, projectRoot),
1255
788
  importSpecifier: plan.importSpecifier,
1256
789
  label: plan.historyLabel,
1257
790
  callsiteFile: plan.callsiteFile,
1258
791
  callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1259
792
  callsitePrevSource: plan.callsitePrevSource,
1260
793
  callsiteNewSource: plan.callsiteNewSource,
1261
- callsitePrevSha: sha256(plan.callsitePrevSource),
1262
- callsiteNewSha: sha256(plan.callsiteNewSource),
1263
794
  },
1264
795
  };
1265
796
  }
1266
797
 
1267
- /**
1268
- * POST `/__ui-source/extract-component` — P1 "Extract Component…".
1269
- *
1270
- * The fork route's sibling, and the SECOND file-creating seam on this
1271
- * middleware (the header on `/__ui-source/fork-component` records why file
1272
- * creation lives server-side at all). `planComponentExtraction` computes the
1273
- * two new modules (component + portable CSF story under `src/prefabs/`) and
1274
- * the callsite edit from current bytes before anything is written, refusing
1275
- * with a sentence on every failure it can see (subtree gone, lexical capture,
1276
- * module-local reference, name/file collision). Only then are the new files
1277
- * written — `existsSync`-guarded here as well as in the planner, because the
1278
- * planner reads a directory LISTING and this is the last moment before the
1279
- * write — and the CALLSITE edit is handed back unwritten so the client pushes
1280
- * it through project history: undo restores the callsite and leaves the two
1281
- * new files behind as an inert module + story nothing imports.
1282
- */
1283
- export function handleExtractComponent(
798
+ /** Plan extraction into a component and portable story, then collision-check
799
+ * and write both new files. Return the callsite change for project history;
800
+ * undo restores that callsite while retaining the created files. */
801
+ function handleExtractComponent(
1284
802
  store: OidStore,
1285
803
  body: Record<string, unknown>,
1286
- projectRoot = process.cwd(),
804
+ projectRoot: string,
1287
805
  ): HandlerResult {
1288
806
  const { oid, name } = body as { oid?: string; name?: string };
1289
807
  const entry = typeof oid === 'string' ? store.index.get(oid) : undefined;
@@ -1298,7 +816,7 @@ export function handleExtractComponent(
1298
816
  }
1299
817
  const planned = planComponentExtraction({
1300
818
  sourceFile: entry.file,
1301
- source: readSettledSource(entry.file),
819
+ source: readSource(entry.file),
1302
820
  line: entry.line,
1303
821
  col: entry.col,
1304
822
  nameSeed: typeof name === 'string' ? name : undefined,
@@ -1327,31 +845,21 @@ export function handleExtractComponent(
1327
845
  newName: plan.newName,
1328
846
  tag: plan.tag,
1329
847
  componentFile: plan.componentFile,
1330
- componentResourcePath: sourceResourcePath(plan.componentFile, projectRoot),
1331
848
  storyFile: plan.storyFile,
1332
- storyResourcePath: sourceResourcePath(plan.storyFile, projectRoot),
1333
849
  importSpecifier: plan.importSpecifier,
1334
850
  label: plan.historyLabel,
1335
851
  callsiteFile: plan.callsiteFile,
1336
852
  callsiteResourcePath: sourceResourcePath(plan.callsiteFile, projectRoot),
1337
853
  callsitePrevSource: plan.callsitePrevSource,
1338
854
  callsiteNewSource: plan.callsiteNewSource,
1339
- callsitePrevSha: sha256(plan.callsitePrevSource),
1340
- callsiteNewSha: sha256(plan.callsiteNewSource),
1341
855
  },
1342
856
  };
1343
857
  }
1344
858
 
1345
- /**
1346
- * POST `/__ui-source/csf-story` — the CSF write half (design ledger:
1347
- * stories composed and framed everywhere, written nowhere): save an arg
1348
- * set as a new story export, rename one, or delete one. The grammar and
1349
- * every refusal live in the pure planner (`ui-source/plan-csf-story.ts`);
1350
- * this handler is scope-guard + read + plan + write.
1351
- */
1352
- export function handleCsfStory(
859
+ /** Save, rename, or delete a CSF story through the scoped source planner. */
860
+ function handleCsfStory(
1353
861
  body: Record<string, unknown>,
1354
- projectRoot = process.cwd(),
862
+ projectRoot: string,
1355
863
  ): HandlerResult {
1356
864
  const { op, file, name, newName, args } = body as {
1357
865
  op?: 'save' | 'rename' | 'delete';
@@ -1367,7 +875,7 @@ export function handleCsfStory(
1367
875
  if (!resolved) {
1368
876
  return { status: 403, body: { changed: false, error: 'file out of editable scope' } };
1369
877
  }
1370
- const source = readSettledSource(resolved);
878
+ const source = readSource(resolved);
1371
879
  const plan =
1372
880
  op === 'save'
1373
881
  ? planSaveStory(source, name, args ?? {})
@@ -1390,22 +898,10 @@ function cssValueFromJsxLiteral(name: string, raw: string): string | null {
1390
898
  return null;
1391
899
  }
1392
900
 
1393
- /**
1394
- * POST `/__ui-source/named-style` — the design ledger's NAMED STYLES order
1395
- * (Webflow prior art: a reusable style is a CSS CLASS in the project's own
1396
- * stylesheet). Three ops on one door:
1397
- *
1398
- * - `create` — mint `.className` in the given first-party stylesheet
1399
- * (`file`, guarded by {@link isEditableCssFile}), MOVING the element's
1400
- * literal inline declarations into it (dynamic members stay inline — they
1401
- * are not the writer's to move), and adding the class token to the
1402
- * element. Both files are planned before either is written, so a refusal
1403
- * (duplicate class, dynamic className) leaves nothing half-done.
1404
- * - `apply` / `remove` — add/remove the bare class token on the element.
1405
- *
1406
- * Every refusal names its gate; a `className={expr}` is never rewritten.
1407
- */
1408
- export function handleNamedStyle(
901
+ /** Create a first-party CSS class from literal inline declarations, or
902
+ * apply/remove a class token. Creation plans both files before writing;
903
+ * dynamic members remain inline and dynamic className expressions refuse. */
904
+ function handleNamedStyle(
1409
905
  store: OidStore,
1410
906
  body: Record<string, unknown>,
1411
907
  ): HandlerResult {
@@ -1427,7 +923,7 @@ export function handleNamedStyle(
1427
923
  }
1428
924
  const entry = store.index.get(oid);
1429
925
  if (!entry) return { body: { changed: false, error: 'unknown oid' } };
1430
- const src = readSettledSource(entry.file);
926
+ const src = readSource(entry.file);
1431
927
  const off = lineColToOffset(src, entry.line, entry.col);
1432
928
 
1433
929
  if (op === 'apply' || op === 'remove') {
@@ -1444,11 +940,7 @@ export function handleNamedStyle(
1444
940
  return { body: { changed: r.changed, file: entry.file } };
1445
941
  }
1446
942
 
1447
- // No stylesheet named ⇒ MINT one beside the element's own module (and make
1448
- // the module import it — an unimported stylesheet never paints, and a bare
1449
- // `import './styles.css'` is exactly the ecosystem-native form Vite and the
1450
- // game's own build both honor). No example or fresh scaffold ships project
1451
- // CSS today, so without this the whole feature would open on a refusal.
943
+ // Without an explicit stylesheet, create one beside the element module and import it.
1452
944
  const cleanCss =
1453
945
  typeof file === 'string'
1454
946
  ? (file.split('?')[0] ?? file)
@@ -1456,7 +948,7 @@ export function handleNamedStyle(
1456
948
  if (!isEditableCssFile(cleanCss)) {
1457
949
  return { body: { changed: false, error: 'css file out of editable scope' } };
1458
950
  }
1459
- const cssSrc = existsSync(cleanCss) ? readSettledSource(cleanCss) : '';
951
+ const cssSrc = existsSync(cleanCss) ? readSource(cleanCss) : '';
1460
952
  const decls: { prop: string; value: string }[] = [];
1461
953
  const moved: string[] = [];
1462
954
  for (const member of collectLiteralInlineStyles(src, off)) {
@@ -1503,23 +995,11 @@ export function handleNamedStyle(
1503
995
  };
1504
996
  }
1505
997
 
1506
- /**
1507
- * POST `/__ui-source/restore` — D-1 (Phase D, spec27 §2): the checksum-guarded whole-file
1508
- * restore that makes Cap-5 structural edits (delete/duplicate/wrap/unwrap/reorder/reparent/
1509
- * create) undoable. `ReactRootAuthoringAdapter.structOp` captures `{prevSource, newSource,
1510
- * prevSha, newSha}` from a struct write's response (`handleStruct`, above) and, on undo/
1511
- * redo, posts the INVERSE `{file, source, ifMatchSha}` here: undo sends `source: prevSource,
1512
- * ifMatchSha: newSha` (the hash of what SHOULD be on disk right now); redo sends the mirror
1513
- * image. Refuses — WITHOUT writing — when the file's CURRENT sha256 doesn't match
1514
- * `ifMatchSha` (an out-of-band change since: a hand-edit, a since-diverged reload, …);
1515
- * `restored: false` is the caller's cue to surface a loud refusal instead of silently
1516
- * corrupting the file. Scope-guarded the same way `handleCss`'s client-supplied `file` is
1517
- * (`defaultProjectScopeInclude` — restore targets the SAME `.tsx` project files struct ops
1518
- * do). Exported (like `handleCss`) so it's directly unit-testable without a dev server.
1519
- */
1520
- export function handleRestore(
998
+ /** Restore a complete source file only when its current SHA matches
999
+ * ifMatchSha. The same project scope guard applies to reads and writes. */
1000
+ function handleRestore(
1521
1001
  body: Record<string, unknown>,
1522
- projectRoot = process.cwd(),
1002
+ projectRoot: string,
1523
1003
  ): HandlerResult {
1524
1004
  const { file, source, ifMatchSha } = body as {
1525
1005
  file: string;
@@ -1532,7 +1012,7 @@ export function handleRestore(
1532
1012
  }
1533
1013
  let current: string;
1534
1014
  try {
1535
- current = readSettledSource(resolved);
1015
+ current = readSource(resolved);
1536
1016
  } catch (e) {
1537
1017
  return { status: 404, body: { restored: false, error: String(e) } };
1538
1018
  }
@@ -1551,67 +1031,39 @@ export function handleRestore(
1551
1031
  return { body: { restored: true, sha: sha256(source) } };
1552
1032
  }
1553
1033
 
1554
- export function uiOidPlugin(
1555
- services: ProjectServingServices,
1556
- include: RegExp | ((id: string) => boolean) = defaultProjectScopeInclude,
1557
- ): Plugin {
1034
+ export function uiOidPlugin(services: ProjectServingServices): Plugin {
1558
1035
  servingServices = services;
1559
1036
  const projectRoot = services.currentProjectRoot;
1560
- const test = typeof include === 'function' ? include : (id: string) => include.test(id);
1561
1037
  const store = new OidStore();
1562
1038
  // Captured by `configureServer` below, read by `transform` — the dev
1563
- // server's own `ModuleGraph` is what lets `declaredSurfaceForProjectFileViaGraph`
1039
+ // server's own `ModuleGraph` is what lets the surface resolver
1564
1040
  // reach a root entry's surface through a NON-entry file's importer chain
1565
1041
  // (a child component with no direct fiber import of its
1566
1042
  // own). `undefined` in a build/SSR context without `configureServer`
1567
1043
  // (e.g. a one-off transform in a test) degrades to the direct-entry-only
1568
- // answer, same as before this existed.
1044
+ // answer.
1569
1045
  let devServer: ViteDevServer | undefined;
1570
1046
  return {
1571
1047
  name: SOURCE_WRITE_ROUTES_PLUGIN,
1572
1048
  enforce: 'pre',
1573
1049
  transform(code, id) {
1574
- // A VIRTUAL MODULE IS NOT A FILE, and everything below this line treats
1575
- // `clean` as one — `resolveOidSurface` walks up from it looking for the
1576
- // owning `volter.project.json`, and `transformSource` records it as the
1577
- // stamped file's path. Vite's convention is that a `\0`-prefixed id was
1578
- // synthesized by a plugin and has no path behind it, so a path plugin
1579
- // skips it.
1580
- //
1581
- // The `test(clean)` below happens to reject today's virtual ids for the
1582
- // wrong reason (they carry no extension); a synthesized id may legally
1583
- // end in `.tsx`, and then the extension test passes and a NUL byte
1584
- // reaches `readFileSync`. That exact throw was measured one layer down
1585
- // on 2026-09-21 — see `importerFiles` in `server/project-root-surface.ts`
1586
- // for the failure and what it looked like from the editor.
1050
+ // Virtual module IDs have no filesystem path and must not reach source reads.
1587
1051
  if (id.startsWith('\0')) return null;
1588
1052
  const clean = id.split('?')[0] ?? id;
1589
- if (!test(clean)) return null;
1590
- // W1 — R3F-dialect files get ` userData-oid` (fiber pierces it into
1591
- // `object.userData.oid`); react-dom files keep ` data-oid` unchanged.
1592
- // WHICH REGION owns this exact file decides: a declaration the project
1593
- // made (an `include` glob or a `mounts` entry in `editor/volter.adapter.ts`), the
1594
- // manifest root whose `entry` it is, or reach from one through the live
1595
- // import graph. Nothing is inferred from the file's own bytes; a file
1596
- // nothing places gets the documented `data-oid` default, said out loud.
1597
- // The project root is threaded because an INGEST root's sources live
1598
- // outside its manifest's directory (see `manifestOwning`), so the walk
1599
- // alone cannot find the project that declares them.
1053
+ if (!defaultProjectScopeInclude(clean)) return null;
1054
+ // The owning region chooses the stamp; source diagnostics report any mismatch.
1600
1055
  const decision = services.surfaceOf(clean, code, devServer?.moduleGraph, projectRoot());
1601
1056
  const { code: out, entries } = transformSource(code, clean, store, {
1602
- attribute: decision.attribute,
1057
+ attribute: oidAttributeForSurface(decision.surface),
1603
1058
  canvasComponents: decision.surface === 'canvas',
1604
- importedR3fContracts: importedR3fContracts(code, clean),
1605
1059
  });
1606
- // Task #45: a wrong stamp must not be silent. Reported only when this
1607
- // file actually GOT stamps — a module with no JSX has no selection to
1608
- // break, so its (irrelevant) attribute is not worth a terminal line.
1060
+ // Report stamp diagnostics only for modules that actually contain stamped JSX.
1609
1061
  if (entries.length > 0) decision.report();
1610
1062
  return { code: out, map: null };
1611
1063
  },
1612
1064
  configureServer(server) {
1613
1065
  devServer = server;
1614
- const activeProjectRoot = (): string => projectRoot() ?? server.config.root;
1066
+ const activeProjectRoot = projectRoot;
1615
1067
  // The editor's own writes invalidate the modules they replace; the kit binds the graph.
1616
1068
  services.bindModuleGraph(server.moduleGraph, activeProjectRoot);
1617
1069
  // The Express host may apply express.json() app-wide, consuming the body and
@@ -1660,25 +1112,8 @@ export function uiOidPlugin(
1660
1112
  isAbsolute(file) ? file : resolve(activeProjectRoot(), file),
1661
1113
  activeProjectRoot(),
1662
1114
  );
1663
- /**
1664
- * Record this write with the collaboration session and REPORT THE
1665
- * REVISION IT PRODUCED.
1666
- *
1667
- * The revision is what the next mutation's optimistic-concurrency guard
1668
- * (`assertSourceMutation`, below) compares against, and the client only
1669
- * learned it from the collaboration SSE stream — one event loop later.
1670
- * A gesture that writes the same file TWICE therefore sent a revision
1671
- * that its own first write had already superseded, and the second write
1672
- * was rejected: measured on a canvas transform drag, whose `position`
1673
- * channel is two props (`x` and `y`) by construction, so EVERY drag lost
1674
- * its whole gesture to the compensation path. An R3F pivot-rotate
1675
- * (position + rotation) has the same shape.
1676
- *
1677
- * Returning it here closes the window without a second channel: the
1678
- * writer already awaits this response, so adopting the number it carries
1679
- * is exactly as fresh as the write itself (`source-write-backend.ts`'s
1680
- * `postJson`).
1681
- */
1115
+ /** Return the revision produced by this write so the next awaited mutation
1116
+ * can use it without waiting for collaboration SSE delivery. */
1682
1117
  const recordEditorMutation = (
1683
1118
  participantId: string,
1684
1119
  files: readonly string[],
@@ -1701,7 +1136,7 @@ export function uiOidPlugin(
1701
1136
  );
1702
1137
  };
1703
1138
  // Single middleware switching on the URL — avoids connect prefix-matching subtleties.
1704
- // Each POST endpoint delegates to a small module-level handler (below) so this
1139
+ // Each POST endpoint delegates to a small module-level handler so this
1705
1140
  // dispatcher stays flat; the try/catch + JSON plumbing lives here once.
1706
1141
  const post = async (
1707
1142
  req: import('node:http').IncomingMessage,
@@ -1711,42 +1146,15 @@ export function uiOidPlugin(
1711
1146
  ): Promise<void> => {
1712
1147
  try {
1713
1148
  const body = await readJson(req);
1714
- const claimedParticipantId = body['participantId'];
1715
- const trustedParticipantId = req.headers['x-volter-share-participant-id'];
1716
- const trustedRole = req.headers['x-volter-share-role'];
1717
- if (
1718
- typeof trustedParticipantId === 'string' &&
1719
- typeof claimedParticipantId === 'string' &&
1720
- trustedParticipantId !== claimedParticipantId
1721
- ) {
1722
- throw new Error(
1723
- 'The authenticated share participant does not match the source mutation author.',
1724
- );
1725
- }
1726
- const participantId =
1727
- typeof trustedParticipantId === 'string' ? trustedParticipantId : claimedParticipantId;
1149
+ const participantId = body['participantId'];
1728
1150
  const expectedRevision = body['expectedRevision'];
1729
1151
  const readOnlySourceRequest =
1730
1152
  url === '/__ui-source/prepare' || url === '/__ui-source/read';
1731
1153
  const beforeFiles = mutationFiles(body);
1732
- if (
1733
- typeof trustedParticipantId === 'string' &&
1734
- beforeFiles.some((file) => !isCanonicalProjectSourcePath(activeProjectRoot(), file))
1735
- ) {
1736
- throw new Error('Shared source path leaves the project root.');
1737
- }
1738
1154
  if (!readOnlySourceRequest && beforeFiles.length > 0) {
1739
1155
  if (typeof participantId !== 'string' || !Number.isInteger(expectedRevision)) {
1740
1156
  throw new Error('Source mutations require participantId and expectedRevision.');
1741
1157
  }
1742
- if (
1743
- typeof trustedRole === 'string' &&
1744
- trustedRole !== 'editor' &&
1745
- trustedRole !== 'terminal' &&
1746
- trustedRole !== 'maintainer'
1747
- ) {
1748
- throw new Error(`The ${trustedRole} share role cannot edit source.`);
1749
- }
1750
1158
  services.collaboration(activeProjectRoot()).assertSourceMutation(
1751
1159
  participantId,
1752
1160
  expectedRevision as number,
@@ -1788,8 +1196,8 @@ export function uiOidPlugin(
1788
1196
  '/__ui-source/css': (b) => handleCss(b),
1789
1197
  '/__ui-source/text': (b) => handleText(store, b),
1790
1198
  '/__ui-source/prop': (b) => handleProp(store, b),
1791
- '/__ui-source/struct': (b) => handleStruct(store, b),
1792
- '/__ui-source/struct-many': (b) => handleStructMany(store, b),
1199
+ '/__ui-source/struct': (b) => handleStruct(store, b, 'struct'),
1200
+ '/__ui-source/struct-many': (b) => handleStruct(store, b, 'struct-many'),
1793
1201
  '/__ui-source/csf-story': (b) => handleCsfStory(b, activeProjectRoot()),
1794
1202
  '/__ui-source/named-style': (b) => handleNamedStyle(store, b),
1795
1203
  '/__ui-source/fork-component': (b) =>
@@ -1800,17 +1208,7 @@ export function uiOidPlugin(
1800
1208
  server.middlewares.use(async (req, res, next) => {
1801
1209
  const url = (req.url ?? '').split('?')[0] ?? '';
1802
1210
  if (url === '/__ui-source/index') {
1803
- // Re-resolve contracts from current project bytes on every index
1804
- // read — but OFF the request: a cold enrich is a 2.6 s ts.Program
1805
- // and must not freeze first paint. The handler returns the last
1806
- // enriched map (or the raw stamp) immediately; see currentOidIndex.
1807
- //
1808
- // H5 (wave 4): the R3F
1809
- // authorability DIAGNOSTICS ride this same response, per entry.
1810
- // Deliberately not a channel of their own — attaching them to the
1811
- // payload the R3F adapter's `refreshSourceState()` already fetches is
1812
- // what makes their freshness structurally identical to the index's,
1813
- // with no second fetch, no polling loop and no cache to invalidate.
1211
+ // Serve current stamps or matching cached enrichment while props resolve off-request.
1814
1212
 
1815
1213
  const indexStarted = Date.now();
1816
1214
  const index = currentOidIndex(store, activeProjectRoot(), server);
@@ -1819,34 +1217,15 @@ export function uiOidPlugin(
1819
1217
  'X-Volter-Oid-Index-Enriched',
1820
1218
  index === lastEnrichedIndex?.index ? '1' : '0',
1821
1219
  );
1822
- if (typeof req.headers['x-volter-share-participant-id'] !== 'string') {
1823
- return json(res, index);
1824
- }
1825
- return json(
1826
- res,
1827
- Object.fromEntries(
1828
- Object.entries(index).filter(([, value]) => {
1829
- const file = (value as { file?: unknown } | null)?.file;
1830
- return (
1831
- typeof file === 'string' &&
1832
- isCanonicalProjectSourcePath(activeProjectRoot(), file)
1833
- );
1834
- }),
1835
- ),
1836
- );
1220
+ return json(res, index);
1837
1221
  }
1838
1222
  if (url === '/__ui-source/component-props' && req.method === 'GET') {
1839
- // THE PER-DEFINITION DOOR: the props a prefab DECLARES, asked before
1840
- // any callsite exists. The index above only knows tags already used
1841
- // in a file; a drop needs the declaration itself so a REQUIRED enum
1842
- // prop can be written with its first option instead of nothing
1843
- // (`<WeaponPickup>` rendered NOTHING until `kind` was set — runhuman
1844
- // pass 123). Same worker, same checker, one request.
1223
+ // Resolve declared props before a callsite exists, for component insertion defaults.
1845
1224
  const params = new URL(req.url ?? '', 'http://localhost').searchParams;
1846
1225
  const file = params.get('file') ?? '';
1847
1226
  const name = params.get('name') ?? '';
1848
1227
  const root = activeProjectRoot();
1849
- if (!root || !file || !name) return json(res, { props: [] });
1228
+ if (!file || !name) return json(res, { props: [] });
1850
1229
  const absolute = isAbsolute(file) ? resolve(file) : resolve(root, file);
1851
1230
  if (
1852
1231
  !isCanonicalProjectSourcePath(root, absolute) ||
@@ -1859,7 +1238,7 @@ export function uiOidPlugin(
1859
1238
  const props = await resolveDeclaredProps(root, [
1860
1239
  { key, file: absolute, tag: name, definition: true },
1861
1240
  ]);
1862
- return json(res, { props: props.get(key) ?? [] });
1241
+ return json(res, { props: props.get(key)! });
1863
1242
  } catch (error) {
1864
1243
  return json(res, {
1865
1244
  props: [],