@volter/editor-ui 0.5.210 → 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.
Files changed (31) hide show
  1. package/NOTICE +12 -0
  2. package/contributions/component-board.service.ts +10 -2
  3. package/contributions/design-time-mount.service.ts +39 -2
  4. package/contributions/react-inspector.service.ts +11 -2
  5. package/contributions/story-documents.service.ts +14 -2
  6. package/contributions/ui.page.ts +28 -0
  7. package/package.json +78 -13
  8. package/src/host/authoring/struct-write-pipe.ts +102 -0
  9. package/src/host/components/ResolutionPicker.tsx +83 -0
  10. package/src/host/crash-null-boundary.ts +24 -0
  11. package/src/host/portable-stories.ts +44 -0
  12. package/src/host/projection/dom.ts +40 -0
  13. package/src/host/react-mount-runtime.ts +76 -0
  14. package/src/host/realm-services.ts +53 -0
  15. package/src/host/roots/react-root.ts +82 -0
  16. package/src/host/stories/story-arg-descriptors.ts +50 -0
  17. package/src/host/stories/story-media-presence.ts +89 -0
  18. package/src/host/stories/story-presentation.ts +203 -0
  19. package/src/host/surface-content.ts +44 -0
  20. package/src/react/composition-movie.ts +247 -0
  21. package/src/react/design-time-react-mount.ts +525 -0
  22. package/src/react/react-inspector-section.tsx +2247 -0
  23. package/src/react/react-world-authoring-adapter.ts +2602 -0
  24. package/src/react/story-documents/story-args-section.ts +9 -0
  25. package/src/react/story-documents/story-documents.tsx +1342 -0
  26. package/src/react/story-paint-bounds.ts +94 -0
  27. package/src/react/ui-board-document.tsx +55 -0
  28. package/src/react/ui-board-title.ts +9 -0
  29. package/src/react/ui-component-board.ts +52 -0
  30. package/src/runtime/presentation.ts +60 -0
  31. package/contributions/play-layers.service.ts +0 -31
@@ -0,0 +1,2602 @@
1
+ /** Source authoring over mounted, data-oid-stamped React DOM. The shared
2
+ * projector supplies hierarchy and selection; source writes prepare complete
3
+ * next-file text and commit through checksum-guarded history. Backend absence
4
+ * leaves inspection available and makes writes report their unavailable destination. */
5
+
6
+ import { editorHost } from '@volter/sdk/host';
7
+ import { activeBreakpoint } from '@volter/sdk/breakpoint-state';
8
+ import {
9
+ cssTextForStyleValue,
10
+ numericStyleValue,
11
+ preserveNumericStyleUnit,
12
+ } from '@volter/sdk/css-numeric-style';
13
+ import { WORLD_SCOPE_NODE_ID } from '@volter/sdk/stories-scope';
14
+ import { createStructWritePipe, type StructOpOptions } from '../host/authoring/struct-write-pipe';
15
+ import { getRootPan } from '@volter/sdk/world-pan-state';
16
+ import {
17
+ resolvesLiveOnly,
18
+ runWritePipe,
19
+ type WriteAck,
20
+ type WriteResolution,
21
+ } from '@volter/sdk/write-pipe';
22
+ import { editorConsole } from '@volter/sdk/editor-console';
23
+ import type { ShellStore } from '@volter/sdk/shell-store';
24
+ import { withProjectSourceHistory } from '@volter/sdk/history/source-history-backend';
25
+ import { projectOidDom } from '../host/projection/dom';
26
+ import { storyArgPropertyDescriptors } from '../host/stories/story-arg-descriptors';
27
+ import type { StoryPresentationIndex } from '../host/stories/story-presentation';
28
+ import { showTransientHint } from '@volter/sdk/transient-hint';
29
+ import {
30
+ camelToKebab,
31
+ type DesignToken,
32
+ type EmptyCandidate,
33
+ findClassRuleSource,
34
+ findEmptyContainers,
35
+ firstPartyStylesheetFiles,
36
+ getComponentProps,
37
+ getComputedStyleValue,
38
+ getDesignTokens,
39
+ getMatchedCssRules,
40
+ getReactComponentName,
41
+ } from '@volter/sdk/ui-source/inspect';
42
+ import type { ComponentPropSpec, OidEntry } from '@volter/editor-react/source/oid-transform';
43
+ import { relativeImportSpecifier } from '@volter/editor-react/source/relative-import-specifier';
44
+ import type { SourceWriteBackend } from '@volter/sdk/ui-source/source-write-backend';
45
+ import { writeCsfStory, writeNamedStyle } from '@volter/sdk/ui-source/source-write-backend';
46
+ import {
47
+ type CssRuleTarget,
48
+ namedStyleRuleFor,
49
+ pickCssRuleTarget,
50
+ tokenReferenceGuardText,
51
+ } from '@volter/editor-react/source/writer';
52
+ import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/sdk/workspace-document-ids';
53
+ import { activateWorkspaceDocument } from '@volter/sdk/workspace-document-registry';
54
+ import type {
55
+ AssetDropContext,
56
+ AssetDropProvider,
57
+ AuthoringAdapter,
58
+ AuthoringCapabilities,
59
+ AuthoringProvenance,
60
+ BoxEditProvider,
61
+ ColorSampleProvider,
62
+ ComponentInstanceApplyResult,
63
+ ComponentInstancesProvider,
64
+ DOMRectLike,
65
+ EditorNode,
66
+ HierarchyProvider,
67
+ InspectorProvider,
68
+ NodeCreationSite,
69
+ PersistenceProvider,
70
+ PickProvider,
71
+ PropertyDescriptor,
72
+ RectProvider,
73
+ RelatedSubjectsProvider,
74
+ SelectionProvider,
75
+ StoriesProvider,
76
+ StoryRef,
77
+ StructureProvider,
78
+ TextProvider,
79
+ TruthProvider,
80
+ WriteAnchorKind,
81
+ } from '@volter/project/adapter';
82
+
83
+ /** Live DOM members shared by the mounted root and the focused-story proxy.
84
+ * The proxy exposes the layer's attributes, style and geometry with the focused
85
+ * story's children and text; descendants are the mounted DOM elements themselves. */
86
+ export interface OidElementLike {
87
+ readonly tagName: string;
88
+ /** The focused-story proxy omits the namespace; live DOM descendants supply it. */
89
+ readonly namespaceURI?: string | null;
90
+ readonly children: ArrayLike<Element>;
91
+ getAttribute(name: string): string | null;
92
+ readonly style: CSSStyleDeclaration;
93
+ getBoundingClientRect(): DOMRect;
94
+ /** Rendered text for the client-side leaf check; dynamic bodies are guarded in source. */
95
+ readonly textContent: string | null;
96
+ }
97
+
98
+ const MAX_ELEMENT_LABEL_LENGTH = 64;
99
+
100
+ /** Compact the browser's semantic text into a stable hierarchy-row label. */
101
+ function normalizeElementText(value: string | null | undefined): string | null {
102
+ const text = value?.replace(/\s+/g, ' ').trim();
103
+ if (!text) return null;
104
+ if (text.length <= MAX_ELEMENT_LABEL_LENGTH) return text;
105
+ return `${text.slice(0, MAX_ELEMENT_LABEL_LENGTH - 1).trimEnd()}…`;
106
+ }
107
+
108
+ /** Read a camelCase inline style property from its live declaration. */
109
+ function styleProp(style: CSSStyleDeclaration, prop: string): string {
110
+ return style.getPropertyValue(camelToKebab(prop));
111
+ }
112
+
113
+ const RGB_RE = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/;
114
+
115
+ /** Normalize hex and rgb/rgba CSS colors into the #rrggbb shape accepted by
116
+ * color inputs. Unsupported values return undefined. */
117
+ export function cssColorToHex(raw: unknown): string | undefined {
118
+ if (typeof raw !== 'string' || raw === '') return undefined;
119
+ if (raw.startsWith('#')) return raw;
120
+ const m = RGB_RE.exec(raw);
121
+ if (!m) return undefined;
122
+ const toHex = (n: string) => Number.parseInt(n, 10).toString(16).padStart(2, '0');
123
+ return `#${toHex(m[1]!)}${toHex(m[2]!)}${toHex(m[3]!)}`;
124
+ }
125
+
126
+ /** CSS property and preview-value mapping for a box-edit patch. */
127
+ interface BoxEditPropMapping {
128
+ /** The CSS style prop to write (already the target, e.g. `x` → `left`). */
129
+ prop: string;
130
+ /** Live-preview CSS VALUE for a raw patch number — always px-suffixed for
131
+ * spatial/spacing props, the `rotate(<deg>deg)` transform string for `rotate`. */
132
+ cssValue: (v: number) => string;
133
+ }
134
+
135
+ /** Map spatial box-edit keys to CSS properties. x/y require positioned
136
+ * elements; rotate becomes a transform string. Unsupported keys return null. */
137
+ function mapBoxEditPatchKey(key: string, isPositioned: boolean): BoxEditPropMapping | null {
138
+ switch (key) {
139
+ case 'width':
140
+ case 'height':
141
+ case 'marginTop':
142
+ case 'marginRight':
143
+ case 'marginBottom':
144
+ case 'marginLeft':
145
+ case 'paddingTop':
146
+ case 'paddingRight':
147
+ case 'paddingBottom':
148
+ case 'paddingLeft':
149
+ return { prop: key, cssValue: (v) => `${v}px` };
150
+ case 'x':
151
+ return isPositioned ? { prop: 'left', cssValue: (v) => `${v}px` } : null;
152
+ case 'y':
153
+ return isPositioned ? { prop: 'top', cssValue: (v) => `${v}px` } : null;
154
+ case 'rotate':
155
+ return { prop: 'transform', cssValue: (v) => `rotate(${v}deg)` };
156
+ default:
157
+ return null;
158
+ }
159
+ }
160
+
161
+ const OID_ATTR = 'data-oid';
162
+ const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
163
+ const VOID_HTML_ELEMENTS = new Set([
164
+ 'area',
165
+ 'base',
166
+ 'br',
167
+ 'col',
168
+ 'embed',
169
+ 'hr',
170
+ 'img',
171
+ 'input',
172
+ 'link',
173
+ 'meta',
174
+ 'param',
175
+ 'source',
176
+ 'track',
177
+ 'wbr',
178
+ ]);
179
+
180
+ /** Empty-layout hints are for HTML elements that could receive content.
181
+ * SVG geometry (especially zero-width `line`/`path` bounds) and HTML void
182
+ * elements are leaves by definition, not collapsed layout containers. */
183
+ function canContainDomChildren(el: OidElementLike, tag: string): boolean {
184
+ const namespace = el.namespaceURI;
185
+ if (namespace !== undefined && namespace !== null && namespace !== HTML_NAMESPACE) return false;
186
+ return !VOID_HTML_ELEMENTS.has(tag);
187
+ }
188
+
189
+ function projectSourcePath(path: string): string {
190
+ const normalized = path.replaceAll('\\', '/').replace(/^\.\//, '');
191
+ const marker = normalized.lastIndexOf('/src/');
192
+ return marker >= 0 ? normalized.slice(marker + 1) : normalized;
193
+ }
194
+
195
+ /** One OID-tagged DOM element resolved into the adapter's internal tree. */
196
+ interface OidNode {
197
+ /** Entity id assigned by the shared DOM projector, disambiguating repeated source OIDs. */
198
+ id: string;
199
+ /** Source OID, shared by repeated instances of the same authored element. */
200
+ oid: string;
201
+ tag: string;
202
+ el: OidElementLike;
203
+ parentId: string | null;
204
+ childIds: string[];
205
+ }
206
+
207
+ interface OidTree {
208
+ /** Every OID-tagged node, keyed by its (disambiguated) entity id. */
209
+ nodes: Map<string, OidNode>;
210
+ /** Top-level entity ids (no OID-tagged ancestor) in DOM/tree order. */
211
+ rootIds: string[];
212
+ }
213
+
214
+ /** Derive source OIDs and labels from the projected live DOM. */
215
+ function walkOidTree(root: OidElementLike): OidTree {
216
+ return oidTreeFromProjection(projectOidDom(root));
217
+ }
218
+
219
+ function oidTreeFromProjection(projection: {
220
+ readonly nodes: ReadonlyMap<
221
+ string,
222
+ { object: OidElementLike; parentId: string | null; childIds: readonly string[] }
223
+ >;
224
+ readonly rootIds: readonly string[];
225
+ }): OidTree {
226
+ const nodes = new Map<string, OidNode>();
227
+ for (const [id, node] of projection.nodes) {
228
+ nodes.set(id, {
229
+ id,
230
+ oid: node.object.getAttribute(OID_ATTR) ?? id,
231
+ tag: node.object.tagName.toLowerCase(),
232
+ el: node.object,
233
+ parentId: node.parentId,
234
+ childIds: [...node.childIds],
235
+ });
236
+ }
237
+ return { nodes, rootIds: [...projection.rootIds] };
238
+ }
239
+
240
+ /** Grouped CSS inspector properties supported by the source writer. */
241
+ const STYLE_PROPERTIES: ReadonlyArray<{
242
+ prop: string;
243
+ label: string;
244
+ type: PropertyDescriptor['type'];
245
+ group: string;
246
+ options?: string[];
247
+ }> = [
248
+ // -- Layout --
249
+ {
250
+ prop: 'display',
251
+ label: 'Display',
252
+ type: 'enum',
253
+ group: 'Layout',
254
+ options: ['block', 'flex', 'grid', 'inline', 'inline-block', 'inline-flex', 'none'],
255
+ },
256
+ {
257
+ prop: 'flexDirection',
258
+ label: 'Direction',
259
+ type: 'enum',
260
+ group: 'Layout',
261
+ options: ['row', 'column', 'row-reverse', 'column-reverse'],
262
+ },
263
+ {
264
+ prop: 'flexWrap',
265
+ label: 'Wrap',
266
+ type: 'enum',
267
+ group: 'Layout',
268
+ options: ['nowrap', 'wrap', 'wrap-reverse'],
269
+ },
270
+ {
271
+ prop: 'justifyContent',
272
+ label: 'Justify',
273
+ type: 'enum',
274
+ group: 'Layout',
275
+ options: ['flex-start', 'center', 'flex-end', 'space-between', 'space-around', 'space-evenly'],
276
+ },
277
+ {
278
+ prop: 'alignItems',
279
+ label: 'Align',
280
+ type: 'enum',
281
+ group: 'Layout',
282
+ options: ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
283
+ },
284
+ { prop: 'gap', label: 'Gap', type: 'number', group: 'Layout' },
285
+ // CSS Grid container and item properties retain their native value syntax.
286
+ { prop: 'gridTemplateColumns', label: 'Columns', type: 'string', group: 'Layout' },
287
+ { prop: 'gridTemplateRows', label: 'Rows', type: 'string', group: 'Layout' },
288
+ {
289
+ prop: 'gridAutoFlow',
290
+ label: 'Flow',
291
+ type: 'enum',
292
+ group: 'Layout',
293
+ options: ['row', 'column', 'row dense', 'column dense'],
294
+ },
295
+ { prop: 'rowGap', label: 'Row Gap', type: 'number', group: 'Layout' },
296
+ { prop: 'columnGap', label: 'Col Gap', type: 'number', group: 'Layout' },
297
+ { prop: 'gridColumn', label: 'Grid Col', type: 'string', group: 'Layout' },
298
+ { prop: 'gridRow', label: 'Grid Row', type: 'string', group: 'Layout' },
299
+ {
300
+ prop: 'justifySelf',
301
+ label: 'Justify Self',
302
+ type: 'enum',
303
+ group: 'Layout',
304
+ options: ['auto', 'start', 'center', 'end', 'stretch'],
305
+ },
306
+ {
307
+ prop: 'overflow',
308
+ label: 'Overflow',
309
+ type: 'enum',
310
+ group: 'Layout',
311
+ options: ['visible', 'hidden', 'scroll', 'auto'],
312
+ },
313
+ { prop: 'width', label: 'Width', type: 'number', group: 'Layout' },
314
+ { prop: 'height', label: 'Height', type: 'number', group: 'Layout' },
315
+ { prop: 'minWidth', label: 'Min W', type: 'number', group: 'Layout' },
316
+ { prop: 'minHeight', label: 'Min H', type: 'number', group: 'Layout' },
317
+ { prop: 'maxWidth', label: 'Max W', type: 'number', group: 'Layout' },
318
+ { prop: 'maxHeight', label: 'Max H', type: 'number', group: 'Layout' },
319
+ // -- Position --
320
+ {
321
+ prop: 'position',
322
+ label: 'Position',
323
+ type: 'enum',
324
+ group: 'Position',
325
+ options: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
326
+ },
327
+ { prop: 'top', label: 'Top', type: 'number', group: 'Position' },
328
+ { prop: 'right', label: 'Right', type: 'number', group: 'Position' },
329
+ { prop: 'bottom', label: 'Bottom', type: 'number', group: 'Position' },
330
+ { prop: 'left', label: 'Left', type: 'number', group: 'Position' },
331
+ { prop: 'zIndex', label: 'Z Index', type: 'number', group: 'Position' },
332
+ { prop: 'flexGrow', label: 'Grow', type: 'number', group: 'Position' },
333
+ { prop: 'flexShrink', label: 'Shrink', type: 'number', group: 'Position' },
334
+ { prop: 'flexBasis', label: 'Basis', type: 'string', group: 'Position' },
335
+ {
336
+ prop: 'alignSelf',
337
+ label: 'Self Align',
338
+ type: 'enum',
339
+ group: 'Position',
340
+ options: ['auto', 'stretch', 'flex-start', 'center', 'flex-end', 'baseline'],
341
+ },
342
+ // -- Spacing (per-side) --
343
+ { prop: 'marginTop', label: 'Margin T', type: 'number', group: 'Spacing' },
344
+ { prop: 'marginRight', label: 'Margin R', type: 'number', group: 'Spacing' },
345
+ { prop: 'marginBottom', label: 'Margin B', type: 'number', group: 'Spacing' },
346
+ { prop: 'marginLeft', label: 'Margin L', type: 'number', group: 'Spacing' },
347
+ { prop: 'paddingTop', label: 'Padding T', type: 'number', group: 'Spacing' },
348
+ { prop: 'paddingRight', label: 'Padding R', type: 'number', group: 'Spacing' },
349
+ { prop: 'paddingBottom', label: 'Padding B', type: 'number', group: 'Spacing' },
350
+ { prop: 'paddingLeft', label: 'Padding L', type: 'number', group: 'Spacing' },
351
+ // -- Type --
352
+ { prop: 'color', label: 'Color', type: 'color', group: 'Type' },
353
+ { prop: 'fontSize', label: 'Size', type: 'number', group: 'Type' },
354
+ { prop: 'fontWeight', label: 'Weight', type: 'string', group: 'Type' },
355
+ { prop: 'lineHeight', label: 'Line H', type: 'string', group: 'Type' },
356
+ { prop: 'letterSpacing', label: 'Spacing', type: 'string', group: 'Type' },
357
+ {
358
+ prop: 'textAlign',
359
+ label: 'Align',
360
+ type: 'enum',
361
+ group: 'Type',
362
+ options: ['left', 'center', 'right', 'justify'],
363
+ },
364
+ {
365
+ prop: 'textTransform',
366
+ label: 'Transform',
367
+ type: 'enum',
368
+ group: 'Type',
369
+ options: ['none', 'uppercase', 'lowercase', 'capitalize'],
370
+ },
371
+ { prop: 'fontStyle', label: 'Style', type: 'enum', group: 'Type', options: ['normal', 'italic'] },
372
+ { prop: 'fontFamily', label: 'Font', type: 'string', group: 'Type' },
373
+ {
374
+ prop: 'textDecoration',
375
+ label: 'Decoration',
376
+ type: 'enum',
377
+ group: 'Type',
378
+ options: ['none', 'underline', 'line-through', 'overline'],
379
+ },
380
+ // -- Fill --
381
+ { prop: 'backgroundColor', label: 'Background', type: 'color', group: 'Fill' },
382
+ // Background longhands keep computed reads consistent with the authored CSS model.
383
+ { prop: 'backgroundImage', label: 'Fills', type: 'string', group: 'Fill' },
384
+ // Image-layer companions apply to the complete background stack.
385
+ {
386
+ prop: 'backgroundSize',
387
+ label: 'Size',
388
+ type: 'enum',
389
+ group: 'Fill',
390
+ options: ['auto', 'cover', 'contain'],
391
+ },
392
+ { prop: 'backgroundPosition', label: 'Pos', type: 'string', group: 'Fill' },
393
+ {
394
+ prop: 'backgroundRepeat',
395
+ label: 'Repeat',
396
+ type: 'enum',
397
+ group: 'Fill',
398
+ options: ['repeat', 'no-repeat', 'repeat-x', 'repeat-y'],
399
+ },
400
+ // -- Stroke --
401
+ { prop: 'borderColor', label: 'Border Color', type: 'color', group: 'Stroke' },
402
+ { prop: 'borderWidth', label: 'Border Width', type: 'number', group: 'Stroke' },
403
+ {
404
+ prop: 'borderStyle',
405
+ label: 'Border Style',
406
+ type: 'enum',
407
+ group: 'Stroke',
408
+ options: ['none', 'solid', 'dashed', 'dotted', 'double'],
409
+ },
410
+ { prop: 'borderRadius', label: 'Radius', type: 'number', group: 'Stroke' },
411
+ // -- Effects --
412
+ { prop: 'opacity', label: 'Opacity', type: 'number', group: 'Effects' },
413
+ { prop: 'boxShadow', label: 'Box Shadow', type: 'string', group: 'Effects' },
414
+ {
415
+ prop: 'mixBlendMode',
416
+ label: 'Blend',
417
+ type: 'enum',
418
+ group: 'Effects',
419
+ options: [
420
+ 'normal',
421
+ 'multiply',
422
+ 'screen',
423
+ 'overlay',
424
+ 'darken',
425
+ 'lighten',
426
+ 'color-dodge',
427
+ 'color-burn',
428
+ 'hard-light',
429
+ 'soft-light',
430
+ 'difference',
431
+ 'exclusion',
432
+ 'hue',
433
+ 'saturation',
434
+ 'color',
435
+ 'luminosity',
436
+ ],
437
+ },
438
+ { prop: 'filter', label: 'Filter', type: 'string', group: 'Effects' },
439
+ { prop: 'backdropFilter', label: 'Backdrop', type: 'string', group: 'Effects' },
440
+ { prop: 'textShadow', label: 'Text Shadow', type: 'string', group: 'Effects' },
441
+ {
442
+ prop: 'cursor',
443
+ label: 'Cursor',
444
+ type: 'enum',
445
+ group: 'Effects',
446
+ options: [
447
+ 'auto',
448
+ 'default',
449
+ 'pointer',
450
+ 'text',
451
+ 'move',
452
+ 'wait',
453
+ 'help',
454
+ 'crosshair',
455
+ 'not-allowed',
456
+ 'grab',
457
+ 'grabbing',
458
+ 'zoom-in',
459
+ 'zoom-out',
460
+ 'col-resize',
461
+ 'row-resize',
462
+ 'none',
463
+ ],
464
+ },
465
+ // -- Transform --
466
+ { prop: 'transform', label: 'Transform', type: 'string', group: 'Transform' },
467
+ ];
468
+ const STYLE_PATH_PREFIX = 'style.';
469
+ /** Where this adapter's writes land — the ONE spelling, read by both the
470
+ * save-status provider and the per-edit ack the pipe returns, so the two can
471
+ * never name different places. */
472
+ const JSX_SOURCE_DESTINATION =
473
+ 'component source (JSX, via /__ui-source — writes are immediate; nothing pending to flush)';
474
+ /** …and the honest floor when no writer is bound. */
475
+ const NO_BACKEND_DESTINATION = 'component source (JSX) — no source-write backend in this session';
476
+ /** Inspector path prefix for editable component props. */
477
+ const PROP_PATH_PREFIX = 'prop.';
478
+ /** Inspector path prefix for document design tokens. */
479
+ const TOKEN_PATH_PREFIX = 'token.';
480
+ const PORTABLE_STORY_ARG_PATH_PREFIX = 'story.arg.';
481
+ /** Session guard key for text edits, which have no property descriptor. */
482
+ const TEXT_PATH = 'text';
483
+ /** Watch selection for 30 × 40ms (1.2s) after an HMR update. */
484
+ const SELECTION_REPAIR_ATTEMPTS = 30;
485
+ /** Report selection repair once per session while restoring resolvable IDs. */
486
+ let reportedSelectionRepair = false;
487
+ const SELECTION_REPAIR_INTERVAL_MS = 40;
488
+
489
+ const DYNAMIC_EXPRESSION_GUARD =
490
+ 'The source value is a dynamic expression, so replacing it with a literal is guarded.';
491
+
492
+ /** CSS shorthand ownership. Per-side writes expand and remove governing
493
+ * shorthands so React receives one vocabulary for each property. Nested
494
+ * shorthands expand from outermost to innermost. */
495
+ const SHORTHAND_LONGHANDS: Readonly<Record<string, readonly string[]>> = {
496
+ border: ['borderWidth', 'borderStyle', 'borderColor'],
497
+ borderWidth: ['borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth'],
498
+ borderStyle: ['borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle'],
499
+ borderColor: ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'],
500
+ borderRadius: [
501
+ 'borderTopLeftRadius',
502
+ 'borderTopRightRadius',
503
+ 'borderBottomRightRadius',
504
+ 'borderBottomLeftRadius',
505
+ ],
506
+ padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
507
+ margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
508
+ inset: ['top', 'right', 'bottom', 'left'],
509
+ };
510
+
511
+ /** Longhand -> its immediate shorthand, derived from the table above so the
512
+ * two directions cannot disagree. */
513
+ const SHORTHAND_OF: ReadonlyMap<string, string> = new Map(
514
+ Object.entries(SHORTHAND_LONGHANDS).flatMap(([shorthand, longhands]) =>
515
+ longhands.map((longhand) => [longhand, shorthand] as const),
516
+ ),
517
+ );
518
+
519
+ /** The shorthands covering `prop`, OUTERMOST FIRST — `borderTopWidth` gives
520
+ * `['border', 'borderWidth']`. Empty for a property no shorthand owns. */
521
+ function shorthandChain(prop: string): readonly string[] {
522
+ const chain: string[] = [];
523
+ for (let cursor = SHORTHAND_OF.get(prop); cursor; cursor = SHORTHAND_OF.get(cursor)) {
524
+ chain.unshift(cursor);
525
+ }
526
+ return chain;
527
+ }
528
+
529
+ /** `prop -> declared type`, so `inspector.get()` knows when to run a style
530
+ * value through {@link numericStyleValue} instead of handing it back raw
531
+ * (a real CSSOM length value is always a unit-suffixed string). */
532
+ const STYLE_PROPERTY_TYPE: ReadonlyMap<string, PropertyDescriptor['type']> = new Map(
533
+ STYLE_PROPERTIES.map(({ prop, type }) => [prop, type]),
534
+ );
535
+
536
+ /** Map side and corner longhands to their controlling shorthand for echo
537
+ * updates after removing an override. */
538
+ const LONGHAND_TO_SHORTHAND: Readonly<Record<string, string>> = {
539
+ borderTopLeftRadius: 'borderRadius',
540
+ borderTopRightRadius: 'borderRadius',
541
+ borderBottomLeftRadius: 'borderRadius',
542
+ borderBottomRightRadius: 'borderRadius',
543
+ borderTopWidth: 'borderWidth',
544
+ borderRightWidth: 'borderWidth',
545
+ borderBottomWidth: 'borderWidth',
546
+ borderLeftWidth: 'borderWidth',
547
+ borderTopStyle: 'borderStyle',
548
+ borderRightStyle: 'borderStyle',
549
+ borderBottomStyle: 'borderStyle',
550
+ borderLeftStyle: 'borderStyle',
551
+ borderTopColor: 'borderColor',
552
+ borderRightColor: 'borderColor',
553
+ borderBottomColor: 'borderColor',
554
+ borderLeftColor: 'borderColor',
555
+ };
556
+
557
+ /** Curated HTML kinds offered by structure.create. The source writer emits
558
+ * self-closing JSX tags for these names. */
559
+ const CREATABLE_KINDS: ReadonlyArray<{ kind: string; label: string }> = [
560
+ { kind: 'div', label: 'Container' },
561
+ { kind: 'span', label: 'Text' },
562
+ { kind: 'p', label: 'Paragraph' },
563
+ { kind: 'button', label: 'Button' },
564
+ { kind: 'a', label: 'Link' },
565
+ { kind: 'img', label: 'Image' },
566
+ { kind: 'ul', label: 'List' },
567
+ { kind: 'li', label: 'List Item' },
568
+ ];
569
+
570
+ type DomAssetDropPlan =
571
+ | {
572
+ ok: true;
573
+ parentId: string;
574
+ snippet: string;
575
+ ensureImport: { name: string; module: string; kind: 'default' | 'named' };
576
+ }
577
+ | { ok: false; reason: string };
578
+
579
+ function serializedComponentValue(value: unknown, spec: ComponentPropSpec): string {
580
+ if (spec.type === 'number') return String(Number(value));
581
+ if (spec.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false';
582
+ if (spec.type === 'vec3' && Array.isArray(value)) {
583
+ return `[${value.map((entry) => Number(entry)).join(', ')}]`;
584
+ }
585
+ if (spec.type === 'json') return JSON.stringify(value);
586
+ return JSON.stringify(String(value));
587
+ }
588
+
589
+ interface ReactRootAuthoringOptions {
590
+ /** Source-write transport. Absent in sessions without source-write support. */
591
+ writeBackend?: SourceWriteBackend | undefined;
592
+ /** Design tokens visible in this root's CSS scope. */
593
+ designTokens?: (() => DesignToken[]) | undefined;
594
+ /** Portable CSF stories associated with this React root. */
595
+ portableStories?: ReadonlyArray<PortableStoryRef> | undefined;
596
+ /** Native Storybook Category → Folder → Component index. When present,
597
+ * both the hierarchy and the board consume this same derived projection;
598
+ * CSF remains the only authored data. */
599
+ portableHierarchy?: StoryPresentationIndex | undefined;
600
+ /** Initially mounted portable story id (normally the explicit/default CSF
601
+ * story selected by the design-time layer). */
602
+ activePortableStoryId?: string | undefined;
603
+ /** Re-render hook for a portable CSF selection. The design-time layer owns
604
+ * Storybook mounting; this adapter owns only selection/provider routing. */
605
+ onPortableStoryApplied?: ((storyId: string | null) => void) | undefined;
606
+ /** Session-scoped Storybook Controls write. The design-time layer remounts
607
+ * the same composed story with these args; CSF remains the canonical
608
+ * definition and Reset restores its composed defaults. */
609
+ onPortableStoryArgsChanged?:
610
+ | ((storyId: string, args: Record<string, unknown>) => void)
611
+ | undefined;
612
+
613
+ }
614
+
615
+ interface PortableStoryRef extends StoryRef {
616
+ args: Readonly<Record<string, unknown>>;
617
+ /** CSF export name addressed by save-as, rename and delete. */
618
+ name: string;
619
+ /** Project-relative source path. */
620
+ modulePath: string;
621
+ }
622
+
623
+ /** The default: this adapter's own truth is the component's JSX source. */
624
+ const JSX_SOURCE_PROVENANCE: AuthoringProvenance = {
625
+ source: 'source-code',
626
+ label: 'jsx',
627
+ detail: 'The JSX source is the document — edits write back to the component source file.',
628
+ };
629
+
630
+ /** The element's literal class tokens, off the live DOM `class` attribute. */
631
+ function classTokensOf(el: OidElementLike): string[] {
632
+ const raw = el.getAttribute('class') ?? '';
633
+ return raw.split(/\s+/).filter(Boolean);
634
+ }
635
+
636
+ /** Does the LIVE inline style declare this property? Deliberately the live
637
+ * read, not the source: a preview-patched inline value also wins the cascade,
638
+ * so the conservative answer keeps a class-routed write from landing a
639
+ * declaration that never paints. */
640
+ function liveInlineDeclares(el: OidElementLike, prop: string): boolean {
641
+ return el.style.getPropertyValue(camelToKebab(prop)) !== '';
642
+ }
643
+
644
+ export class ReactRootAuthoringAdapter implements AuthoringAdapter {
645
+ readonly capabilities: AuthoringCapabilities;
646
+
647
+ readonly provenance = JSX_SOURCE_PROVENANCE;
648
+ private readonly writeBackend: SourceWriteBackend | undefined;
649
+ private hierarchySnapshotCache: OidTree | null = null;
650
+ private hierarchySnapshotClearQueued = false;
651
+ private readonly matchedCssRules = (el: unknown): CssRuleTarget[] =>
652
+ getMatchedCssRules(el as Element) as CssRuleTarget[];
653
+ private readonly designTokens: () => DesignToken[];
654
+ private readonly designTokenValue: (name: string) => string | undefined;
655
+ /** OID → {component, tag, …} labels, fetched once (if a backend exists) from the
656
+ * SAME `/__ui-source/index` the OID store already serves — see `index()` on
657
+ * `SourceWriteBackend`. Absent/unresolved ⇒ nodes label from the DOM tag alone
658
+ * (honest degradation, not a second index). */
659
+ private oidIndex: Map<string, OidEntry> = new Map();
660
+ private dirty = false;
661
+ /** Optimistic inspector values, keyed by node and full property path.
662
+ * Writes populate them before awaiting source; refusals clear individual
663
+ * entries, and HMR reconciliation clears the remaining echo. */
664
+ private readonly valueEcho = new Map<string, Map<string, unknown>>();
665
+ /** Session-scoped source guard reasons. A refused dynamic write disables
666
+ * that field for subsequent edits; this does not predict source literality. */
667
+ private readonly guardedPaths = new Map<string, string>();
668
+ /** Session-local pick locks. Canvas click and marquee skip locked nodes,
669
+ * while hierarchy selection remains available for unlocking. */
670
+ private readonly lockedIds = new Set<string>();
671
+ /** Successful structural/text writes awaiting their post-HMR DOM refresh.
672
+ * Each landed update notifies the store and drains one pending reconcile. */
673
+ private pendingSourceReconcile = 0;
674
+ /**
675
+ * The last non-empty selection this adapter's tree served — the input to
676
+ * {@link repairSelectionAfterReprojection}.
677
+ */
678
+ private lastResolvedSelection: readonly string[] = [];
679
+ /** One repair watcher per re-projection; see {@link scheduleSelectionRepair}. */
680
+ private selectionRepairWatching = false;
681
+ /** Release the Vite HMR or hosted story-registry reconciliation subscription. */
682
+ private readonly disposeReloadSignal: () => void;
683
+ // Rename and delete update this list before discovery republishes it.
684
+ private readonly portableStories: PortableStoryRef[];
685
+ private readonly portableStoryArgs = new Map<string, Record<string, unknown>>();
686
+ private readonly portableHierarchy: StoryPresentationIndex | null;
687
+ private readonly portableDefaultStoryId: string | null;
688
+ private readonly onPortableStoryApplied: ((storyId: string | null) => void) | undefined;
689
+ private readonly onPortableStoryArgsChanged:
690
+ | ((storyId: string, args: Record<string, unknown>) => void)
691
+ | undefined;
692
+ /** The currently applied portable story, or `null` when this root has no
693
+ * portable CSF document. */
694
+ private activeStoryId: string | null = null;
695
+ /** Current box gesture. touched keeps the final value per CSS property;
696
+ * priorInline captures each property before its first live preview. */
697
+ private boxEditSession: {
698
+ id: string;
699
+ touched: Map<string, string | number>;
700
+ priorInline: Map<string, string>;
701
+ /** The one hint this gesture already showed for a move it cannot write. */
702
+ hinted?: boolean;
703
+ } | null = null;
704
+
705
+ /**
706
+ * Serializes {@link commitBoxEdit} runs so two gestures never overlap.
707
+ *
708
+ * `boxEdit.end()` fire-and-forgets an ASYNC commit (it cannot await — the
709
+ * gesture API is synchronous, driven straight from pointer/key handlers), and
710
+ * `editor-hotkeys.ts` drives `apply()`+`end()` on EVERY keypress. Hold an
711
+ * arrow key, or align a multi-selection, and the second commit calls
712
+ * a second source-history gesture while the first is still awaiting its
713
+ * writes. Chaining here keeps adapter preview/echo updates ordered too; the
714
+ * scoped history backend independently serializes each complete callback.
715
+ */
716
+ private boxEditTail: Promise<void> = Promise.resolve();
717
+
718
+ constructor(
719
+ private readonly root: OidElementLike,
720
+ private readonly store: ShellStore,
721
+ opts: ReactRootAuthoringOptions,
722
+ ) {
723
+ this.writeBackend = withProjectSourceHistory(opts.writeBackend, store.projectHistory);
724
+ // Read project custom properties within this root's scoped CSS container.
725
+ const tokenStyle = () => {
726
+ const root = this.root as unknown as Element;
727
+ // Custom properties inherit from the mounted CSS scope below this layer.
728
+ // Read that scope to include the authored token values.
729
+ const el = root.querySelector('[data-volter-mount-styles]') ??
730
+ root.closest('[data-volter-mount-styles]') ?? root;
731
+ return getComputedStyle(el);
732
+ };
733
+ this.designTokens = opts.designTokens ?? (() => getDesignTokens(tokenStyle()));
734
+ // Read one custom property per field so same-turn CSS edits stay visible.
735
+ this.designTokenValue = opts.designTokens
736
+ ? (name) => opts.designTokens!().find((token) => token.name === name)?.value
737
+ : (name) => tokenStyle().getPropertyValue(name).trim() || undefined;
738
+ this.portableStories = [...(opts.portableStories ?? [])];
739
+ for (const story of this.portableStories) {
740
+ this.portableStoryArgs.set(story.id, { ...story.args });
741
+ }
742
+ this.portableHierarchy = opts.portableHierarchy ?? null;
743
+ this.portableDefaultStoryId = opts.activePortableStoryId ?? null;
744
+ this.onPortableStoryApplied = opts.onPortableStoryApplied;
745
+ this.onPortableStoryArgsChanged = opts.onPortableStoryArgsChanged;
746
+ this.activeStoryId = this.portableDefaultStoryId;
747
+ this.capabilities = {
748
+ transform: false, // no 3D gizmo — DOM has no Object3D pose
749
+ inspectorFields: true,
750
+ persist: true,
751
+ };
752
+ // Reconcile optimistic values when HMR or the hosted story registry publishes the updated tree.
753
+ const hot = import.meta.hot;
754
+ if (hot) {
755
+ const onAfterUpdate = (): void => this.reconcileEchoAfterReload();
756
+ hot.on('vite:afterUpdate', onAfterUpdate);
757
+ this.disposeReloadSignal = () => hot.off('vite:afterUpdate', onAfterUpdate);
758
+ } else {
759
+ // Hosted writes refresh story modules; registry publication signals the
760
+ // resulting DOM projection for echo and selection reconciliation.
761
+ const unsubscribe = editorHost().stories.subscribe(() => this.reconcileEchoAfterReload());
762
+ this.disposeReloadSignal = unsubscribe;
763
+ }
764
+ if (this.writeBackend) {
765
+ this.writeBackend
766
+ .index()
767
+ .then((idx) => {
768
+ this.oidIndex = new Map(Object.entries(idx));
769
+ this.store.notifyIngestEdit();
770
+ })
771
+ .catch(() => {
772
+ // Honest degradation: labels stay tag-only if the index can't be fetched.
773
+ });
774
+ }
775
+ }
776
+
777
+ private snapshot(): OidTree {
778
+ return walkOidTree(this.root);
779
+ }
780
+
781
+ /**
782
+ * One coherent tree for a synchronous hierarchy consumer. Composite roots
783
+ * walk every child adapter by calling `node(id)` once per row; projecting the
784
+ * complete DOM on every lookup turns that ordinary walk into O(rows²).
785
+ * Inspector and write paths keep using {@link snapshot} directly, so only
786
+ * hierarchy reads share this task-bounded view and every later task sees the
787
+ * current DOM.
788
+ */
789
+ private hierarchySnapshot(): OidTree {
790
+ this.hierarchySnapshotCache ??= this.snapshot();
791
+ if (!this.hierarchySnapshotClearQueued) {
792
+ this.hierarchySnapshotClearQueued = true;
793
+ queueMicrotask(() => {
794
+ this.hierarchySnapshotCache = null;
795
+ this.hierarchySnapshotClearQueued = false;
796
+ });
797
+ }
798
+ return this.hierarchySnapshotCache;
799
+ }
800
+
801
+ private async refreshOidIndex(): Promise<void> {
802
+ const index = await this.writeBackend?.index();
803
+ if (!index) return;
804
+ this.oidIndex = new Map(Object.entries(index));
805
+ this.store.notifyIngestEdit();
806
+ }
807
+
808
+ // --- Optimistic inspector values ---
809
+ private setEcho(id: string, path: string, value: unknown): void {
810
+ let byPath = this.valueEcho.get(id);
811
+ if (!byPath) {
812
+ byPath = new Map();
813
+ this.valueEcho.set(id, byPath);
814
+ }
815
+ byPath.set(path, value);
816
+ }
817
+
818
+ /** Drop one echoed entry — used the instant a write is refused/no-op'd, so the field
819
+ * reverts to the live-DOM (unchanged) value rather than sticking on a value the source
820
+ * never took (the "stale in the other direction" guard for rejected writes). */
821
+ private clearEcho(id: string, path: string): void {
822
+ const byPath = this.valueEcho.get(id);
823
+ if (!byPath) return;
824
+ byPath.delete(path);
825
+ if (byPath.size === 0) this.valueEcho.delete(id);
826
+ }
827
+
828
+ /** Remember the source guard's reason for this node and property path. */
829
+ private markGuarded(id: string, path: string, reason: string): void {
830
+ this.guardedPaths.set(`${id}|${path}`, reason);
831
+ }
832
+
833
+ /** Read-through for `inspector.get`: `{hit:true}` when this id+path was optimistically
834
+ * echoed and not yet reconciled; `{hit:false}` otherwise (fall back to the live DOM). A
835
+ * distinct `hit` flag (not a sentinel value) so a legitimately-`undefined` echoed value
836
+ * still wins over the live-DOM walk. */
837
+ private readEcho(id: string, path: string): { hit: boolean; value: unknown } {
838
+ const byPath = this.valueEcho.get(id);
839
+ if (byPath?.has(path)) return { hit: true, value: byPath.get(path) };
840
+ return { hit: false, value: undefined };
841
+ }
842
+
843
+ /** Clear optimistic values after HMR, notify pending structural/text
844
+ * changes, and schedule bounded selection repair for the remounted tree. */
845
+ reconcileEchoAfterReload(): void {
846
+ // BEFORE the early return: a re-projection triggered by a source edit this
847
+ // adapter did NOT make (a hand edit, another lane's write) has neither an
848
+ // echo nor a pending reconcile, and it drops the selection exactly the
849
+ // same way.
850
+ this.scheduleSelectionRepair();
851
+ if (this.valueEcho.size === 0 && this.pendingSourceReconcile === 0) return;
852
+ this.valueEcho.clear();
853
+ if (this.pendingSourceReconcile > 0) this.pendingSourceReconcile--;
854
+ this.store.notifyIngestEdit();
855
+ }
856
+
857
+ /** Watch the remounted tree for lost selection during a bounded interval.
858
+ * Restore only IDs that still resolve and report once per watch. */
859
+ private scheduleSelectionRepair(): void {
860
+ if (this.selectionRepairWatching) return;
861
+ if (this.lastResolvedSelection.length === 0) return;
862
+ this.selectionRepairWatching = true;
863
+ let attempts = 0;
864
+ let reported = false;
865
+ const tick = (): void => {
866
+ attempts++;
867
+ // Cover every update in the window; repeated repairs share one report.
868
+ reported = this.repairSelectionAfterReprojection(reported) || reported;
869
+ if (attempts >= SELECTION_REPAIR_ATTEMPTS) {
870
+ this.selectionRepairWatching = false;
871
+ return;
872
+ }
873
+ setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
874
+ };
875
+ setTimeout(tick, SELECTION_REPAIR_INTERVAL_MS);
876
+ }
877
+
878
+ /**
879
+ * One repair attempt. Returns whether it SAID something, so the watcher can
880
+ * report once and restore as many times as the re-projection needs.
881
+ *
882
+ * The remembered set is NOT consumed: `selection.get` overwrites it with
883
+ * every non-empty selection it serves, so it always names the last selection
884
+ * the author actually had, and a second drop inside the same window is
885
+ * repaired from the same truth.
886
+ */
887
+ private repairSelectionAfterReprojection(alreadyReported: boolean): boolean {
888
+ const remembered = this.lastResolvedSelection;
889
+ if (remembered.length === 0) return false;
890
+ if (this.store.selectedEntityIds.size > 0) return false; // not lost (yet)
891
+ const tree = this.snapshot();
892
+ const alive = remembered.filter((id) => tree.nodes.has(id));
893
+ if (alive.length === 0) {
894
+ if (!alreadyReported) {
895
+ console.warn(
896
+ '[ReactRootAuthoringAdapter] the re-projection dropped the selection ' +
897
+ `(${remembered.join(', ')}) and none of those ids resolve in the remounted tree, so ` +
898
+ 'it cannot be restored. An inspector write issued now has no subject — re-select.',
899
+ );
900
+ }
901
+ this.lastResolvedSelection = [];
902
+ return true;
903
+ }
904
+ if (!alreadyReported && !reportedSelectionRepair) {
905
+ reportedSelectionRepair = true;
906
+ console.warn(
907
+ '[ReactRootAuthoringAdapter] OPEN DEFECT, repaired: an HMR re-projection drops this ' +
908
+ "root's selection, and the editor is putting it back — restoring " +
909
+ `${alive.length} of ${remembered.length} id(s) that still resolve in the remounted ` +
910
+ `tree (${alive.join(', ')}). A write issued in that window has no subject. Named once ` +
911
+ 'per session; the repair runs on every re-projection.',
912
+ );
913
+ }
914
+ this.store.selectMultiple([...alive]);
915
+ return true;
916
+ }
917
+
918
+ /** Release the HMR or story-registry subscription. Safe to call repeatedly. */
919
+ disposeReactRootAdapter(): void {
920
+ this.disposeReloadSignal();
921
+ }
922
+
923
+ private componentName(n: OidNode): string | null {
924
+ const entry = this.oidIndex.get(n.oid);
925
+ return entry?.component ?? getReactComponentName(n.el) ?? null;
926
+ }
927
+
928
+ private elementLabel(n: OidNode, tree: OidTree): string {
929
+ // Preserve the explicit escape hatch, but make ordinary DOM semantics
930
+ // sufficient by default. Authored React should not need component splits
931
+ // or `data-volter-name` merely to produce a legible hierarchy.
932
+ const editorLabel = normalizeElementText(n.el.getAttribute('data-volter-name'));
933
+ if (editorLabel) return editorLabel;
934
+
935
+ const labelledBy = n.el.getAttribute('aria-labelledby');
936
+ if (labelledBy) {
937
+ const labels = labelledBy
938
+ .split(/\s+/)
939
+ .map((id) =>
940
+ Array.from(tree.nodes.values()).find(
941
+ (candidate) => candidate.el.getAttribute('id') === id,
942
+ ),
943
+ )
944
+ .map((candidate) => normalizeElementText(candidate?.el.textContent))
945
+ .filter((label): label is string => label !== null);
946
+ const combinedLabel = normalizeElementText(labels.join(' '));
947
+ if (combinedLabel) return combinedLabel;
948
+ }
949
+
950
+ const accessibleLabel = normalizeElementText(n.el.getAttribute('aria-label'));
951
+ if (accessibleLabel) return accessibleLabel;
952
+
953
+ // A semantic section without explicit ARIA normally takes its identity
954
+ // from its heading. Restrict this to direct children so a large nested
955
+ // subtree cannot accidentally lend an unrelated heading to its parent.
956
+ if (['section', 'article', 'aside', 'nav', 'main', 'header', 'footer'].includes(n.tag)) {
957
+ const heading = n.childIds
958
+ .map((id) => tree.nodes.get(id))
959
+ .find((child) => child !== undefined && /^h[1-6]$/.test(child.tag));
960
+ const headingText = normalizeElementText(heading?.el.textContent);
961
+ if (headingText) return headingText;
962
+ }
963
+
964
+ // Text-bearing HTML elements are already named by their content in the
965
+ // browser/accessibility model. Use the same identity, with a short cap so
966
+ // a paragraph cannot turn into an unbounded hierarchy row.
967
+ if (
968
+ /^(h[1-6]|p|span|label|button|a|time|output|dt|dd|li|legend|caption|summary)$/.test(n.tag)
969
+ ) {
970
+ const text = normalizeElementText(n.el.textContent);
971
+ if (text) return text;
972
+ }
973
+
974
+ for (const attribute of ['alt', 'title', 'placeholder', 'name']) {
975
+ const value = normalizeElementText(n.el.getAttribute(attribute));
976
+ if (value) return value;
977
+ }
978
+
979
+ const id = n.el.getAttribute('id');
980
+ if (id) return `#${id}`;
981
+ // Use the first CSS class to distinguish anonymous elements sharing a tag.
982
+ const firstClass = (n.el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean)[0];
983
+ if (firstClass) return `${n.tag}.${firstClass}`;
984
+ return n.tag;
985
+ }
986
+
987
+ private toEditorNode(n: OidNode, tree: OidTree): EditorNode {
988
+ // A component name marks only the boundary where that component enters the
989
+ // native tree. Descendants owned by the same component use their own DOM
990
+ // identity, so a component name is not repeated on every row.
991
+ const component = this.componentName(n);
992
+ const parent = n.parentId ? tree.nodes.get(n.parentId) : null;
993
+ const parentComponent = parent ? this.componentName(parent) : null;
994
+ const isComponentBoundary = component !== null && component !== parentComponent;
995
+ const crossSurfaceId = n.el.getAttribute('data-volter-hierarchy-id');
996
+ const crossSurfaceParentId = n.el.getAttribute('data-volter-hierarchy-parent-id');
997
+ const crossSurfaceOrderAttribute = n.el.getAttribute('data-volter-hierarchy-order');
998
+ const crossSurfaceGroupLabel = n.el.getAttribute('data-volter-hierarchy-group-label');
999
+ const crossSurfaceOrder =
1000
+ crossSurfaceOrderAttribute === null ? undefined : Number(crossSurfaceOrderAttribute);
1001
+ const node: EditorNode = {
1002
+ id: n.id,
1003
+ label: isComponentBoundary ? component : this.elementLabel(n, tree),
1004
+ ...(isComponentBoundary ? { secondaryLabel: `<${n.tag}>` } : {}),
1005
+ role: isComponentBoundary ? 'component' : 'element',
1006
+ kind: n.tag,
1007
+ parentId: n.parentId,
1008
+ childIds: n.childIds,
1009
+ ...(crossSurfaceId === null ? {} : { crossSurfaceId }),
1010
+ ...(crossSurfaceParentId === null ? {} : { crossSurfaceParentId }),
1011
+ ...(crossSurfaceOrder === undefined || !Number.isInteger(crossSurfaceOrder)
1012
+ ? {}
1013
+ : { crossSurfaceOrder }),
1014
+ ...(crossSurfaceGroupLabel === null ? {} : { crossSurfaceGroupLabel }),
1015
+ };
1016
+ if (this.portableStories.length > 0 && node.parentId === null) {
1017
+ return { ...node, parentId: this.activeStoryId };
1018
+ }
1019
+ return node;
1020
+ }
1021
+
1022
+ /** Exact DOM-native input signature for the composite's semantic join.
1023
+ * Shell notifications unrelated to this root leave it unchanged. The
1024
+ * nearest semantic ancestor and traversal order are included because
1025
+ * transparent DOM wrappers fold between explicitly identified rows. */
1026
+ private crossSurfaceStructureSignature(): string | null {
1027
+ const tree = this.hierarchySnapshot();
1028
+ const semanticIds = new Set<string>();
1029
+ for (const node of tree.nodes.values()) {
1030
+ if (
1031
+ node.el.getAttribute('data-volter-hierarchy-id') !== null ||
1032
+ node.el.getAttribute('data-volter-hierarchy-parent-id') !== null ||
1033
+ node.el.getAttribute('data-volter-hierarchy-order') !== null ||
1034
+ node.el.getAttribute('data-volter-hierarchy-group-label') !== null
1035
+ ) {
1036
+ semanticIds.add(node.id);
1037
+ }
1038
+ }
1039
+ if (semanticIds.size === 0) return null;
1040
+ return JSON.stringify(
1041
+ [...tree.nodes.values()].flatMap((node) => {
1042
+ if (!semanticIds.has(node.id)) return [];
1043
+ let parentId = node.parentId;
1044
+ while (parentId !== null && !semanticIds.has(parentId)) {
1045
+ parentId = tree.nodes.get(parentId)?.parentId ?? null;
1046
+ }
1047
+ return [
1048
+ [
1049
+ node.id,
1050
+ parentId,
1051
+ node.el.getAttribute('data-volter-hierarchy-id'),
1052
+ node.el.getAttribute('data-volter-hierarchy-parent-id'),
1053
+ node.el.getAttribute('data-volter-hierarchy-order'),
1054
+ node.el.getAttribute('data-volter-hierarchy-group-label'),
1055
+ ],
1056
+ ];
1057
+ }),
1058
+ );
1059
+ }
1060
+
1061
+ private portableHierarchyNode(id: string) {
1062
+ return this.portableHierarchy?.nodes.find((node) => node.id === id) ?? null;
1063
+ }
1064
+
1065
+ private portableStoriesForNode(nodeId: string): StoryRef[] {
1066
+ // Story application remounts the whole root; the world scope lists all stories.
1067
+ if (nodeId === WORLD_SCOPE_NODE_ID) return [...this.portableStories];
1068
+ const hierarchy = this.portableHierarchy!;
1069
+ const hierarchyNode = this.portableHierarchyNode(nodeId);
1070
+ if (hierarchyNode && hierarchyNode.role !== 'component') return [];
1071
+ const componentId =
1072
+ hierarchyNode?.role === 'component'
1073
+ ? hierarchyNode.id
1074
+ : (hierarchy.storyParentIds.get(nodeId) ??
1075
+ hierarchy.storyParentIds.get(this.activeStoryId ?? ''));
1076
+ if (!componentId) return [];
1077
+ const component = this.portableHierarchyNode(componentId);
1078
+ const storyIds = new Set(component?.childIds ?? []);
1079
+ return this.portableStories.filter((story) => storyIds.has(story.id));
1080
+ }
1081
+
1082
+ private portableStoryNode(story: StoryRef, rootIds: string[]): EditorNode {
1083
+ const active = story.id === this.activeStoryId;
1084
+ return {
1085
+ id: story.id,
1086
+ label: story.label,
1087
+ role: 'story',
1088
+ kind: 'story',
1089
+ parentId: this.portableHierarchy!.storyParentIds.get(story.id) ?? null,
1090
+ childIds: active ? rootIds : [],
1091
+ };
1092
+ }
1093
+
1094
+ readonly hierarchy: HierarchyProvider = {
1095
+ crossSurfaceStructureSignature: () => this.crossSurfaceStructureSignature(),
1096
+ roots: () => {
1097
+ const tree = this.hierarchySnapshot();
1098
+ const { nodes, rootIds } = tree;
1099
+ if (this.portableStories.length > 0) {
1100
+ return this.portableHierarchy!.rootIds.flatMap((id) => {
1101
+ const node = this.portableHierarchy!.nodes.find((candidate) => candidate.id === id);
1102
+ return node ? [{ ...node, childIds: [...node.childIds] }] : [];
1103
+ });
1104
+ }
1105
+ return rootIds.map((id) => this.toEditorNode(nodes.get(id)!, tree));
1106
+ },
1107
+ node: (id) => {
1108
+ if (this.portableStories.length > 0) {
1109
+ const hierarchyNode = this.portableHierarchy?.nodes.find(
1110
+ (candidate) => candidate.id === id,
1111
+ );
1112
+ if (hierarchyNode) return { ...hierarchyNode, childIds: [...hierarchyNode.childIds] };
1113
+ const story = this.portableStories.find((candidate) => candidate.id === id);
1114
+ if (story) return this.portableStoryNode(story, this.hierarchySnapshot().rootIds);
1115
+ }
1116
+ const tree = this.hierarchySnapshot();
1117
+ const n = tree.nodes.get(id);
1118
+ return n ? this.toEditorNode(n, tree) : null;
1119
+ },
1120
+ // No `nativeObject`/`idForNativeObject`: react entities are DOM, not Object3D —
1121
+ // the concept does not apply here (no 3D gizmo binding).
1122
+ };
1123
+
1124
+ readonly selection: SelectionProvider = {
1125
+ get: () => {
1126
+ const ids = [...this.store.selectedEntityIds];
1127
+ // Remember the last NON-EMPTY selection so a re-projection that drops it
1128
+ // can be repaired — see `repairSelectionAfterReprojection`. Recorded on
1129
+ // the read because that is the one call every projection of the
1130
+ // selection already makes; the adapter has no store subscription of its
1131
+ // own and adding one to observe a value it is handed anyway would be a
1132
+ // second source of the same fact.
1133
+ if (ids.length > 0) this.lastResolvedSelection = ids;
1134
+ return ids;
1135
+ },
1136
+ set: (ids) => this.store.selectMultiple(ids),
1137
+ resolve: (rawId, options) => {
1138
+ const raw = this.hierarchy.node(rawId);
1139
+ if (!raw) return null;
1140
+
1141
+ const innerToOuter: EditorNode[] = [];
1142
+ let cursor: EditorNode | null = raw;
1143
+ let guard = 0;
1144
+ while (cursor && guard++ < 1000) {
1145
+ if (
1146
+ cursor.role === 'component' ||
1147
+ cursor.role === 'instance' ||
1148
+ cursor.role === 'boundary'
1149
+ ) {
1150
+ innerToOuter.push(cursor);
1151
+ }
1152
+ cursor = cursor.parentId ? this.hierarchy.node(cursor.parentId) : null;
1153
+ }
1154
+ const chain = innerToOuter.reverse();
1155
+ if (chain.length === 0) {
1156
+ return { id: rawId };
1157
+ }
1158
+
1159
+ const scopeIndex = options?.scopeId
1160
+ ? chain.findIndex((candidate) => candidate.id === options.scopeId)
1161
+ : -1;
1162
+ let resolvedIndex = scopeIndex >= 0 ? scopeIndex + 1 : 0;
1163
+ if (options?.intent === 'deep') resolvedIndex += 1;
1164
+
1165
+ // Unlike the R3F projection, React keeps authored host elements in the
1166
+ // hierarchy. Once every component boundary on the route is open, the
1167
+ // exact DOM element becomes the honest selection subject.
1168
+ const subject = chain[resolvedIndex] ?? raw;
1169
+ return { id: subject.id };
1170
+ },
1171
+ };
1172
+
1173
+ readonly truth: TruthProvider = {
1174
+ resolve: (id): { site: NodeCreationSite; writeAnchorKind: WriteAnchorKind | undefined } => {
1175
+ const node = this.snapshot().nodes.get(id);
1176
+ if (!node) {
1177
+ return {
1178
+ site: { anchored: false, reason: 'This row is a design document, not a JSX element.' },
1179
+ writeAnchorKind: undefined,
1180
+ };
1181
+ }
1182
+ const entry = this.oidIndex.get(node.oid);
1183
+ const writeAnchorKind = this.writeBackend ? 'source-prop' : 'live-only';
1184
+ if (!entry) {
1185
+ return {
1186
+ site: { anchored: false, reason: 'Source metadata is still loading or unavailable.' },
1187
+ writeAnchorKind,
1188
+ };
1189
+ }
1190
+ const file = projectSourcePath(entry.file);
1191
+ return {
1192
+ site: {
1193
+ anchored: true,
1194
+ kind: 'source',
1195
+ file,
1196
+ line: entry.line,
1197
+ col: entry.col,
1198
+ display: `${file}:${entry.line}`,
1199
+ },
1200
+ writeAnchorKind,
1201
+ };
1202
+ },
1203
+ };
1204
+
1205
+ readonly related: RelatedSubjectsProvider = {
1206
+ links: (id) => {
1207
+ const node = this.hierarchy.node(id);
1208
+ if (node?.role !== 'component' || this.stories.storiesFor(id).length === 0) return [];
1209
+ return [
1210
+ {
1211
+ // `definition:` marks this as the subject's OPEN-FOR-EDIT target (the
1212
+ // component's own UI Components document), so the inspector surfaces it
1213
+ // as the labeled Edit button by kind, never by list position.
1214
+ id: `definition:${UI_COMPONENTS_DOCUMENT_ID}`,
1215
+ title: 'Open in UI Components',
1216
+ open: () => {
1217
+ void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
1218
+ },
1219
+ },
1220
+ ];
1221
+ },
1222
+ };
1223
+
1224
+ private instanceSource(id: string): {
1225
+ node: OidNode;
1226
+ callSiteOid: string;
1227
+ callSite: OidEntry;
1228
+ props: Record<string, string>;
1229
+ } | null {
1230
+ const node = this.snapshot().nodes.get(id);
1231
+ if (!node || this.hierarchy.node(id)?.role !== 'component') return null;
1232
+ const component = getComponentProps(node.el);
1233
+ if (!component) return null;
1234
+ const callSite = this.oidIndex.get(component.callSiteOid);
1235
+ if (!callSite || !/^[A-Z]/.test(callSite.tag)) return null;
1236
+ return {
1237
+ node,
1238
+ callSiteOid: component.callSiteOid,
1239
+ callSite,
1240
+ props: component.props,
1241
+ };
1242
+ }
1243
+
1244
+ private instanceProp(
1245
+ id: string,
1246
+ path: string,
1247
+ ): {
1248
+ source: NonNullable<ReturnType<ReactRootAuthoringAdapter['instanceSource']>>;
1249
+ prop: string;
1250
+ spec: ComponentPropSpec;
1251
+ authored: NonNullable<OidEntry['authoredProps']>[number];
1252
+ } | null {
1253
+ if (!path.startsWith(PROP_PATH_PREFIX)) return null;
1254
+ const source = this.instanceSource(id);
1255
+ if (!source) return null;
1256
+ const prop = path.slice(PROP_PATH_PREFIX.length);
1257
+ const spec = source.callSite.props?.find((candidate) => candidate.name === prop);
1258
+ const authored = source.callSite.authoredProps?.find((candidate) => candidate.name === prop);
1259
+ return spec && authored ? { source, prop, spec, authored } : null;
1260
+ }
1261
+
1262
+ private affectedInstances(id: string, prop: string): number {
1263
+ const selected = this.instanceSource(id);
1264
+ if (!selected) return 0;
1265
+ let count = 0;
1266
+ for (const candidate of this.snapshot().nodes.values()) {
1267
+ const component = getComponentProps(candidate.el);
1268
+ if (!component) continue;
1269
+ const entry = this.oidIndex.get(component.callSiteOid);
1270
+ if (!entry || entry.tag !== selected.callSite.tag) continue;
1271
+ if (candidate.id === id || !entry.authoredProps?.some((item) => item.name === prop)) count++;
1272
+ }
1273
+ return Math.max(1, count);
1274
+ }
1275
+
1276
+ readonly instances: ComponentInstancesProvider = {
1277
+ openComponent: () => {
1278
+ void activateWorkspaceDocument(UI_COMPONENTS_DOCUMENT_ID);
1279
+ },
1280
+ describe: (id) => {
1281
+ const source = this.instanceSource(id);
1282
+ if (!source || this.stories.storiesFor(id).length === 0) return null;
1283
+ const overrides = Object.entries(source.props).flatMap(([prop, value]) => {
1284
+ const path = `${PROP_PATH_PREFIX}${prop}`;
1285
+ const detail = this.instanceProp(id, path);
1286
+ if (!detail) return [];
1287
+ const canApplyToComponent =
1288
+ detail.authored.literal &&
1289
+ detail.spec.defaultValue !== undefined &&
1290
+ source.node.oid !== source.callSiteOid &&
1291
+ !!this.writeBackend?.runGesture &&
1292
+ !!this.writeBackend.writeComponentDefault;
1293
+ return [
1294
+ {
1295
+ path,
1296
+ label: prop,
1297
+ value,
1298
+ ...(detail.spec.defaultText === undefined
1299
+ ? {}
1300
+ : { defaultText: detail.spec.defaultText }),
1301
+ canApplyToComponent,
1302
+ affectedInstanceCount: this.affectedInstances(id, prop),
1303
+ ...(canApplyToComponent
1304
+ ? {}
1305
+ : {
1306
+ applyUnavailableReason: !detail.authored.literal
1307
+ ? 'The callsite value is a dynamic expression, so it cannot become a component literal.'
1308
+ : detail.spec.defaultValue === undefined
1309
+ ? 'The component default is computed or absent, so source cannot be changed safely.'
1310
+ : 'This session cannot atomically update the component and its callsite.',
1311
+ }),
1312
+ },
1313
+ ];
1314
+ });
1315
+ return {
1316
+ componentName: source.callSite.tag,
1317
+ sourcePath: projectSourcePath(source.callSite.file),
1318
+ overrides,
1319
+ };
1320
+ },
1321
+ revert: async (id, paths) => {
1322
+ const source = this.instanceSource(id);
1323
+ const backend = this.writeBackend;
1324
+ if (!source || !backend) return;
1325
+ const props = paths
1326
+ .map((path) => this.instanceProp(id, path)?.prop)
1327
+ .filter((prop): prop is string => !!prop);
1328
+ let changed = false;
1329
+ const remove = async (selected: SourceWriteBackend): Promise<void> => {
1330
+ for (const prop of props) {
1331
+ const result = await selected.removeProp(source.callSiteOid, prop);
1332
+ changed ||= result.changed;
1333
+ }
1334
+ };
1335
+ if (props.length > 1 && backend.runGesture) {
1336
+ await backend.runGesture(`Revert ${props.length} Overrides`, remove);
1337
+ } else {
1338
+ await remove(backend);
1339
+ }
1340
+ if (!changed) return;
1341
+ this.dirty = true;
1342
+ this.pendingSourceReconcile++;
1343
+ await this.refreshOidIndex();
1344
+ return { destination: JSX_SOURCE_DESTINATION, persisted: true };
1345
+ },
1346
+ applyToComponent: async (id, path): Promise<ComponentInstanceApplyResult> => {
1347
+ const detail = this.instanceProp(id, path);
1348
+ const backend = this.writeBackend;
1349
+ if (
1350
+ !detail ||
1351
+ !detail.authored.literal ||
1352
+ detail.spec.defaultValue === undefined ||
1353
+ detail.source.node.oid === detail.source.callSiteOid ||
1354
+ !backend?.runGesture ||
1355
+ !backend.writeComponentDefault
1356
+ ) {
1357
+ return {
1358
+ changed: false,
1359
+ message: 'Apply was refused because both literal source writes are not available.',
1360
+ };
1361
+ }
1362
+ const value = detail.source.props[detail.prop];
1363
+ await backend.runGesture(
1364
+ `Apply ${detail.prop} to ${detail.source.callSite.tag}`,
1365
+ async (scoped) => {
1366
+ const applied = await scoped.writeComponentDefault?.(
1367
+ detail.source.node.oid,
1368
+ detail.prop,
1369
+ serializedComponentValue(value, detail.spec),
1370
+ );
1371
+ if (!applied?.changed) {
1372
+ throw new Error(applied?.error ?? 'The component default did not change.');
1373
+ }
1374
+ const reverted = await scoped.removeProp(detail.source.callSiteOid, detail.prop);
1375
+ if (!reverted.changed) {
1376
+ throw new Error(reverted.error ?? 'The callsite override was not removed.');
1377
+ }
1378
+ },
1379
+ );
1380
+ this.dirty = true;
1381
+ this.pendingSourceReconcile++;
1382
+ await this.refreshOidIndex();
1383
+ return {
1384
+ changed: true,
1385
+ message: `${detail.prop} now defaults to ${String(value)} in ${detail.source.callSite.tag}.`,
1386
+ write: { destination: JSX_SOURCE_DESTINATION, persisted: true },
1387
+ };
1388
+ },
1389
+ };
1390
+
1391
+ /** Pick the smallest unlocked OID element containing the point, breaking
1392
+ * ties by later tree order. Ignore inline pointer-events:none; the host
1393
+ * wrapper's computed pointer policy does not hide authorable descendants. */
1394
+ readonly pickable: PickProvider = {
1395
+ pick: (clientX, clientY) => {
1396
+ const { nodes } = this.snapshot();
1397
+ let bestId: string | null = null;
1398
+ let bestArea = Number.POSITIVE_INFINITY;
1399
+ let bestOrder = -1;
1400
+ let order = -1;
1401
+ for (const node of nodes.values()) {
1402
+ order++;
1403
+ // Locked nodes do not compete for canvas selection.
1404
+ if (this.lockedIds.has(node.id)) continue;
1405
+ if (styleProp(node.el.style, 'pointerEvents') === 'none') continue;
1406
+ const rect = node.el.getBoundingClientRect();
1407
+ if (
1408
+ clientX < rect.left ||
1409
+ clientX > rect.right ||
1410
+ clientY < rect.top ||
1411
+ clientY > rect.bottom
1412
+ ) {
1413
+ continue;
1414
+ }
1415
+ const area = rect.width * rect.height;
1416
+ // smallest-area (deepest) wins; ties broken by later tree-order (topmost)
1417
+ if (area < bestArea || (area === bestArea && order > bestOrder)) {
1418
+ bestId = node.id;
1419
+ bestArea = area;
1420
+ bestOrder = order;
1421
+ }
1422
+ }
1423
+ return bestId;
1424
+ },
1425
+ };
1426
+
1427
+ /** Mounted layer origin used to express overlay rectangles in host coordinates. */
1428
+ private hostRect(): { left: number; top: number } {
1429
+ const r = this.root.getBoundingClientRect();
1430
+ return { left: r.left, top: r.top };
1431
+ }
1432
+
1433
+ private toHostRelative(r: {
1434
+ left: number;
1435
+ top: number;
1436
+ width: number;
1437
+ height: number;
1438
+ }): DOMRectLike {
1439
+ const host = this.hostRect();
1440
+ const zoom = getRootPan().zoom;
1441
+ return {
1442
+ x: (r.left - host.left) / zoom,
1443
+ y: (r.top - host.top) / zoom,
1444
+ width: r.width / zoom,
1445
+ height: r.height / zoom,
1446
+ };
1447
+ }
1448
+
1449
+ /** Host-relative node geometry for overlays, snapping, and marquee selection. */
1450
+ readonly rects: RectProvider = {
1451
+ rect: (id) => {
1452
+ const n = this.snapshot().nodes.get(id);
1453
+ const r = n?.el.getBoundingClientRect();
1454
+ return r ? this.toHostRelative(r) : null;
1455
+ },
1456
+ contextRects: (id) => {
1457
+ const { nodes } = this.snapshot();
1458
+ const n = nodes.get(id);
1459
+ if (!n) return {};
1460
+ const parentNode = n.parentId ? nodes.get(n.parentId) : undefined;
1461
+ const parentRect = parentNode?.el.getBoundingClientRect();
1462
+ const siblingIds = (parentNode ? parentNode.childIds : this.snapshot().rootIds).filter(
1463
+ (sid) => sid !== id,
1464
+ );
1465
+ const siblings = siblingIds
1466
+ .map((sid) => nodes.get(sid)?.el.getBoundingClientRect())
1467
+ .filter((r): r is NonNullable<typeof r> => r != null)
1468
+ .map((r) => this.toHostRelative(r));
1469
+ // Padding box (CSS box model, inside the border) — border widths read off
1470
+ // the same computed styles the inspector uses; absent/unparsable
1471
+ // border widths degrade to 0 (padding box === border box), never thrown.
1472
+ const el = n.el;
1473
+ const rect = el.getBoundingClientRect();
1474
+ const bt =
1475
+ numericStyleValue(getComputedStyleValue(el, 'borderTopWidth')) ?? 0;
1476
+ const br =
1477
+ numericStyleValue(getComputedStyleValue(el, 'borderRightWidth')) ?? 0;
1478
+ const bb =
1479
+ numericStyleValue(getComputedStyleValue(el, 'borderBottomWidth')) ??
1480
+ 0;
1481
+ const bl =
1482
+ numericStyleValue(getComputedStyleValue(el, 'borderLeftWidth')) ?? 0;
1483
+ const paddingBox = this.toHostRelative({
1484
+ left: rect.left + bl,
1485
+ top: rect.top + bt,
1486
+ width: Math.max(0, rect.width - bl - br),
1487
+ height: Math.max(0, rect.height - bt - bb),
1488
+ });
1489
+ // Persistent board guides (board-guides.ts), converted from client
1490
+ // space into the same host-relative units as every rect above.
1491
+ const guideClients = editorHost().documents.boardGuideEdges();
1492
+ const host = this.hostRect();
1493
+ const zoom = getRootPan().zoom;
1494
+ const guideEdges =
1495
+ guideClients.x.length > 0 || guideClients.y.length > 0
1496
+ ? {
1497
+ x: guideClients.x.map((cx) => (cx - host.left) / zoom),
1498
+ y: guideClients.y.map((cy) => (cy - host.top) / zoom),
1499
+ }
1500
+ : undefined;
1501
+ return {
1502
+ ...(parentRect ? { parent: this.toHostRelative(parentRect) } : {}),
1503
+ ...(siblings.length ? { siblings } : {}),
1504
+ paddingBox,
1505
+ ...(guideEdges ? { guideEdges } : {}),
1506
+ };
1507
+ },
1508
+ // Empty containers have no visible element children or non-whitespace text.
1509
+ emptyContainers: () => {
1510
+ const { nodes } = this.snapshot();
1511
+ const candidates: EmptyCandidate[] = [];
1512
+ for (const n of nodes.values()) {
1513
+ const r = n.el.getBoundingClientRect();
1514
+ if (!canContainDomChildren(n.el, n.tag)) continue;
1515
+ candidates.push({
1516
+ oid: n.id,
1517
+ rect: this.toHostRelative(r),
1518
+ displayName: n.tag,
1519
+ hasVisibleChildren: n.el.children.length > 0,
1520
+ hasText: (n.el.textContent ?? '').trim().length > 0,
1521
+ });
1522
+ }
1523
+ return findEmptyContainers(candidates).map((c) => ({
1524
+ id: c.oid,
1525
+ rect: c.rect,
1526
+ displayName: c.displayName,
1527
+ }));
1528
+ },
1529
+ };
1530
+
1531
+ /** Sample the topmost eligible node's computed color at the pointer. */
1532
+ readonly colorSample: ColorSampleProvider = {
1533
+ backgroundChainAt: (clientX, clientY) => {
1534
+ const hitId = this.pickable.pick(clientX, clientY);
1535
+ if (!hitId) return null;
1536
+ const { nodes } = this.snapshot();
1537
+ const chain: string[] = [];
1538
+ let cur: OidNode | undefined = nodes.get(hitId);
1539
+ while (cur) {
1540
+ chain.push(getComputedStyleValue(cur.el, 'backgroundColor'));
1541
+ cur = cur.parentId ? nodes.get(cur.parentId) : undefined;
1542
+ }
1543
+ return chain;
1544
+ },
1545
+ };
1546
+
1547
+ /** Preview box gestures on live DOM, then persist touched properties at end. */
1548
+ readonly boxEdit: BoxEditProvider = {
1549
+ begin: (id) => {
1550
+ // Starting a gesture replaces any uncommitted session; its
1551
+ // preview mutations are already live on the DOM either way.
1552
+ this.boxEditSession = { id, touched: new Map(), priorInline: new Map() };
1553
+ },
1554
+ apply: (id, patch) => {
1555
+ const session = this.boxEditSession;
1556
+ if (!session || session.id !== id) return; // no open gesture for this id
1557
+ const n = this.snapshot().nodes.get(id);
1558
+ if (!n) return; // unresolved id — no-op (per contract)
1559
+ const pos = getComputedStyleValue(n.el, 'position');
1560
+ const isPositioned = pos === 'absolute' || pos === 'fixed';
1561
+ for (const [key, v] of Object.entries(patch)) {
1562
+ const mapped = mapBoxEditPatchKey(key, isPositioned);
1563
+ if (!mapped) {
1564
+ console.warn(
1565
+ `[ReactRootAuthoringAdapter] boxEdit: dropping patch key "${key}" for "${id}" — ` +
1566
+ (key === 'x' || key === 'y'
1567
+ ? `node is not absolutely/fixed positioned (computed position: "${pos}"), ` +
1568
+ 'no left/top to move'
1569
+ : 'unrecognized box-edit patch key'),
1570
+ );
1571
+ // Explain why a flow-positioned element cannot accept a spatial move.
1572
+ if ((key === 'x' || key === 'y') && !session.hinted) {
1573
+ session.hinted = true;
1574
+ showTransientHint(
1575
+ `${n.tag} flows in its layout (position: ${pos}), so a drag ` +
1576
+ 'cannot move it. Set Position to "abs" in the Inspector to place it freely; ' +
1577
+ 'drag a handle to resize.',
1578
+ );
1579
+ }
1580
+ continue;
1581
+ }
1582
+ // Lazily snapshot the TRUE pre-gesture inline value the first time THIS
1583
+ // prop is touched in this gesture — before mutating it — see
1584
+ // `boxEditSession`'s doc comment for why this can't be re-derived later.
1585
+ if (!session.priorInline.has(mapped.prop)) {
1586
+ const prevRaw = styleProp(n.el.style, mapped.prop);
1587
+ session.priorInline.set(mapped.prop, prevRaw);
1588
+ }
1589
+ // x/y arrive HOST-RELATIVE (the rect provider's space), but CSS
1590
+ // left/top are OFFSET-PARENT-relative. They only coincide when the
1591
+ // offset parent sits at the host origin — which a story-frame child
1592
+ // never does, so writing them as-is would put BOARD coordinates into
1593
+ // the element's source and move it outside its own frame.
1594
+ // parentOrigin = rect − offsetLeft is constant through the gesture
1595
+ // (both shift together as we mutate), and an element positioned via
1596
+ // translate() cancels exactly: rect includes the transform, so the
1597
+ // written left lands the VISUAL box at the requested position.
1598
+ let write = v;
1599
+ if ((key === 'x' || key === 'y') && typeof v === 'number') {
1600
+ const el = n.el as unknown as {
1601
+ getBoundingClientRect: () => {
1602
+ left: number;
1603
+ top: number;
1604
+ width: number;
1605
+ height: number;
1606
+ };
1607
+ offsetLeft?: number;
1608
+ offsetTop?: number;
1609
+ };
1610
+ const rect = el.getBoundingClientRect();
1611
+ if (typeof el.offsetLeft === 'number' && typeof el.offsetTop === 'number') {
1612
+ const hostRel = this.toHostRelative(rect);
1613
+ const parentOrigin = key === 'x' ? hostRel.x - el.offsetLeft : hostRel.y - el.offsetTop;
1614
+ // Whole-pixel, like every other gesture write (see the spacing
1615
+ // scrub's own doc comment): the patch is grid/edge-snapped, but
1616
+ // edge targets and this origin are MEASURED (rect ÷ zoom), so
1617
+ // without rounding a 65%-zoom drag authors `left:
1618
+ // '215.00005607057415px'` — measurement noise, not intent.
1619
+ write = Math.round(v - parentOrigin);
1620
+ }
1621
+ }
1622
+ const cssValue = mapped.cssValue(write);
1623
+ (n.el.style as unknown as Record<string, unknown>)[mapped.prop] = cssValue;
1624
+ // Commit numerics as plain numbers (matching every other numeric style
1625
+ // write in this adapter — see `writeStyle`'s callers), transform as a
1626
+ // string — NOT the px-suffixed preview string, which is preview-only.
1627
+ session.touched.set(mapped.prop, mapped.prop === 'transform' ? cssValue : write);
1628
+ }
1629
+ },
1630
+ end: (id) => {
1631
+ const session = this.boxEditSession;
1632
+ this.boxEditSession = null;
1633
+ if (!session || session.id !== id || session.touched.size === 0) return;
1634
+ // Queue behind any still-running commit — see `boxEditTail`. A failed
1635
+ // commit must not poison the chain for every later gesture, so the tail
1636
+ // absorbs the rejection (commitBoxEdit reports its own failures).
1637
+ this.boxEditTail = this.boxEditTail.then(
1638
+ () => this.commitBoxEdit(id, session.touched, session.priorInline),
1639
+ () => this.commitBoxEdit(id, session.touched, session.priorInline),
1640
+ );
1641
+ void this.boxEditTail.catch((error) => {
1642
+ console.error('[ReactRootAuthoringAdapter] box edit failed and was not committed.', error);
1643
+ });
1644
+ },
1645
+ };
1646
+
1647
+ /** Commit touched properties as one gesture with one source-history transaction. */
1648
+ private async commitBoxEdit(
1649
+ id: string,
1650
+ touched: Map<string, string | number>,
1651
+ priorInline: Map<string, string>,
1652
+ ): Promise<void> {
1653
+ const wasDirty = this.dirty;
1654
+ // Echo all touched values before awaiting source writes.
1655
+ for (const [prop, value] of touched) {
1656
+ this.setEcho(id, `${STYLE_PATH_PREFIX}${prop}`, value);
1657
+ }
1658
+ // Surface a refused gesture in the editor console and near the pointer.
1659
+ let applied = 0;
1660
+ this.lastStyleWriteRefusal = null;
1661
+ const commit = async (backend: SourceWriteBackend | undefined): Promise<void> => {
1662
+ for (const [prop, value] of touched) {
1663
+ // Persist numeric length values as CSS lengths; transform values remain strings.
1664
+ const writeValue = typeof value === 'number' ? `${value}px` : value;
1665
+ if (await this.writeStyleEntry(id, prop, writeValue, priorInline.get(prop), backend)) {
1666
+ applied += 1;
1667
+ }
1668
+ }
1669
+ if (applied === 0 && touched.size > 0) {
1670
+ const reason = this.lastStyleWriteRefusal ?? 'the write was refused without a reason';
1671
+ editorConsole.error(`The last edit on this element was NOT saved — ${reason}`, 'authoring');
1672
+ showTransientHint('Not saved: the edit could not be written to source — see the Console.');
1673
+ }
1674
+ };
1675
+ // The history-managed backend supplies an explicit scoped writer. Holding
1676
+ // the callback as one queue entry makes the gesture failure-atomic and
1677
+ // prevents unrelated inspector writes from being absorbed/interleaved.
1678
+ try {
1679
+ if (this.writeBackend?.runGesture) {
1680
+ await this.writeBackend.runGesture(`Edit ${touched.size} Styles`, commit);
1681
+ } else {
1682
+ await commit(this.writeBackend);
1683
+ }
1684
+ } catch (error) {
1685
+ const node = this.snapshot().nodes.get(id);
1686
+ for (const [prop, prior] of priorInline) {
1687
+ this.clearEcho(id, `${STYLE_PATH_PREFIX}${prop}`);
1688
+ if (node) (node.el.style as unknown as Record<string, unknown>)[prop] = prior;
1689
+ }
1690
+ this.dirty = wasDirty;
1691
+ this.store.notifyIngestEdit();
1692
+ throw error;
1693
+ }
1694
+ }
1695
+
1696
+ /** Read leaf text from DOM and write through the source-side dynamic-body guard. */
1697
+ readonly text: TextProvider = {
1698
+ get: (id) => {
1699
+ // Do not reopen text editing for a body already refused by the source guard.
1700
+ if (this.guardedPaths.has(`${id}|${TEXT_PATH}`)) return null;
1701
+ const n = this.snapshot().nodes.get(id);
1702
+ if (!n || n.el.children.length > 0) return null;
1703
+ const raw = n.el.textContent;
1704
+ if (raw == null) return null;
1705
+ const trimmed = raw.trim();
1706
+ return trimmed ? trimmed : null;
1707
+ },
1708
+ set: (id, text) => {
1709
+ void this.editText(id, text);
1710
+ },
1711
+ };
1712
+
1713
+ readonly inspector: InspectorProvider = {
1714
+ properties: (id: string): PropertyDescriptor[] => {
1715
+ const portableHierarchyNode = this.portableHierarchyNode(id);
1716
+ if (portableHierarchyNode) {
1717
+ return portableHierarchyNode.secondaryLabel
1718
+ ? [
1719
+ {
1720
+ path: 'document.path',
1721
+ label: 'Source',
1722
+ type: 'string',
1723
+ readonly: true,
1724
+ group: 'Document',
1725
+ },
1726
+ ]
1727
+ : [];
1728
+ }
1729
+ if (this.portableStories.some((story) => story.id === id))
1730
+ return this.portableStoryArgDescriptors(id);
1731
+ const style = STYLE_PROPERTIES.map(({ prop, label, type, group, options }) => ({
1732
+ path: `${STYLE_PATH_PREFIX}${prop}`,
1733
+ label,
1734
+ type,
1735
+ group,
1736
+ ...(options ? { options } : {}),
1737
+ }));
1738
+ // Append component props from the selected node's source contract.
1739
+ const all = [...style, ...this.propDescriptors(id), ...this.tokenDescriptors()];
1740
+ // Return remembered source guards as read-only descriptors with their own reason.
1741
+ if (this.guardedPaths.size === 0) return all;
1742
+ return all.map((p) => {
1743
+ const reason = this.guardedPaths.get(`${id}|${p.path}`);
1744
+ return reason ? { ...p, readonly: true, readonlyReason: reason } : p;
1745
+ });
1746
+ },
1747
+ get: (id, path) => {
1748
+ const portableHierarchyNode = this.portableHierarchyNode(id);
1749
+ if (portableHierarchyNode) {
1750
+ return path === 'document.path' ? portableHierarchyNode.secondaryLabel : undefined;
1751
+ }
1752
+ if (this.portableStories.some((story) => story.id === id)) {
1753
+ if (path === 'story.active') return id === this.activeStoryId;
1754
+ if (path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) {
1755
+ return this.portableStoryArgs.get(id)?.[
1756
+ path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length)
1757
+ ];
1758
+ }
1759
+ return undefined;
1760
+ }
1761
+ // A freshly committed echo takes precedence over the live DOM value.
1762
+ const echoed = this.readEcho(id, path);
1763
+ if (echoed.hit) return echoed.value;
1764
+ // Lock state is session-local; visibility reads its source-backed style echo.
1765
+ if (path === 'locked') return this.lockedIds.has(id);
1766
+ if (path === 'visible') {
1767
+ const echoedStyle = this.readEcho(id, `${STYLE_PATH_PREFIX}visibility`);
1768
+ if (echoedStyle.hit) return echoedStyle.value !== 'hidden';
1769
+ const n = this.snapshot().nodes.get(id);
1770
+ if (!n) return undefined;
1771
+ const raw = getComputedStyleValue(n.el, 'visibility');
1772
+ return raw !== 'hidden';
1773
+ }
1774
+ if (path.startsWith(TOKEN_PATH_PREFIX)) {
1775
+ const name = path.slice(TOKEN_PATH_PREFIX.length);
1776
+ return this.designTokenValue(name);
1777
+ }
1778
+ if (path.startsWith(PROP_PATH_PREFIX)) {
1779
+ const n = this.snapshot().nodes.get(id);
1780
+ const cp = n ? getComponentProps(n.el) : null;
1781
+ return cp?.props[path.slice(PROP_PATH_PREFIX.length)];
1782
+ }
1783
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return undefined;
1784
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1785
+ const n = this.snapshot().nodes.get(id);
1786
+ if (!n) return undefined;
1787
+ // Prefer authored inline values; computed style resolves class and stylesheet values.
1788
+ const authored = styleProp(n.el.style, prop);
1789
+ const raw = authored || getComputedStyleValue(n.el, prop);
1790
+ const declaredType = STYLE_PROPERTY_TYPE.get(prop);
1791
+ // Convert CSSStyleDeclaration's unit-suffixed strings to numbers for numeric inspector
1792
+ // descriptors, and normalize color descriptors to #rrggbb through cssColorToHex.
1793
+ if (declaredType === 'number') return numericStyleValue(raw);
1794
+ if (declaredType === 'color') return cssColorToHex(raw);
1795
+ return raw || undefined; // an unset computed value reads '' — surface as blank
1796
+ },
1797
+ set: (id, path, value) => {
1798
+ if (this.portableHierarchyNode(id)) return;
1799
+ if (this.portableStories.some((story) => story.id === id)) {
1800
+ if (!path.startsWith(PORTABLE_STORY_ARG_PATH_PREFIX)) return;
1801
+ const name = path.slice(PORTABLE_STORY_ARG_PATH_PREFIX.length);
1802
+ const current = this.portableStoryArgs.get(id);
1803
+ if (!current || !(name in current)) return;
1804
+ const next = { ...current, [name]: value };
1805
+ this.portableStoryArgs.set(id, next);
1806
+ this.onPortableStoryArgsChanged?.(id, { ...next });
1807
+ this.store.notifyIngestEdit();
1808
+ return;
1809
+ }
1810
+ // Apply session lock and source-backed visibility changes through their own paths.
1811
+ if (path === 'locked') {
1812
+ if (value) this.lockedIds.add(id);
1813
+ else this.lockedIds.delete(id);
1814
+ this.store.notifyIngestEdit();
1815
+ return;
1816
+ }
1817
+ if (path === 'visible') {
1818
+ const cssValue = value ? 'visible' : 'hidden';
1819
+ this.setEcho(id, `${STYLE_PATH_PREFIX}visibility`, cssValue);
1820
+ return this.pipedSourceWrite(() => this.writeStyle(id, 'visibility', cssValue));
1821
+ }
1822
+ if (path.startsWith(PROP_PATH_PREFIX)) {
1823
+ // Echo before awaiting the source write.
1824
+ this.setEcho(id, path, value);
1825
+ return this.pipedSourceWrite(() =>
1826
+ this.writePropEdit(id, path.slice(PROP_PATH_PREFIX.length), String(value)),
1827
+ );
1828
+ }
1829
+ if (path.startsWith(TOKEN_PATH_PREFIX)) {
1830
+ // Tokens-as-noun: a token declared by a project stylesheet is EDITED
1831
+ // AT ITS DECLARATION (`writeCss` on the traced rule) — the one write
1832
+ // that moves every binding at once. Undeclared tokens (script-set)
1833
+ // refuse with the pointer; their descriptor is read-only anyway.
1834
+ this.setEcho(id, path, value);
1835
+ return this.pipedSourceWrite(() =>
1836
+ this.writeTokenValue(id, path.slice(TOKEN_PATH_PREFIX.length), String(value)),
1837
+ );
1838
+ }
1839
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
1840
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1841
+ // Echo BEFORE the async write: the field reflects the commit
1842
+ // within one frame; the write path clears this echo if the write is refused, and HMR
1843
+ // clears it once the re-render lands.
1844
+ this.setEcho(id, path, value);
1845
+ return this.pipedSourceWrite(() => this.writeStyle(id, prop, value as string | number));
1846
+ },
1847
+ remove: (id, path) => {
1848
+ // Remove longhand overrides so a uniform shorthand controls the rendered value.
1849
+ if (!path.startsWith(STYLE_PATH_PREFIX)) return;
1850
+ const prop = path.slice(STYLE_PATH_PREFIX.length);
1851
+ // Point the removed longhand's echo at the value it actually renders at
1852
+ // once the override is gone — the shorthand's current (just-committed)
1853
+ // value — so `inspector.get(longhand)` reports the resolved corner value,
1854
+ // not a stale override or an empty computed read, and stays consistent
1855
+ // after HMR clears the echo (the shorthand then cascades to it).
1856
+ const shorthand = LONGHAND_TO_SHORTHAND[prop];
1857
+ if (shorthand)
1858
+ this.setEcho(id, path, this.inspector.get(id, `${STYLE_PATH_PREFIX}${shorthand}`));
1859
+ else this.clearEcho(id, path);
1860
+ // Through the SAME pipe `set` uses, so a removal answers for itself with
1861
+ // an awaited per-edit ack instead of being fired into the void. It is the
1862
+ // only door that can express byte-ABSENCE — `set` writes a value, and a
1863
+ // longhand set back to the shorthand's value is still a longhand in the
1864
+ // file.
1865
+ return this.pipedSourceWrite(() => this.removeStyleProp(id, prop));
1866
+ },
1867
+ };
1868
+
1869
+ /** Storybook Controls descriptors for an ordinary CSF story node. Functions
1870
+ * and other non-serializable implementation values stay out of the form;
1871
+ * they remain present in the complete args object passed to the composed
1872
+ * story. Per-story documents share the rule in
1873
+ * ../host/stories/story-arg-descriptors.ts. */
1874
+ private portableStoryArgDescriptors(storyId: string): PropertyDescriptor[] {
1875
+ return storyArgPropertyDescriptors(
1876
+ this.portableStoryArgs.get(storyId) ?? {},
1877
+ PORTABLE_STORY_ARG_PATH_PREFIX,
1878
+ );
1879
+ }
1880
+
1881
+ /** Current complete args for a portable story, including callbacks omitted
1882
+ * from the visual Controls list. Returned as a copy so inspector UI cannot
1883
+ * mutate adapter state behind the provider contract. */
1884
+ portableArgs(storyId: string): Record<string, unknown> | null {
1885
+ const args = this.portableStoryArgs.get(storyId);
1886
+ return args ? { ...args } : null;
1887
+ }
1888
+
1889
+ /** Classes on the element, their scope-local use counts, and any matching
1890
+ * first-party rule that makes the class an editable named style. */
1891
+ elementClasses(id: string): { name: string; count: number; styledIn: string | null }[] {
1892
+ const n = this.snapshot().nodes.get(id);
1893
+ const el = n?.el as unknown as Element | undefined;
1894
+ if (!el) return [];
1895
+ const tokens = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
1896
+ // Count class uses inside the element's mounted CSS scope, or its document.
1897
+ const scope = el.closest('[data-volter-mount-styles]') ?? el.ownerDocument;
1898
+ return tokens.map((name) => {
1899
+ const count = scope.querySelectorAll(`.${CSS.escape(name)}`).length;
1900
+ return { name, count, styledIn: findClassRuleSource(name)?.sourceFile ?? null };
1901
+ });
1902
+ }
1903
+
1904
+ /**
1905
+ * The named-style write: APPLY when any first-party stylesheet already
1906
+ * declares `.className` (the class is an existing style — adding the token
1907
+ * is the whole gesture), else CREATE — mint the rule in the element's own
1908
+ * stylesheet (the last matched first-party rule's file, else the first
1909
+ * first-party stylesheet in the document), moving the element's literal
1910
+ * inline declarations into it. `remove` strips the token. Refusals are
1911
+ * returned AND logged, so the calling UI can show the sentence.
1912
+ */
1913
+ async writeNamedStyle(
1914
+ id: string,
1915
+ op: { kind: 'set' | 'remove'; className: string },
1916
+ ): Promise<{ changed: boolean; created?: boolean; error?: string }> {
1917
+ const n = this.snapshot().nodes.get(id);
1918
+ if (!n) return { changed: false, error: `"${id}" no longer resolves in this root` };
1919
+ const refuse = (error: string): { changed: false; error: string } => {
1920
+ console.warn(`[ReactRootAuthoringAdapter] named-style ${op.kind} refused: ${error}`);
1921
+ return { changed: false, error };
1922
+ };
1923
+ if (op.kind === 'remove') {
1924
+ const res = await writeNamedStyle({ op: 'remove', oid: n.oid, className: op.className });
1925
+ if (!res.changed) return refuse(res.error ?? 'no change');
1926
+ this.dirty = true;
1927
+ this.store.notifyIngestEdit();
1928
+ return { changed: true };
1929
+ }
1930
+ const existing = findClassRuleSource(op.className);
1931
+ if (existing) {
1932
+ const res = await writeNamedStyle({ op: 'apply', oid: n.oid, className: op.className });
1933
+ if (!res.changed) return refuse(res.error ?? 'the element already wears this class');
1934
+ this.dirty = true;
1935
+ this.store.notifyIngestEdit();
1936
+ return { changed: true };
1937
+ }
1938
+ // The rule's home: the last matched first-party rule's own file, else any
1939
+ // first-party stylesheet in the document — and with NEITHER, omit `file`
1940
+ // and the server MINTS a stylesheet beside the element's module (adding
1941
+ // the bare css import), so the first named style in a css-less project
1942
+ // works instead of refusing.
1943
+ // Candidate homes are the GAME's stylesheets. The dev-id enumeration also
1944
+ // sees the editor's own vite-served CSS, and offering one of those as the
1945
+ // home would (rightly) bounce off the server's scope guard — so mirror the
1946
+ // guard's exclusions here and let a css-less project fall through to the
1947
+ // server's minting path instead.
1948
+ const gameCss = (f: string | undefined): boolean =>
1949
+ f !== undefined &&
1950
+ !f.includes('/packages/editor-ui/') &&
1951
+ !f.includes('/node_modules/') &&
1952
+ !f.includes('/vendor/');
1953
+ const matched = this.matchedCssRules(n.el).filter((rule) => gameCss(rule.sourceFile));
1954
+ const cssFile =
1955
+ matched[matched.length - 1]?.sourceFile ?? firstPartyStylesheetFiles().find(gameCss);
1956
+ const res = await writeNamedStyle({
1957
+ op: 'create',
1958
+ oid: n.oid,
1959
+ className: op.className,
1960
+ ...(cssFile !== undefined ? { file: cssFile } : {}),
1961
+ });
1962
+ if (!res.changed) return refuse(res.error ?? 'no change');
1963
+ this.dirty = true;
1964
+ this.store.notifyIngestEdit();
1965
+ return { changed: true, created: true };
1966
+ }
1967
+
1968
+ async writeStory(
1969
+ storyId: string,
1970
+ op:
1971
+ | { kind: 'save-as'; name: string }
1972
+ | { kind: 'rename'; newName: string }
1973
+ | { kind: 'delete' },
1974
+ ): Promise<{ changed: boolean; error?: string }> {
1975
+ const story = this.portableStories.find((candidate) => candidate.id === storyId);
1976
+ if (!story) return { changed: false, error: `unknown portable story '${storyId}'` };
1977
+ const request =
1978
+ op.kind === 'save-as'
1979
+ ? {
1980
+ op: 'save' as const,
1981
+ file: story.modulePath,
1982
+ name: op.name,
1983
+ args: this.portableStoryArgs.get(storyId) ?? {},
1984
+ }
1985
+ : op.kind === 'rename'
1986
+ ? { op: 'rename' as const, file: story.modulePath, name: story.name, newName: op.newName }
1987
+ : { op: 'delete' as const, file: story.modulePath, name: story.name };
1988
+ const result = await writeCsfStory(request);
1989
+ if (!result.changed) {
1990
+ console.warn(
1991
+ `[ReactRootAuthoringAdapter] csf-story ${op.kind} refused for '${story.label}': ` +
1992
+ `${result.error ?? 'no change'}`,
1993
+ );
1994
+ return { changed: false, ...(result.error !== undefined ? { error: result.error } : {}) };
1995
+ }
1996
+ // Update renamed and deleted entries immediately; save-as becomes visible
1997
+ // when story discovery refreshes.
1998
+ if (op.kind === 'rename') {
1999
+ story.name = op.newName;
2000
+ story.label = op.newName.replace(/([a-z])([A-Z])/g, '$1 $2');
2001
+ } else if (op.kind === 'delete') {
2002
+ const index = this.portableStories.indexOf(story);
2003
+ if (index >= 0) this.portableStories.splice(index, 1);
2004
+ if (this.activeStoryId === storyId) this.activeStoryId = null;
2005
+ }
2006
+ this.dirty = true;
2007
+ this.store.notifyIngestEdit();
2008
+ return { changed: true };
2009
+ }
2010
+
2011
+ /** Restore the selected story's composed CSF defaults. */
2012
+ resetPortableArgs(storyId: string): void {
2013
+ const story = this.portableStories.find((candidate) => candidate.id === storyId);
2014
+ if (!story) return;
2015
+ const next = { ...story.args };
2016
+ this.portableStoryArgs.set(storyId, next);
2017
+ this.onPortableStoryArgsChanged?.(storyId, { ...next });
2018
+ this.store.notifyIngestEdit();
2019
+ }
2020
+
2021
+ /** Editable component props read from the mounted React fiber. */
2022
+ private propDescriptors(id: string): PropertyDescriptor[] {
2023
+ const n = this.snapshot().nodes.get(id);
2024
+ const cp = n ? getComponentProps(n.el) : null;
2025
+ if (!cp) return [];
2026
+ return Object.keys(cp.props).map((name) => ({
2027
+ path: `${PROP_PATH_PREFIX}${name}`,
2028
+ label: name,
2029
+ type: 'string' as const,
2030
+ group: 'Props',
2031
+ }));
2032
+ }
2033
+
2034
+ /** Design-token descriptors. A traced project declaration is writable. */
2035
+ private tokenDescriptors(): PropertyDescriptor[] {
2036
+ // Tokens-as-noun: a token whose `:root` declaration traces to a project
2037
+ // stylesheet is WRITABLE (the edit lands on that declaration through the
2038
+ // css door and every binding moves at once); one set by script — a theme
2039
+ // data module mirroring itself onto `:root`, or inherited host style —
2040
+ // stays read-only, because its truth lives in that source, not in a rule
2041
+ // the css writer can reach.
2042
+ return this.designTokens().map((t) => ({
2043
+ path: `${TOKEN_PATH_PREFIX}${t.name}`,
2044
+ label: t.name,
2045
+ type: 'string' as const,
2046
+ group: 'Tokens',
2047
+ readonly: !t.declaredIn,
2048
+ }));
2049
+ }
2050
+
2051
+ /**
2052
+ * The token-edit write: `writeCss` on the token's TRACED `:root`
2053
+ * declaration. Refusals are loud and name the remedy — an untraced token's
2054
+ * edit door is its declaring source (plain data), not this path.
2055
+ */
2056
+ private async writeTokenValue(id: string, name: string, value: string): Promise<boolean> {
2057
+ const echoPath = `${TOKEN_PATH_PREFIX}${name}`;
2058
+ const token = this.designTokens().find((t) => t.name === name);
2059
+ if (!token) {
2060
+ this.clearEcho(id, echoPath);
2061
+ console.warn(`[ReactRootAuthoringAdapter] unknown design token "${name}".`);
2062
+ return false;
2063
+ }
2064
+ if (!token.declaredIn) {
2065
+ this.clearEcho(id, echoPath);
2066
+ console.warn(
2067
+ `[ReactRootAuthoringAdapter] token "${name}" has no stylesheet declaration to edit — ` +
2068
+ 'it is set by script (a theme data module mirroring itself onto :root, or host ' +
2069
+ 'style). Edit that source; it is plain data.',
2070
+ );
2071
+ return false;
2072
+ }
2073
+ const backend = this.writeBackend;
2074
+ if (!backend) {
2075
+ this.clearEcho(id, echoPath);
2076
+ console.warn(
2077
+ `[ReactRootAuthoringAdapter] cannot write token "${name}": no ` +
2078
+ 'source-write backend in this session.',
2079
+ );
2080
+ return false;
2081
+ }
2082
+ const res = await backend.writeCss(
2083
+ token.declaredIn.sourceFile,
2084
+ token.declaredIn.selector,
2085
+ name,
2086
+ value,
2087
+ );
2088
+ if (!res.changed) {
2089
+ this.clearEcho(id, echoPath);
2090
+ console.warn(
2091
+ `[ReactRootAuthoringAdapter] token write refused/no-op for "${name}" at ` +
2092
+ `${token.declaredIn.sourceFile} (${token.declaredIn.selector}): ${res.error ?? 'no change'}`,
2093
+ );
2094
+ return false;
2095
+ }
2096
+ this.dirty = true;
2097
+ this.store.notifyIngestEdit();
2098
+ return true;
2099
+ }
2100
+
2101
+ /**
2102
+ * Portable Storybook CSF stories. `active`/`apply` are world-level because
2103
+ * applying a story remounts the whole React root. `storiesFor` narrows
2104
+ * presentation to the stories of the node's own component
2105
+ * (`portableStoriesForNode`) — but the world question, `WORLD_SCOPE_NODE_ID`,
2106
+ * is still answered with the whole list, because that is the id the shell
2107
+ * probes this provider's SCOPE with and the scope is world-level.
2108
+ * `apply` validates the id, updates `activeStoryId`, asks the design-time
2109
+ * layer to render that composed CSF story, and notifies the shell.
2110
+ */
2111
+ readonly stories: StoriesProvider = {
2112
+ storiesFor: (nodeId): StoryRef[] =>
2113
+ this.portableStories.length > 0 ? this.portableStoriesForNode(nodeId) : [],
2114
+ active: () => this.activeStoryId,
2115
+ apply: (_nodeId, storyId) => {
2116
+ if (storyId !== null && !this.portableStories.some((story) => story.id === storyId)) {
2117
+ console.warn(
2118
+ `[ReactRootAuthoringAdapter] stories.apply: unknown portable CSF story id "${storyId}" — ignoring.`,
2119
+ );
2120
+ return;
2121
+ }
2122
+ // Portable CSF always renders a design state. Clearing the picker means
2123
+ // "restore the document's default", not "orphan the live DOM roots
2124
+ // outside every story row".
2125
+ this.activeStoryId = storyId ?? this.portableDefaultStoryId;
2126
+ this.onPortableStoryApplied?.(storyId);
2127
+ this.store.notifyIngestEdit();
2128
+ },
2129
+ // Publish this provider's discovery state alongside its story list.
2130
+ unavailable: () => editorHost().stories.discoveryUnavailable(),
2131
+ };
2132
+
2133
+ readonly structure: StructureProvider = {
2134
+ // Every structural op journals complete next-file text. Undo never needs to
2135
+ // address a moved or deleted element by an OID that may no longer exist.
2136
+ // The new element's OID is minted by the server on the next index, so the
2137
+ // id half is '' and the ack half is the write itself (`StructuralIdWrite`).
2138
+ create: (kind, parentId) => ({
2139
+ id: '',
2140
+ ack: this.structOp(parentId ?? '', 'create', { wrapperTag: kind }),
2141
+ }),
2142
+ // Preserve the acknowledgement so callers can await the source write.
2143
+ remove: (id) => this.removeElement(id),
2144
+ // Delete the selection in one request against one source snapshot, independent of DOM order.
2145
+ removeMany: (ids) => this.removeManyElements(ids),
2146
+ // Serialize duplication writes so same-file edits do not read competing snapshots.
2147
+ duplicate: (id) => ({ id, ack: this.structOp(id, 'duplicate') }),
2148
+ reparent: (id, newParentId) => {
2149
+ const parentOid = newParentId ? this.oidOf(newParentId) : undefined;
2150
+ if (!parentOid) {
2151
+ return this.structRefusal(
2152
+ `reparent refused: "${newParentId ?? 'the document root'}" has no authored source element to move into.`,
2153
+ );
2154
+ }
2155
+ return this.structOp(id, 'reparent', { parentOid });
2156
+ },
2157
+ // An absent before-sibling means append to the node's current parent.
2158
+ reorder: (id, beforeSiblingId) => {
2159
+ const parentId =
2160
+ beforeSiblingId === null ? (this.snapshot().nodes.get(id)?.parentId ?? null) : null;
2161
+ return this.reorder(id, beforeSiblingId, parentId);
2162
+ },
2163
+ // Every OID container accepts the curated HTML child kinds.
2164
+ creatableKinds: () => [...CREATABLE_KINDS],
2165
+ // Route wrapping through the same source-backed structure contract.
2166
+ wrap: (id, wrapperTag) => this.wrap(id, wrapperTag),
2167
+ unwrap: (id) => this.unwrap(id),
2168
+ };
2169
+
2170
+ readonly assetDrop: AssetDropProvider = {
2171
+ accepts: (nodeId, _assetPath, context) => this.assetDropPlan(nodeId, context).ok,
2172
+ drop: (nodeId, _assetPath, context) => {
2173
+ const plan = this.assetDropPlan(nodeId, context);
2174
+ if (!plan.ok) return this.structRefusal(`asset drop refused — ${plan.reason}`);
2175
+ return this.structOp(plan.parentId, 'create', {
2176
+ snippet: plan.snippet,
2177
+ ensureImport: plan.ensureImport,
2178
+ });
2179
+ },
2180
+ };
2181
+
2182
+ private assetDropPlan(nodeId: string, context: AssetDropContext): DomAssetDropPlan {
2183
+ if (!this.writeBackend) {
2184
+ return { ok: false, reason: 'This session has no source writer, so nothing can be added.' };
2185
+ }
2186
+ const node = this.snapshot().nodes.get(nodeId);
2187
+ if (!node) return { ok: false, reason: 'That row has no authored JSX element.' };
2188
+ if (!canContainDomChildren(node.el, node.tag)) {
2189
+ return { ok: false, reason: `<${node.tag}> cannot contain child elements.` };
2190
+ }
2191
+ const component = context.item;
2192
+ if (component.surface !== 'dom') {
2193
+ return {
2194
+ ok: false,
2195
+ reason: `${component.name} is a ${component.surface} component, not a React UI component.`,
2196
+ };
2197
+ }
2198
+ const from = this.sourceLocation(nodeId)?.file;
2199
+ if (!from) {
2200
+ return { ok: false, reason: 'The target source location has not loaded yet.' };
2201
+ }
2202
+ return {
2203
+ ok: true,
2204
+ parentId: nodeId,
2205
+ snippet: `<${component.name} />`,
2206
+ ensureImport: {
2207
+ name: component.name,
2208
+ module: this.relativeProjectModule(from, component.sourcePath),
2209
+ kind: component.exportKind,
2210
+ },
2211
+ };
2212
+ }
2213
+
2214
+ private relativeProjectModule(fromFile: string, targetFile: string): string {
2215
+ return relativeImportSpecifier(projectSourcePath(fromFile), projectSourcePath(targetFile));
2216
+ }
2217
+ /** Wrap the element in a new container. */
2218
+ wrap(id: string, wrapperTag = 'div'): Promise<WriteAck> {
2219
+ return this.structOp(id, 'wrap', { wrapperTag });
2220
+ }
2221
+
2222
+ /** Replace the element with its children. */
2223
+ unwrap(id: string): Promise<WriteAck> {
2224
+ return this.structOp(id, 'unwrap');
2225
+ }
2226
+
2227
+ /** Reorder the element before a sibling (`beforeId`), or to the end of `parentId` when
2228
+ * `beforeId` is null. */
2229
+ reorder(id: string, beforeId: string | null, parentId: string | null): Promise<WriteAck> {
2230
+ const opts: { targetOid?: string; parentOid?: string } = {};
2231
+ const targetOid = beforeId ? this.oidOf(beforeId) : undefined;
2232
+ const parentOid = parentId ? this.oidOf(parentId) : undefined;
2233
+ if (targetOid) opts.targetOid = targetOid;
2234
+ if (parentOid) opts.parentOid = parentOid;
2235
+ return this.structOp(id, 'reorder', opts);
2236
+ }
2237
+
2238
+ /** The underlying (component-global) OID for an entity id (strips the `#n` repeat tag). */
2239
+ private oidOf(id: string): string | undefined {
2240
+ return this.snapshot().nodes.get(id)?.oid;
2241
+ }
2242
+
2243
+ /** Indexed source location for an OID node, absent before the index resolves. */
2244
+ sourceLocation(id: string): OidEntry | undefined {
2245
+ const oid = this.oidOf(id);
2246
+ if (!oid) return undefined;
2247
+ return this.oidIndex.get(oid);
2248
+ }
2249
+
2250
+ /** Run a structural op through the persistence pipe (loud degradation without
2251
+ * a writer). */
2252
+ private structOp(id: string, op: string, opts?: StructOpOptions): Promise<WriteAck> {
2253
+ return this.structPipe.structOp(this.oidOf(id), id, op, opts, JSX_SOURCE_DESTINATION);
2254
+ }
2255
+
2256
+ /** Most recent style refusal, surfaced when a gesture writes nothing. */
2257
+ private lastStyleWriteRefusal: string | null = null;
2258
+ private refuseStyleWrite(message: string): void {
2259
+ this.lastStyleWriteRefusal = message;
2260
+ console.warn(message);
2261
+ }
2262
+
2263
+ private async writeStyleEntry(
2264
+ id: string,
2265
+ prop: string,
2266
+ value: string | number,
2267
+ priorInlineOverride?: string,
2268
+ backend: SourceWriteBackend | undefined = this.writeBackend,
2269
+ ): Promise<boolean> {
2270
+ // Clear this write's echo on every refused or unavailable path.
2271
+ const echoPath = `${STYLE_PATH_PREFIX}${prop}`;
2272
+ const n = this.snapshot().nodes.get(id);
2273
+ if (!n) {
2274
+ this.clearEcho(id, echoPath);
2275
+ // Report stale selection when the node disappeared before the source write.
2276
+ this.refuseStyleWrite(
2277
+ `[ReactRootAuthoringAdapter] cannot write "${prop}": "${id}" no longer resolves in this ` +
2278
+ 'root (the tree was re-projected — an HMR remount — between the selection and the ' +
2279
+ 'write). Nothing was written. Re-select and retry.',
2280
+ );
2281
+ return false;
2282
+ }
2283
+ if (!backend) {
2284
+ this.clearEcho(id, echoPath);
2285
+ this.refuseStyleWrite(
2286
+ `[ReactRootAuthoringAdapter] cannot write "${prop}" on "${id}": no source-write ` +
2287
+ 'backend in this session (hosted/no dev server) — selection/inspection still work.',
2288
+ );
2289
+ return false;
2290
+ }
2291
+ // A per-side longhand cannot share a style object with the shorthand that
2292
+ // owns it — React raises its own conflicting-property error and the winner
2293
+ // becomes key-order dependent. Expand the shorthand first, so the element
2294
+ // is authored in ONE vocabulary. A source no-op on a clean element.
2295
+ await this.expandShorthandsFor(id, prop, backend);
2296
+ // Route matching first-party declarations to their stylesheet.
2297
+ const breakpoint = activeBreakpoint();
2298
+ if (breakpoint !== null) {
2299
+ const named = namedStyleRuleFor(this.matchedCssRules(n.el), classTokensOf(n.el));
2300
+ if (!named) {
2301
+ const guard =
2302
+ 'no style class carries this element — create one in the Style class row first ' +
2303
+ '(inline styles cannot be responsive, so a breakpoint edit needs a class rule).';
2304
+ this.clearEcho(id, echoPath);
2305
+ this.markGuarded(id, echoPath, guard);
2306
+ showTransientHint(guard);
2307
+ this.store.notifyIngestEdit();
2308
+ this.refuseStyleWrite(`[ReactRootAuthoringAdapter] breakpoint edit refused: ${guard}`);
2309
+ return false;
2310
+ }
2311
+ const res = await backend.writeCss(
2312
+ named.sourceFile,
2313
+ named.selectorText,
2314
+ prop,
2315
+ cssTextForStyleValue(prop, value),
2316
+ breakpoint,
2317
+ );
2318
+ if (!res.changed) {
2319
+ this.clearEcho(id, echoPath);
2320
+ this.refuseStyleWrite(
2321
+ `[ReactRootAuthoringAdapter] breakpoint css write refused/no-op for ` +
2322
+ `"${named.selectorText}" ${prop} at ${breakpoint}: ${res.error ?? 'no change'}`,
2323
+ );
2324
+ return false;
2325
+ }
2326
+ this.dirty = true;
2327
+ this.store.notifyIngestEdit();
2328
+ return true;
2329
+ }
2330
+ const matchedRules = this.matchedCssRules(n.el);
2331
+ const cssTarget = pickCssRuleTarget(matchedRules, prop);
2332
+ // Add undeclared properties to an existing named style unless inline CSS would shadow it.
2333
+ const namedTarget =
2334
+ !cssTarget && !liveInlineDeclares(n.el, prop)
2335
+ ? namedStyleRuleFor(matchedRules, classTokensOf(n.el))
2336
+ : null;
2337
+ const routedRule = cssTarget ?? namedTarget;
2338
+ if (routedRule) {
2339
+ // A CSS FILE declaration is CSS TEXT, so a numeric value needs its unit
2340
+ // spelled here (`300` -> `300px`) — unlike the JSX object below, where
2341
+ // React does the px-ifying. `css-numeric-style.ts` owns that rule once.
2342
+ const res = await backend.writeCss(
2343
+ routedRule.sourceFile,
2344
+ routedRule.selectorText,
2345
+ prop,
2346
+ cssTextForStyleValue(prop, value),
2347
+ );
2348
+ if (res.changed) {
2349
+ this.dirty = true;
2350
+ this.store.notifyIngestEdit();
2351
+ return true;
2352
+ }
2353
+ if (!res.generated) {
2354
+ this.clearEcho(id, echoPath);
2355
+ this.refuseStyleWrite(
2356
+ `[ReactRootAuthoringAdapter] CSS write refused/no-op for selector ` +
2357
+ `"${routedRule.selectorText}" prop "${prop}": ${res.error ?? 'no change'}`,
2358
+ );
2359
+ return false;
2360
+ }
2361
+ // res.generated ⇒ selector is generated CSS — fall through to inline/class routing.
2362
+ }
2363
+ value = preserveNumericStyleUnit(value, styleProp(n.el.style, prop));
2364
+ // NOT `String(value)`. A pixel-valued numeric descriptor must reach the writer as a
2365
+ // NUMBER so the JSX carries a bare `300`, which React px-ifies at render;
2366
+ // a quoted `'300'` is passed through verbatim by React
2367
+ // and REJECTED by the CSSOM, which keeps the previous value — a source
2368
+ // write with no rendered effect, acked `persisted: true`.
2369
+ const res = await backend.writeStyle(n.oid, prop, value);
2370
+ if (!res.changed) {
2371
+ this.clearEcho(id, echoPath);
2372
+ // Expression and token-reference refusals keep their distinct reasons.
2373
+ const guard = res.tokenRef
2374
+ ? tokenReferenceGuardText(prop, res.tokenRef)
2375
+ : res.dynamic
2376
+ ? DYNAMIC_EXPRESSION_GUARD
2377
+ : null;
2378
+ if (guard) this.markGuarded(id, echoPath, guard);
2379
+ // Notify after clearing echo or marking the descriptor read-only.
2380
+ this.store.notifyIngestEdit();
2381
+ this.refuseStyleWrite(
2382
+ `[ReactRootAuthoringAdapter] style write refused/no-op for oid "${n.oid}" ` +
2383
+ `prop "${prop}": ${guard ?? res.error ?? 'no change'}`,
2384
+ );
2385
+ return false;
2386
+ }
2387
+ this.dirty = true;
2388
+ // Apply successful inline writes immediately for inspector feedback. Box
2389
+ // gestures already previewed their values; stylesheet writes return above
2390
+ // so an inline patch cannot shadow their rule. Live CSS lengths need units.
2391
+ if (priorInlineOverride === undefined) {
2392
+ (n.el.style as unknown as Record<string, unknown>)[prop] = cssTextForStyleValue(prop, value);
2393
+ }
2394
+ this.store.notifyIngestEdit();
2395
+ return true;
2396
+ }
2397
+
2398
+ private async writeStyle(id: string, prop: string, value: string | number): Promise<boolean> {
2399
+ return this.writeStyleEntry(id, prop, value);
2400
+ }
2401
+
2402
+ /** Expand governing shorthands before writing a per-side property. */
2403
+ private async expandShorthandsFor(
2404
+ id: string,
2405
+ prop: string,
2406
+ backend: SourceWriteBackend | undefined,
2407
+ ): Promise<void> {
2408
+ const chain = shorthandChain(prop);
2409
+ if (chain.length === 0) return;
2410
+ const n = this.snapshot().nodes.get(id);
2411
+ if (!n || !backend) return;
2412
+ for (const shorthand of chain) {
2413
+ const longhands = SHORTHAND_LONGHANDS[shorthand] ?? [];
2414
+ // Read BEFORE the removal — the removal drops the live inline override,
2415
+ // so a read afterwards would see the cascade without it.
2416
+ const resolved = longhands.map((longhand) =>
2417
+ getComputedStyleValue(n.el, longhand),
2418
+ );
2419
+ // The SAME backend the caller writes through — a box-edit commit hands
2420
+ // in a scoped writer for atomicity, and an expansion that reached around
2421
+ // it would split one gesture across two transactions.
2422
+ if (!(await this.removeStyleProp(id, shorthand, backend))) continue; // not authored here
2423
+ for (const [index, longhand] of longhands.entries()) {
2424
+ if (longhand === prop) continue; // the caller writes this one itself
2425
+ const value = resolved[index];
2426
+ if (!value) continue;
2427
+ await this.writeStyleEntry(id, longhand, value, undefined, backend);
2428
+ }
2429
+ }
2430
+ }
2431
+
2432
+ /** Remove an authored style override through the source writer. */
2433
+ private async removeStyleProp(
2434
+ id: string,
2435
+ prop: string,
2436
+ backend: SourceWriteBackend | undefined = this.writeBackend,
2437
+ ): Promise<boolean> {
2438
+ const n = this.snapshot().nodes.get(id);
2439
+ if (!n || !backend) return false;
2440
+ const res = await backend.removeStyle(n.oid, prop);
2441
+ if (!res.changed) return false; // longhand wasn't authored — nothing removed, no undo
2442
+ this.dirty = true;
2443
+ delete (n.el.style as unknown as Record<string, unknown>)[prop];
2444
+ this.store.notifyIngestEdit();
2445
+ return true;
2446
+ }
2447
+
2448
+ /** Replace leaf text through the source-side expression/children guard. */
2449
+ async editText(id: string, newText: string): Promise<void> {
2450
+ const n = this.snapshot().nodes.get(id);
2451
+ if (!n) return;
2452
+ if (!this.writeBackend) {
2453
+ console.warn(
2454
+ `[ReactRootAuthoringAdapter] cannot edit text on "${id}": no source-write backend ` +
2455
+ 'in this session.',
2456
+ );
2457
+ return;
2458
+ }
2459
+ const res = await this.writeBackend.writeText(n.oid, newText);
2460
+ if (!res.changed) {
2461
+ // Remember dynamic-body refusal and notify its read-only indicator.
2462
+ if (res.dynamic) {
2463
+ this.markGuarded(
2464
+ id,
2465
+ TEXT_PATH,
2466
+ 'The body has an expression or child elements, so replacing it with text is guarded.',
2467
+ );
2468
+ }
2469
+ this.store.notifyIngestEdit();
2470
+ console.warn(
2471
+ `[ReactRootAuthoringAdapter] text edit refused/no-op for oid "${n.oid}": ` +
2472
+ `${res.dynamic ? 'body has an expression/children (guarded)' : (res.error ?? 'no change')}`,
2473
+ );
2474
+ return;
2475
+ }
2476
+ this.dirty = true;
2477
+ // Reconcile text-derived layout hints after the HMR DOM update.
2478
+ this.pendingSourceReconcile++;
2479
+ this.store.notifyIngestEdit();
2480
+ }
2481
+
2482
+ /** Write a component prop at the callsite through its source guard. */
2483
+ private async writePropEdit(id: string, prop: string, value: string): Promise<boolean> {
2484
+ // Clear this prop write's optimistic echo on refusal.
2485
+ const echoPath = `${PROP_PATH_PREFIX}${prop}`;
2486
+ const n = this.snapshot().nodes.get(id);
2487
+ if (!n) {
2488
+ this.clearEcho(id, echoPath);
2489
+ return false;
2490
+ }
2491
+ if (!this.writeBackend) {
2492
+ this.clearEcho(id, echoPath);
2493
+ console.warn(
2494
+ `[ReactRootAuthoringAdapter] cannot write prop "${prop}" on "${id}": no source-write ` +
2495
+ 'backend in this session.',
2496
+ );
2497
+ return false;
2498
+ }
2499
+ const cp = getComponentProps(n.el);
2500
+ if (!cp) {
2501
+ this.clearEcho(id, echoPath);
2502
+ console.warn(
2503
+ `[ReactRootAuthoringAdapter] "${id}" is not a component call site with props — ` +
2504
+ `cannot write prop "${prop}".`,
2505
+ );
2506
+ return false;
2507
+ }
2508
+ const res = await this.writeBackend.writeProp(cp.callSiteOid, prop, value);
2509
+ if (!res.changed) {
2510
+ this.clearEcho(id, echoPath);
2511
+ if (res.dynamic) this.markGuarded(id, echoPath, DYNAMIC_EXPRESSION_GUARD);
2512
+ // Notify the cleared echo and any newly read-only descriptor.
2513
+ this.store.notifyIngestEdit();
2514
+ console.warn(
2515
+ `[ReactRootAuthoringAdapter] prop write refused/no-op for oid "${cp.callSiteOid}" ` +
2516
+ `prop "${prop}": ${res.dynamic ? 'value is a dynamic expression (guarded)' : (res.error ?? 'no change')}`,
2517
+ );
2518
+ return false;
2519
+ }
2520
+ this.dirty = true;
2521
+ this.store.notifyIngestEdit();
2522
+ return true;
2523
+ }
2524
+
2525
+ private removeElement(id: string): Promise<WriteAck> {
2526
+ const n = this.snapshot().nodes.get(id);
2527
+ if (!n) return this.structRefusal(`cannot delete "${id}": no such node in this world.`);
2528
+ // Whole-file source history restores deletion without fabricating an inverse
2529
+ // element insertion from an OID that no longer exists.
2530
+ return this.structPipe.structOp(n.oid, id, 'delete', undefined, JSX_SOURCE_DESTINATION);
2531
+ }
2532
+
2533
+ /** Resolve all selected OIDs and delete them against one source snapshot. */
2534
+ private removeManyElements(ids: readonly string[]): Promise<WriteAck> {
2535
+ const snapshot = this.snapshot();
2536
+ const oids = [
2537
+ ...new Set(
2538
+ ids.map((id) => snapshot.nodes.get(id)?.oid).filter((oid): oid is string => !!oid),
2539
+ ),
2540
+ ];
2541
+ if (oids.length === 0) {
2542
+ return this.structRefusal('batch delete reached no source-addressable element.');
2543
+ }
2544
+ // One prepared batch source write becomes one history transaction.
2545
+ return this.structPipe.deleteMany(oids, JSX_SOURCE_DESTINATION);
2546
+ }
2547
+
2548
+ subscribe(listener: () => void): () => void {
2549
+ return this.store.subscribe(listener);
2550
+ }
2551
+
2552
+ // A class GETTER, not a field initializer: field initializers run before the
2553
+ // constructor body assigns `this.writeBackend`.
2554
+ get persistence(): PersistenceProvider {
2555
+ const backend = this.writeBackend;
2556
+ return {
2557
+ isDirty: () => this.dirty,
2558
+ save: async () => {
2559
+ // Source writes persist immediately; save has no pending queue to flush.
2560
+ },
2561
+ destination: backend ? JSX_SOURCE_DESTINATION : NO_BACKEND_DESTINATION,
2562
+ };
2563
+ }
2564
+
2565
+ /** Value writes resolve through the shared persistence pipe. The backend
2566
+ * owns whole-file history, so this layer records no second undo entry. */
2567
+ private pipedSourceWrite(write: () => Promise<boolean>): Promise<WriteAck> {
2568
+ return runWritePipe({
2569
+ resolve: (): WriteResolution =>
2570
+ this.writeBackend
2571
+ ? {
2572
+ reaches: 'writer',
2573
+ destination: JSX_SOURCE_DESTINATION,
2574
+ write,
2575
+ }
2576
+ : resolvesLiveOnly(NO_BACKEND_DESTINATION),
2577
+ record: () => undefined,
2578
+ // Report unavailable source writes at the pipe's resolution boundary.
2579
+ report: (reason) =>
2580
+ console.warn(`[ReactRootAuthoringAdapter] this edit stays live-only — ${reason}.`),
2581
+ });
2582
+ }
2583
+
2584
+ /** Structural writes use the shared pipe and queue a post-HMR notification.
2585
+ * The backend owns their complete-file history transaction. */
2586
+ private readonly structPipe = createStructWritePipe({
2587
+ // biome-ignore lint/suspicious/noConsole: a refused source write must be visible
2588
+ report: (message) => console.warn(`[ReactRootAuthoringAdapter] ${message}`),
2589
+ noWriterReason: NO_BACKEND_DESTINATION,
2590
+ backend: () => this.writeBackend,
2591
+ onChanged: () => {
2592
+ this.dirty = true;
2593
+ this.pendingSourceReconcile++;
2594
+ this.store.notifyIngestEdit();
2595
+ },
2596
+ });
2597
+
2598
+ /** The shared pipe's live-only floor, under this lane's own label. */
2599
+ private structRefusal(reason: string): Promise<WriteAck> {
2600
+ return this.structPipe.structRefusal(reason);
2601
+ }
2602
+ }