@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,2247 @@
1
+ import { setAuthoringSelection } from '@volter/sdk/authoring/consumer-actions';
2
+ import { faBookBookmark, faCode, faPalette } from '@fortawesome/free-solid-svg-icons';
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ DraftTextInput,
7
+ Select,
8
+ TextArea,
9
+ TextInput,
10
+ } from '@volter/sdk/widgets';
11
+
12
+ /** React inspector contribution. Resolve the selected node's owning React
13
+ * adapter through the composite contract, group its property descriptors, and
14
+ * render each with the matching widget. Both change handlers commit through
15
+ * inspector.set, which owns optimistic echo and source writes. */
16
+
17
+ import {
18
+ activeBreakpoint,
19
+ setActiveBreakpoint,
20
+ subscribeBreakpoint,
21
+ } from '@volter/sdk/breakpoint-state';
22
+ import { CompositeAuthoringAdapter } from '@volter/sdk/authoring/composite-authoring-adapter';
23
+ import { numericStyleValue, UNITLESS_NUMBER_PROPS } from '@volter/sdk/css-numeric-style';
24
+ import { beginEyedropperSession } from '@volter/sdk/eyedropper-session';
25
+ import { groupProperties } from '@volter/sdk/inspector-property-grouping';
26
+ import { useEditorStore } from '@volter/sdk/editor-runtime';
27
+ import { createHmrRegistrationGroup } from '@volter/sdk/hmr-registration-group';
28
+ import { groupIcon } from '@volter/sdk/inspection/group-icons';
29
+ import {
30
+ GROUP_SECTION_ORDER,
31
+ groupSectionId,
32
+ type InspectionSection,
33
+ PROPERTIES_SECTION_ID,
34
+ PROPERTIES_SECTION_ORDER,
35
+ } from '@volter/sdk/inspection-model';
36
+ import { registerInspectorSections } from '@volter/sdk/inspector-section-registry';
37
+ import { effectiveColorFromChain, getAvailableFonts } from '@volter/sdk/ui-source/inspect';
38
+ import type { OidEntry } from '@volter/editor-react/source/oid-transform';
39
+ import type { AuthoringAdapter, EditorNode, PropertyDescriptor } from '@volter/project/adapter';
40
+ import {
41
+ AlignmentGrid,
42
+ type AlignmentValue,
43
+ BorderEditor,
44
+ BorderRadiusEditor,
45
+ type BorderRadiusValue,
46
+ type BorderSide,
47
+ type BorderValue,
48
+ ColorSwatch,
49
+ composeFilter,
50
+ composeGradient,
51
+ composeShadows,
52
+ FilterEditor,
53
+ type FilterFunc,
54
+ FONT_MONO,
55
+ FontPicker,
56
+ fontSizeVar,
57
+ GradientEditor,
58
+ type GradientValue,
59
+ isUniformBorder,
60
+ isUniformRadius,
61
+ LABEL_COL_WIDTH,
62
+ lineHeightVar,
63
+ parseFilterFunctions,
64
+ parseGradient,
65
+ parseShadows,
66
+ radius,
67
+ rowStyle,
68
+ ScrubbableInput,
69
+ ShadowEditor,
70
+ spaceVar,
71
+ TextShadowEditor,
72
+ THEME,
73
+ } from '@volter/sdk/widgets';
74
+ import { createElement, useMemo, useState, useSyncExternalStore } from 'react';
75
+ import { cssColorToHex, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
76
+
77
+ /** Resolve the React adapter that owns the selected node, including
78
+ * composite children. Return null for an unresolved or non-React subject. */
79
+ function resolveReactOwner(
80
+ node: EditorNode | null,
81
+ adapter: AuthoringAdapter,
82
+ ): ReactRootAuthoringAdapter | null {
83
+ if (!node) return null;
84
+ const target: AuthoringAdapter | null = (() => {
85
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
86
+ const owner = adapter.ownerOf(node.id);
87
+ if (owner === null) return null;
88
+ return adapter.childAdapters().find((c) => c.worldId === owner)?.adapter ?? null;
89
+ })();
90
+ if (target instanceof ReactRootAuthoringAdapter) return target;
91
+ return null;
92
+ }
93
+
94
+ /** The registry `match`: any real selection owned by a react/DOM adapter. */
95
+ const isDomSelection = (node: EditorNode | null, adapter: AuthoringAdapter): boolean =>
96
+ resolveReactOwner(node, adapter) !== null;
97
+
98
+ /** Element and story facets precede property groups on the shared inspection ordering scale.
99
+ * Section ids name the inspected facet; the subject's identity row names the element itself. */
100
+ const REACT_ELEMENT_SECTION_ID = 'element';
101
+ const REACT_ELEMENT_SECTION_ORDER = 1500;
102
+ const REACT_STORY_SECTION_ID = 'story';
103
+ const REACT_STORY_SECTION_ORDER = 1600;
104
+
105
+ // --- Property descriptor to widget mapping ---
106
+
107
+ type ReactPropertyWidgetKind =
108
+ | 'color'
109
+ | 'font'
110
+ | 'gradient'
111
+ | 'shadow'
112
+ | 'text-shadow'
113
+ | 'filter'
114
+ | 'backdrop-filter'
115
+ | 'radius'
116
+ | 'transform'
117
+ | 'scrub'
118
+ | 'select'
119
+ | 'checkbox'
120
+ | 'json'
121
+ | 'text';
122
+
123
+ function propNameFromPath(path: string): string {
124
+ const idx = path.indexOf('.');
125
+ return idx === -1 ? path : path.slice(idx + 1);
126
+ }
127
+
128
+ /** Map structured CSS properties by name and other descriptors by type. */
129
+ function widgetKindFor(prop: PropertyDescriptor): ReactPropertyWidgetKind {
130
+ const name = propNameFromPath(prop.path);
131
+ if (prop.type === 'color') return 'color';
132
+ if (/^fontFamily$/i.test(name)) return 'font';
133
+ if (name === 'background' || name === 'backgroundImage') return 'gradient';
134
+ if (name === 'boxShadow') return 'shadow';
135
+ if (name === 'textShadow') return 'text-shadow';
136
+ if (name === 'filter') return 'filter';
137
+ if (name === 'backdropFilter') return 'backdrop-filter';
138
+ if (name === 'borderRadius') return 'radius';
139
+ // Scoped to `type: 'string'` (the react-world `style.transform` CSS-string
140
+ // descriptor) so an unrelated `transform` name of a DIFFERENT type — e.g. a
141
+ // three `vec3` position/transform prop — still falls through to its own
142
+ // generic widget instead of being misread as a CSS transform string.
143
+ if (prop.type === 'string' && name === 'transform') return 'transform';
144
+ if (prop.type === 'boolean') return 'checkbox';
145
+ if (prop.type === 'enum') return 'select';
146
+ if (prop.type === 'json') return 'json';
147
+ if (prop.type === 'number') return 'scrub';
148
+ // Other descriptors use the plain text input.
149
+ return 'text';
150
+ }
151
+
152
+ /** A composed row: either one property, or a "combo" of several sibling
153
+ * properties this section renders as ONE richer widget (per-side border,
154
+ * 3x3 flex alignment) instead of N separate fields. */
155
+ type PropertyRow =
156
+ | {
157
+ kind: 'border';
158
+ width: PropertyDescriptor;
159
+ style: PropertyDescriptor;
160
+ color: PropertyDescriptor;
161
+ }
162
+ | { kind: 'align'; justify: PropertyDescriptor; align: PropertyDescriptor }
163
+ | { kind: 'single'; prop: PropertyDescriptor };
164
+
165
+ /** Emit one combo at its first member and omit members already consumed. */
166
+ function tryBorderCombo(
167
+ p: PropertyDescriptor,
168
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
169
+ consumed: Set<string>,
170
+ rows: PropertyRow[],
171
+ ): boolean {
172
+ const isBorderProp =
173
+ p.path === 'style.borderWidth' ||
174
+ p.path === 'style.borderStyle' ||
175
+ p.path === 'style.borderColor';
176
+ if (!isBorderProp) return false;
177
+ const width = byPath.get('style.borderWidth');
178
+ const style = byPath.get('style.borderStyle');
179
+ const color = byPath.get('style.borderColor');
180
+ if (!width || !style || !color) return false;
181
+ rows.push({ kind: 'border', width, style, color });
182
+ consumed.add(width.path);
183
+ consumed.add(style.path);
184
+ consumed.add(color.path);
185
+ return true;
186
+ }
187
+
188
+ /** Emit one alignment combo when both justifyContent and alignItems are present. */
189
+ function tryAlignCombo(
190
+ p: PropertyDescriptor,
191
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
192
+ consumed: Set<string>,
193
+ rows: PropertyRow[],
194
+ ): boolean {
195
+ const isAlignProp = p.path === 'style.justifyContent' || p.path === 'style.alignItems';
196
+ if (!isAlignProp) return false;
197
+ const justify = byPath.get('style.justifyContent');
198
+ const align = byPath.get('style.alignItems');
199
+ if (!justify || !align) return false;
200
+ rows.push({ kind: 'align', justify, align });
201
+ consumed.add(justify.path);
202
+ consumed.add(align.path);
203
+ return true;
204
+ }
205
+
206
+ function buildPropertyRows(props: readonly PropertyDescriptor[]): PropertyRow[] {
207
+ const byPath = new Map(props.map((p) => [p.path, p] as const));
208
+ const consumed = new Set<string>();
209
+ const rows: PropertyRow[] = [];
210
+ for (const p of props) {
211
+ if (consumed.has(p.path)) continue;
212
+ if (tryBorderCombo(p, byPath, consumed, rows)) continue;
213
+ if (tryAlignCombo(p, byPath, consumed, rows)) continue;
214
+ rows.push({ kind: 'single', prop: p });
215
+ }
216
+ return rows;
217
+ }
218
+
219
+ function numericValue(v: unknown): number {
220
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
221
+ }
222
+
223
+ function testIdFor(path: string): string {
224
+ return `react-prop-${path.replace(/\./g, '-')}`;
225
+ }
226
+
227
+ const DEFAULT_GRADIENT: GradientValue = {
228
+ type: 'linear',
229
+ angle: 180,
230
+ stops: [
231
+ { color: '#ffffff', position: 0 },
232
+ { color: '#000000', position: 1 },
233
+ ],
234
+ };
235
+
236
+ /** Labeled property row. Its wrapper uses testId-row, keeping the control
237
+ * itself addressable by the unsuffixed testId. */
238
+ function Field({
239
+ label,
240
+ testId,
241
+ children,
242
+ }: {
243
+ label: string;
244
+ testId: string;
245
+ children: React.ReactNode;
246
+ }): React.ReactElement {
247
+ return (
248
+ <div
249
+ data-testid={`${testId}-row`}
250
+ style={{ ...rowStyle, padding: `${spaceVar[2]} ${spaceVar[4]}` }}
251
+ >
252
+ <span
253
+ style={{
254
+ width: LABEL_COL_WIDTH,
255
+ flexShrink: 0,
256
+ fontSize: fontSizeVar.base,
257
+ color: THEME.textMuted,
258
+ }}
259
+ >
260
+ {label}
261
+ </span>
262
+ <div style={{ flex: 1, minWidth: 0 }}>{children}</div>
263
+ </div>
264
+ );
265
+ }
266
+
267
+ /** The border and alignment variants rendered by ComboRow. */
268
+ type ComboPropertyRow = Extract<PropertyRow, { kind: 'border' } | { kind: 'align' }>;
269
+
270
+ interface ComboRowProps {
271
+ adapter: AuthoringAdapter;
272
+ id: string;
273
+ row: ComboPropertyRow;
274
+ }
275
+
276
+ /** Shared row inputs: raw is read from the Inspector; commit writes the
277
+ * requested path and notifies the store through the row's existing edit path. */
278
+ interface KindRowProps {
279
+ prop: PropertyDescriptor;
280
+ raw: unknown;
281
+ disabled: boolean | undefined;
282
+ testId: string;
283
+ commit: (path: string, value: unknown) => void;
284
+ fonts: readonly string[];
285
+ }
286
+
287
+ function ColorRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
288
+ return (
289
+ <Field label={prop.label} testId={testId}>
290
+ <ColorSwatch
291
+ testId={testId}
292
+ value={String(raw ?? '#ffffff')}
293
+ disabled={disabled}
294
+ onChange={(c) => commit(prop.path, c)}
295
+ onChangeEnd={(c) => commit(prop.path, c)}
296
+ eyedropperFallback={() => effectiveColorFromChain([String(raw ?? '')])}
297
+ // Use an interactive canvas sample session when native EyeDropper is unavailable.
298
+ onEyedropperPick={(apply) => beginEyedropperSession(apply)}
299
+ />
300
+ </Field>
301
+ );
302
+ }
303
+
304
+ function FontRow({ prop, raw, disabled, fonts, testId, commit }: KindRowProps): React.ReactElement {
305
+ return (
306
+ <Field label={prop.label} testId={testId}>
307
+ <FontPicker
308
+ testId={testId}
309
+ value={String(raw ?? '')}
310
+ fonts={fonts}
311
+ disabled={disabled}
312
+ onChange={(f) => commit(prop.path, f)}
313
+ />
314
+ </Field>
315
+ );
316
+ }
317
+
318
+ function GradientRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
319
+ const parsed = parseGradient(String(raw ?? 'none'));
320
+ // Show an empty state without a real gradient; seed defaults only for an explicit add.
321
+ if (!parsed) {
322
+ return (
323
+ <Field label={prop.label} testId={testId}>
324
+ <Button
325
+ type="button"
326
+ variant="secondary"
327
+ className="volter-field-trigger"
328
+ data-testid={testId}
329
+ disabled={disabled}
330
+ onClick={() => commit(prop.path, composeGradient(DEFAULT_GRADIENT))}
331
+ >
332
+ No gradient — click to add
333
+ </Button>
334
+ </Field>
335
+ );
336
+ }
337
+ return (
338
+ <Field label={prop.label} testId={testId}>
339
+ <GradientEditor
340
+ testId={testId}
341
+ value={parsed}
342
+ disabled={disabled}
343
+ onChange={(g) => commit(prop.path, composeGradient(g))}
344
+ onChangeEnd={(g) => commit(prop.path, composeGradient(g))}
345
+ />
346
+ </Field>
347
+ );
348
+ }
349
+
350
+ function ShadowRow({
351
+ prop,
352
+ raw,
353
+ disabled,
354
+ testId,
355
+ commit,
356
+ withSpreadInset,
357
+ }: KindRowProps & { withSpreadInset: boolean }): React.ReactElement {
358
+ const parsed = parseShadows(String(raw ?? 'none'), withSpreadInset);
359
+ const Widget = withSpreadInset ? ShadowEditor : TextShadowEditor;
360
+ return (
361
+ <Field label={prop.label} testId={testId}>
362
+ <Widget
363
+ value={parsed}
364
+ disabled={disabled}
365
+ onChange={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
366
+ onChangeEnd={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
367
+ />
368
+ </Field>
369
+ );
370
+ }
371
+
372
+ function FilterRow({
373
+ prop,
374
+ raw,
375
+ disabled,
376
+ testId,
377
+ commit,
378
+ label,
379
+ }: KindRowProps & { label: string }): React.ReactElement {
380
+ const parsed = parseFilterFunctions(String(raw ?? 'none'));
381
+ return (
382
+ <Field label={prop.label} testId={testId}>
383
+ <FilterEditor
384
+ testId={testId}
385
+ label={label}
386
+ value={parsed}
387
+ disabled={disabled}
388
+ onChange={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
389
+ onChangeEnd={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
390
+ />
391
+ </Field>
392
+ );
393
+ }
394
+
395
+ /** Corner suffixes (`borderTopLeftRadius`, …) shared by {@link RadiusRow}'s
396
+ * read + write-back — the per-corner longhand CSS the shorthand `borderRadius`
397
+ * descriptor (`prop`/`raw`/`commit` above) never had a slot for. */
398
+ const RADIUS_CORNERS: ReadonlyArray<{ key: keyof BorderRadiusValue; suffix: string }> = [
399
+ { key: 'topLeft', suffix: 'TopLeft' },
400
+ { key: 'topRight', suffix: 'TopRight' },
401
+ { key: 'bottomLeft', suffix: 'BottomLeft' },
402
+ { key: 'bottomRight', suffix: 'BottomRight' },
403
+ ];
404
+
405
+ /** Read each corner longhand, with shorthand fallback when unavailable.
406
+ * Uniform writes use borderRadius; nonuniform writes use four corner values. */
407
+ function RadiusRow({
408
+ adapter,
409
+ id,
410
+ prop,
411
+ raw,
412
+ disabled,
413
+ testId,
414
+ commit,
415
+ removeStyle,
416
+ }: KindRowProps & {
417
+ adapter: AuthoringAdapter;
418
+ id: string;
419
+ removeStyle: (path: string) => void;
420
+ }): React.ReactElement {
421
+ const fallback = numericValue(raw);
422
+ const value: BorderRadiusValue = RADIUS_CORNERS.reduce((acc, { key, suffix }) => {
423
+ const cornerRaw = adapter.inspector?.get(id, `style.border${suffix}Radius`);
424
+ acc[key] = numericStyleValue(cornerRaw) ?? fallback;
425
+ return acc;
426
+ }, {} as BorderRadiusValue);
427
+ const writeBack = (v: BorderRadiusValue): void => {
428
+ if (isUniformRadius(v)) {
429
+ commit(prop.path, v.topLeft);
430
+ // Remove corner longhands so a uniform shorthand controls every corner.
431
+ for (const { suffix } of RADIUS_CORNERS) {
432
+ removeStyle(`style.border${suffix}Radius`);
433
+ }
434
+ return;
435
+ }
436
+ for (const { key, suffix } of RADIUS_CORNERS) {
437
+ commit(`style.border${suffix}Radius`, v[key]);
438
+ }
439
+ };
440
+ return (
441
+ <Field label={prop.label} testId={testId}>
442
+ <BorderRadiusEditor
443
+ value={value}
444
+ disabled={disabled}
445
+ onChange={writeBack}
446
+ onChangeEnd={writeBack}
447
+ />
448
+ </Field>
449
+ );
450
+ }
451
+
452
+ function ScrubRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
453
+ const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
454
+ return (
455
+ <ScrubbableInput
456
+ testId={testId}
457
+ label={prop.label}
458
+ value={numericValue(raw)}
459
+ unit={unit}
460
+ disabled={disabled}
461
+ onChange={(v) => commit(prop.path, v)}
462
+ onChangeEnd={(v) => commit(prop.path, v)}
463
+ style={{ padding: `${spaceVar[2]} ${spaceVar[4]}` }}
464
+ />
465
+ );
466
+ }
467
+
468
+ function SelectRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
469
+ return (
470
+ <Field label={prop.label} testId={testId}>
471
+ <Select
472
+ data-testid={testId}
473
+ value={String(raw ?? '')}
474
+ disabled={disabled}
475
+ data-dynamic={disabled || undefined}
476
+ onChange={(e) => commit(prop.path, e.target.value)}
477
+ style={{ width: '100%' }}
478
+ >
479
+ {(prop.options ?? []).map((o) => (
480
+ <option key={String(o)} value={String(o)}>
481
+ {String(o)}
482
+ </option>
483
+ ))}
484
+ </Select>
485
+ </Field>
486
+ );
487
+ }
488
+
489
+ function CheckboxRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
490
+ return (
491
+ <label
492
+ style={{
493
+ display: 'flex',
494
+ gap: spaceVar[3],
495
+ alignItems: 'center',
496
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
497
+ fontSize: fontSizeVar.md,
498
+ }}
499
+ >
500
+ <Checkbox
501
+ className="volter-checkbox"
502
+ data-testid={testId}
503
+ checked={raw !== false}
504
+ disabled={disabled}
505
+ onChange={(e) => commit(prop.path, e.target.checked)}
506
+ />
507
+ {prop.label}
508
+ </label>
509
+ );
510
+ }
511
+
512
+ function JsonRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
513
+ return (
514
+ <Field label={prop.label} testId={testId}>
515
+ <TextArea
516
+ data-testid={testId}
517
+ defaultValue={JSON.stringify(raw ?? null)}
518
+ disabled={disabled}
519
+ data-dynamic={disabled || undefined}
520
+ data-variant="code"
521
+ style={{ width: '100%', minHeight: 40 }}
522
+ onBlur={(e) => {
523
+ try {
524
+ commit(prop.path, JSON.parse(e.target.value));
525
+ } catch {
526
+ // Invalid JSON — leave the field as typed, don't write garbage.
527
+ }
528
+ }}
529
+ />
530
+ </Field>
531
+ );
532
+ }
533
+
534
+ function TextRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
535
+ // Commit the draft on Enter or blur so source reprojection does not interrupt typing.
536
+ return (
537
+ <Field label={prop.label} testId={testId}>
538
+ <DraftTextInput
539
+ type="text"
540
+ data-testid={testId}
541
+ readOnly={disabled}
542
+ data-dynamic={disabled || undefined}
543
+ value={String(raw ?? '')}
544
+ onCommit={(next) => commit(prop.path, next)}
545
+ style={{ width: '100%' }}
546
+ />
547
+ </Field>
548
+ );
549
+ }
550
+
551
+ /** Rotate and scale controls for losslessly representable transforms. */
552
+ interface TransformValue {
553
+ rotateDeg: number;
554
+ scale: number;
555
+ }
556
+
557
+ const TRANSFORM_ROTATE_SCALE_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)\s*scale\(\s*(-?[\d.]+)\s*\)$/i;
558
+ const TRANSFORM_SCALE_ROTATE_RE = /^scale\(\s*(-?[\d.]+)\s*\)\s*rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
559
+ // Authored single-function literals also support the structured control.
560
+ const TRANSFORM_ROTATE_ONLY_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
561
+ const TRANSFORM_SCALE_ONLY_RE = /^scale\(\s*(-?[\d.]+)\s*\)$/i;
562
+ const TRANSFORM_MATRIX_RE =
563
+ /^matrix\(\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*\)$/;
564
+ const TRANSFORM_EPS = 1e-4;
565
+
566
+ /** Parse authored rotate/scale literals and browser-computed 2D matrices.
567
+ * Translation, skew, nonuniform or degenerate scale, and other transform
568
+ * functions remain editable as raw text rather than losing information. */
569
+ function parseTransform(raw: string): TransformValue | null {
570
+ const v = raw.trim();
571
+ if (v === '' || v === 'none') return { rotateDeg: 0, scale: 1 };
572
+ const lit = TRANSFORM_ROTATE_SCALE_RE.exec(v);
573
+ if (lit) return { rotateDeg: Number(lit[1]), scale: Number(lit[2]) };
574
+ const swapped = TRANSFORM_SCALE_ROTATE_RE.exec(v);
575
+ if (swapped) return { rotateDeg: Number(swapped[2]), scale: Number(swapped[1]) };
576
+ const rotOnly = TRANSFORM_ROTATE_ONLY_RE.exec(v);
577
+ if (rotOnly) return { rotateDeg: Number(rotOnly[1]), scale: 1 }; // Missing scale defaults to one.
578
+ const scaleOnly = TRANSFORM_SCALE_ONLY_RE.exec(v);
579
+ if (scaleOnly) return { rotateDeg: 0, scale: Number(scaleOnly[1]) }; // Missing rotation defaults to zero.
580
+ const m = TRANSFORM_MATRIX_RE.exec(v);
581
+ if (!m) return null; // matrix3d / translate / skew / any other function list
582
+ const [a, b, c, d, e, f] = m.slice(1, 7).map(Number) as [
583
+ number,
584
+ number,
585
+ number,
586
+ number,
587
+ number,
588
+ number,
589
+ ];
590
+ if (Math.abs(e) > TRANSFORM_EPS || Math.abs(f) > TRANSFORM_EPS) return null; // has a translate
591
+ if (Math.abs(c + b) > TRANSFORM_EPS || Math.abs(d - a) > TRANSFORM_EPS) return null; // skew / non-uniform scale
592
+ const scale = Math.hypot(a, b);
593
+ if (scale < TRANSFORM_EPS) return null; // degenerate (zero scale)
594
+ const rotateDeg = (Math.atan2(b, a) * 180) / Math.PI;
595
+ return {
596
+ rotateDeg: Math.round(rotateDeg * 100) / 100,
597
+ scale: Math.round(scale * 1000) / 1000,
598
+ };
599
+ }
600
+
601
+ /** The inverse of {@link parseTransform}'s literal fast-path — always
602
+ * composes the readable `rotate()/scale()` form, never a matrix, so the
603
+ * written source stays human-editable. */
604
+ function composeTransform(v: TransformValue): string {
605
+ return `rotate(${v.rotateDeg}deg) scale(${v.scale})`;
606
+ }
607
+
608
+ function TransformRow(props: KindRowProps): React.ReactElement {
609
+ const { prop, raw, disabled, testId, commit } = props;
610
+ const parsed = parseTransform(String(raw ?? 'none'));
611
+ if (!parsed) return <TextRow {...props} />; // safety net — no expressiveness lost
612
+ const writeBack = (next: TransformValue): void => commit(prop.path, composeTransform(next));
613
+ return (
614
+ <Field label={prop.label} testId={testId}>
615
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
616
+ <ScrubbableInput
617
+ testId={`${testId}-rotate`}
618
+ label="Rotate"
619
+ value={parsed.rotateDeg}
620
+ unit="deg"
621
+ disabled={disabled}
622
+ onChange={(v) => writeBack({ ...parsed, rotateDeg: v })}
623
+ onChangeEnd={(v) => writeBack({ ...parsed, rotateDeg: v })}
624
+ style={{ flex: 1 }}
625
+ />
626
+ <ScrubbableInput
627
+ testId={`${testId}-scale`}
628
+ label="Scale"
629
+ value={parsed.scale}
630
+ unit="x"
631
+ step={0.1}
632
+ disabled={disabled}
633
+ onChange={(v) => writeBack({ ...parsed, scale: v })}
634
+ onChangeEnd={(v) => writeBack({ ...parsed, scale: v })}
635
+ style={{ flex: 1 }}
636
+ />
637
+ </div>
638
+ </Field>
639
+ );
640
+ }
641
+
642
+ /** Select the widget for one property row. Preview and final changes both
643
+ * use the same source commit path. */
644
+ function SingleRow({
645
+ adapter,
646
+ id,
647
+ prop,
648
+ fonts,
649
+ }: {
650
+ adapter: AuthoringAdapter;
651
+ id: string;
652
+ prop: PropertyDescriptor;
653
+ fonts: readonly string[];
654
+ }): React.ReactElement {
655
+ const store = useEditorStore();
656
+ const commit = (path: string, value: unknown): void => {
657
+ adapter.inspector?.set(id, path, value);
658
+ store.notifyIngestEdit();
659
+ };
660
+ // Remove an authored longhand when this inspector supports removal.
661
+ const removeStyle = (path: string): void => {
662
+ adapter.inspector?.remove?.(id, path);
663
+ store.notifyIngestEdit();
664
+ };
665
+ const rowProps: KindRowProps = {
666
+ prop,
667
+ raw: adapter.inspector?.get(id, prop.path),
668
+ disabled: prop.readonly,
669
+ testId: testIdFor(prop.path),
670
+ commit,
671
+ fonts,
672
+ };
673
+ switch (widgetKindFor(prop)) {
674
+ case 'color':
675
+ return <ColorRow {...rowProps} />;
676
+ case 'font':
677
+ return <FontRow {...rowProps} />;
678
+ case 'gradient':
679
+ return <GradientRow {...rowProps} />;
680
+ case 'shadow':
681
+ return <ShadowRow {...rowProps} withSpreadInset />;
682
+ case 'text-shadow':
683
+ return <ShadowRow {...rowProps} withSpreadInset={false} />;
684
+ case 'filter':
685
+ return <FilterRow {...rowProps} label="Filter" />;
686
+ case 'backdrop-filter':
687
+ return <FilterRow {...rowProps} label="Backdrop Filter" />;
688
+ case 'radius':
689
+ return <RadiusRow {...rowProps} adapter={adapter} id={id} removeStyle={removeStyle} />;
690
+ case 'transform':
691
+ return <TransformRow {...rowProps} />;
692
+ case 'scrub':
693
+ return <ScrubRow {...rowProps} />;
694
+ case 'select':
695
+ return <SelectRow {...rowProps} />;
696
+ case 'checkbox':
697
+ return <CheckboxRow {...rowProps} />;
698
+ case 'json':
699
+ return <JsonRow {...rowProps} />;
700
+ default:
701
+ return <TextRow {...rowProps} />;
702
+ }
703
+ }
704
+
705
+ /** A `border`/`align` combo row — reads all its member props, composes the
706
+ * widget's structured value, and writes every member back on change. */
707
+ function ComboRow({ adapter, id, row }: ComboRowProps): React.ReactElement | null {
708
+ const store = useEditorStore();
709
+ const commit = (path: string, value: unknown): void => {
710
+ adapter.inspector?.set(id, path, value);
711
+ store.notifyIngestEdit();
712
+ };
713
+ const removeStyle = (path: string): void => {
714
+ adapter.inspector?.remove?.(id, path);
715
+ store.notifyIngestEdit();
716
+ };
717
+ if (row.kind === 'border') {
718
+ // Read each side's longhands with uniform shorthand fallback when unavailable.
719
+ const fallback: BorderSide = {
720
+ width: numericValue(adapter.inspector?.get(id, row.width.path)),
721
+ style: String(adapter.inspector?.get(id, row.style.path) ?? 'solid'),
722
+ color: String(adapter.inspector?.get(id, row.color.path) ?? '#000000'),
723
+ };
724
+ const readSide = (suffix: 'Top' | 'Right' | 'Bottom' | 'Left'): BorderSide => {
725
+ const w = numericStyleValue(adapter.inspector?.get(id, `style.border${suffix}Width`));
726
+ const st = adapter.inspector?.get(id, `style.border${suffix}Style`);
727
+ const c = adapter.inspector?.get(id, `style.border${suffix}Color`);
728
+ // Normalize computed side colors before a later uniform commit.
729
+ const cHex = typeof c === 'string' ? cssColorToHex(c) : undefined;
730
+ return {
731
+ width: w ?? fallback.width,
732
+ style: typeof st === 'string' && st ? st : fallback.style,
733
+ color: cHex ?? fallback.color,
734
+ };
735
+ };
736
+ const value: BorderValue = {
737
+ top: readSide('Top'),
738
+ right: readSide('Right'),
739
+ bottom: readSide('Bottom'),
740
+ left: readSide('Left'),
741
+ };
742
+ // Uniform borders write the three shorthand descriptors; independent
743
+ // side values write their CSS longhands.
744
+ const sideSuffixes = ['Top', 'Right', 'Bottom', 'Left'] as const;
745
+ const writeBack = (v: BorderValue): void => {
746
+ if (isUniformBorder(v)) {
747
+ commit(row.width.path, v.top.width);
748
+ commit(row.style.path, v.top.style);
749
+ commit(row.color.path, v.top.color);
750
+ // Clear side longhands so the uniform shorthand takes effect.
751
+ for (const suffix of sideSuffixes) {
752
+ removeStyle(`style.border${suffix}Width`);
753
+ removeStyle(`style.border${suffix}Style`);
754
+ removeStyle(`style.border${suffix}Color`);
755
+ }
756
+ return;
757
+ }
758
+ const sides: ReadonlyArray<{
759
+ suffix: 'Top' | 'Right' | 'Bottom' | 'Left';
760
+ side: BorderSide;
761
+ }> = sideSuffixes.map((suffix) => ({
762
+ suffix,
763
+ side: v[suffix.toLowerCase() as 'top' | 'right' | 'bottom' | 'left'],
764
+ }));
765
+ for (const { suffix, side } of sides) {
766
+ commit(`style.border${suffix}Width`, side.width);
767
+ commit(`style.border${suffix}Style`, side.style);
768
+ commit(`style.border${suffix}Color`, side.color);
769
+ }
770
+ };
771
+ return (
772
+ <Field label="Border" testId="react-prop-border">
773
+ <BorderEditor value={value} onChange={writeBack} onChangeEnd={writeBack} />
774
+ </Field>
775
+ );
776
+ }
777
+ const value: AlignmentValue = {
778
+ justify:
779
+ (adapter.inspector?.get(id, row.justify.path) as AlignmentValue['justify']) ?? 'flex-start',
780
+ align: (adapter.inspector?.get(id, row.align.path) as AlignmentValue['align']) ?? 'flex-start',
781
+ };
782
+ return (
783
+ <Field label="Align" testId="react-prop-align">
784
+ <AlignmentGrid
785
+ value={value}
786
+ onChange={(v) => {
787
+ commit(row.justify.path, v.justify);
788
+ commit(row.align.path, v.align);
789
+ }}
790
+ />
791
+ </Field>
792
+ );
793
+ }
794
+
795
+ function rowKey(row: PropertyRow): string {
796
+ if (row.kind === 'single') return row.prop.path;
797
+ if (row.kind === 'border') return `border:${row.width.path}`;
798
+ return `align:${row.justify.path}`;
799
+ }
800
+
801
+ function compactScrub(
802
+ adapter: AuthoringAdapter,
803
+ id: string,
804
+ prop: PropertyDescriptor | undefined,
805
+ label: string,
806
+ commit: (path: string, value: unknown) => void,
807
+ ): React.ReactElement | null {
808
+ if (!prop) return null;
809
+ const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
810
+ const testId = testIdFor(prop.path);
811
+ return (
812
+ <ScrubbableInput
813
+ key={prop.path}
814
+ testId={testId}
815
+ label={label}
816
+ value={numericValue(adapter.inspector?.get(id, prop.path))}
817
+ unit={unit}
818
+ disabled={prop.readonly}
819
+ onChange={(value) => commit(prop.path, value)}
820
+ onChangeEnd={(value) => commit(prop.path, value)}
821
+ style={{ flex: 1, minWidth: 0 }}
822
+ />
823
+ );
824
+ }
825
+
826
+ function SemanticLayout({
827
+ adapter,
828
+ id,
829
+ props,
830
+ fonts,
831
+ showConstraints,
832
+ onToggleConstraints,
833
+ }: {
834
+ adapter: AuthoringAdapter;
835
+ id: string;
836
+ props: readonly PropertyDescriptor[];
837
+ fonts: readonly string[];
838
+ showConstraints: boolean;
839
+ onToggleConstraints: () => void;
840
+ }): React.ReactElement {
841
+ const store = useEditorStore();
842
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
843
+ const commit = (path: string, value: unknown): void => {
844
+ adapter.inspector?.set(id, path, value);
845
+ store.notifyIngestEdit();
846
+ };
847
+ const display = String(
848
+ adapter.inspector?.get(id, byName.get('display')?.path ?? 'style.display') ?? '',
849
+ );
850
+ const isFlex = display === 'flex' || display === 'inline-flex';
851
+ const isGrid = display === 'grid' || display === 'inline-grid';
852
+ const displayProp = byName.get('display');
853
+ const gapProp = byName.get('gap');
854
+ const alignRows = buildPropertyRows(
855
+ props.filter((prop) => ['justifyContent', 'alignItems'].includes(propNameFromPath(prop.path))),
856
+ );
857
+ const ordinary = ['flexDirection', 'flexWrap', 'overflow']
858
+ .map((name) => byName.get(name))
859
+ .filter((prop): prop is PropertyDescriptor => !!prop);
860
+ return (
861
+ <div style={{ padding: '2px 8px 7px' }}>
862
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
863
+ {compactScrub(adapter, id, byName.get('width'), 'W', commit)}
864
+ {compactScrub(adapter, id, byName.get('height'), 'H', commit)}
865
+ </div>
866
+ {showConstraints && (
867
+ <div data-testid="react-layout-constraints" style={{ marginTop: 5 }}>
868
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
869
+ {compactScrub(adapter, id, byName.get('minWidth'), 'Min W', commit)}
870
+ {compactScrub(adapter, id, byName.get('minHeight'), 'Min H', commit)}
871
+ </div>
872
+ <div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[2] }}>
873
+ {compactScrub(adapter, id, byName.get('maxWidth'), 'Max W', commit)}
874
+ {compactScrub(adapter, id, byName.get('maxHeight'), 'Max H', commit)}
875
+ </div>
876
+ </div>
877
+ )}
878
+ <Button
879
+ type="button"
880
+ variant="ghost"
881
+ size="compact"
882
+ data-testid="react-layout-toggle-constraints"
883
+ onClick={onToggleConstraints}
884
+ style={{ display: 'flex', margin: '2px auto 5px' }}
885
+ >
886
+ {showConstraints ? '− constraints' : '··· constraints'}
887
+ </Button>
888
+ <div
889
+ style={{
890
+ display: 'grid',
891
+ gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 1fr)',
892
+ gap: spaceVar[3],
893
+ }}
894
+ >
895
+ {displayProp && (
896
+ <Select
897
+ data-testid={testIdFor(displayProp.path)}
898
+ value={display}
899
+ disabled={displayProp.readonly}
900
+ data-dynamic={displayProp.readonly || undefined}
901
+ onChange={(event) => commit(displayProp.path, event.target.value)}
902
+ style={{ width: '100%' }}
903
+ >
904
+ {(displayProp.options ?? []).map((option) => (
905
+ <option key={String(option)} value={String(option)}>
906
+ {String(option)}
907
+ </option>
908
+ ))}
909
+ </Select>
910
+ )}
911
+ {compactScrub(adapter, id, gapProp, 'Gap', commit)}
912
+ </div>
913
+ {isFlex && (
914
+ <div data-testid="react-layout-flex-controls" style={{ marginTop: 5 }}>
915
+ {ordinary
916
+ .filter((prop) => propNameFromPath(prop.path) !== 'overflow')
917
+ .map((prop) => (
918
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
919
+ ))}
920
+ {alignRows.map((row) =>
921
+ row.kind === 'single' ? (
922
+ <SingleRow
923
+ key={rowKey(row)}
924
+ adapter={adapter}
925
+ id={id}
926
+ prop={row.prop}
927
+ fonts={fonts}
928
+ />
929
+ ) : (
930
+ <ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
931
+ ),
932
+ )}
933
+ </div>
934
+ )}
935
+ {isGrid && (
936
+ <div data-testid="react-layout-grid-controls" style={{ marginTop: 5 }}>
937
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
938
+ {gridCountScrub(adapter, id, byName.get('gridTemplateColumns'), 'Cols', commit)}
939
+ {gridCountScrub(adapter, id, byName.get('gridTemplateRows'), 'Rows', commit)}
940
+ </div>
941
+ {[byName.get('gridTemplateColumns'), byName.get('gridTemplateRows')]
942
+ .filter((prop): prop is PropertyDescriptor => !!prop)
943
+ .map((prop) => (
944
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
945
+ ))}
946
+ <div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[1] }}>
947
+ {compactScrub(adapter, id, byName.get('rowGap'), 'Row Gap', commit)}
948
+ {compactScrub(adapter, id, byName.get('columnGap'), 'Col Gap', commit)}
949
+ </div>
950
+ {byName.get('gridAutoFlow') && (
951
+ <SingleRow
952
+ adapter={adapter}
953
+ id={id}
954
+ prop={byName.get('gridAutoFlow') as PropertyDescriptor}
955
+ fonts={fonts}
956
+ />
957
+ )}
958
+ </div>
959
+ )}
960
+ {ordinary
961
+ .filter((prop) => propNameFromPath(prop.path) === 'overflow')
962
+ .map((prop) => (
963
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
964
+ ))}
965
+ <GridItemDisclosure adapter={adapter} id={id} byName={byName} fonts={fonts} />
966
+ </div>
967
+ );
968
+ }
969
+
970
+ /** Count grid tracks, expanding integer repeat() groups. Auto-fit/auto-fill
971
+ * repeats count as one segment because their resolved count needs layout. */
972
+ function gridTrackCount(template: string): number {
973
+ const t = template.trim();
974
+ if (!t || t === 'none') return 0;
975
+ const segments: string[] = [];
976
+ let depth = 0;
977
+ let start = 0;
978
+ for (let i = 0; i < t.length; i++) {
979
+ const ch = t[i];
980
+ if (ch === '(' || ch === '[') depth++;
981
+ else if (ch === ')' || ch === ']') depth--;
982
+ else if (ch === ' ' && depth === 0) {
983
+ if (i > start) segments.push(t.slice(start, i));
984
+ start = i + 1;
985
+ }
986
+ }
987
+ if (start < t.length) segments.push(t.slice(start));
988
+ let count = 0;
989
+ for (const segment of segments) {
990
+ const rep = /^repeat\(\s*(\d+)\s*,([\s\S]*)\)$/.exec(segment);
991
+ count += rep?.[1] && rep[2] !== undefined ? Number(rep[1]) * gridTrackCount(rep[2]) : 1;
992
+ }
993
+ return count;
994
+ }
995
+
996
+ /** Figma's grid COUNT control on a CSS template: scrub/type the track count
997
+ * and the template becomes `repeat(N, 1fr)` — exactly what Figma's
998
+ * rows/columns numbers mean. A custom template (minmax, named lines) stays
999
+ * authorable through the template text row below; bumping the count
1000
+ * deliberately rewrites to N equal tracks, same as Figma. */
1001
+ function gridCountScrub(
1002
+ adapter: AuthoringAdapter,
1003
+ id: string,
1004
+ prop: PropertyDescriptor | undefined,
1005
+ label: string,
1006
+ commit: (path: string, value: unknown) => void,
1007
+ ): React.ReactElement | null {
1008
+ if (!prop) return null;
1009
+ // Update the controlled value on change and suppress duplicate source writes.
1010
+ let lastWritten: string | null = null;
1011
+ const write = (value: number): void => {
1012
+ const n = Math.max(1, Math.round(value));
1013
+ const template = `repeat(${n}, 1fr)`;
1014
+ if (template === lastWritten) return;
1015
+ lastWritten = template;
1016
+ commit(prop.path, template);
1017
+ };
1018
+ return (
1019
+ <ScrubbableInput
1020
+ key={`${prop.path}-count`}
1021
+ testId={`${testIdFor(prop.path)}-count`}
1022
+ label={label}
1023
+ value={gridTrackCount(String(adapter.inspector?.get(id, prop.path) ?? ''))}
1024
+ min={1}
1025
+ disabled={prop.readonly}
1026
+ onChange={write}
1027
+ onChangeEnd={write}
1028
+ style={{ flex: 1, minWidth: 0 }}
1029
+ />
1030
+ );
1031
+ }
1032
+
1033
+ /** The grid-ITEM half (placement lives on the child, not the container):
1034
+ * `grid-column` / `grid-row` / `justify-self` behind a ghost disclosure —
1035
+ * same idiom as the constraints toggle, because for most elements these
1036
+ * are noise, and the inspector cannot cheaply know whether the PARENT is
1037
+ * a grid (its reads are node-scoped). */
1038
+ function GridItemDisclosure({
1039
+ adapter,
1040
+ id,
1041
+ byName,
1042
+ fonts,
1043
+ }: {
1044
+ adapter: AuthoringAdapter;
1045
+ id: string;
1046
+ byName: ReadonlyMap<string, PropertyDescriptor>;
1047
+ fonts: readonly string[];
1048
+ }): React.ReactElement | null {
1049
+ const [open, setOpen] = useState(false);
1050
+ const props = ['gridColumn', 'gridRow', 'justifySelf']
1051
+ .map((name) => byName.get(name))
1052
+ .filter((prop): prop is PropertyDescriptor => !!prop);
1053
+ if (props.length === 0) return null;
1054
+ return (
1055
+ <>
1056
+ <Button
1057
+ type="button"
1058
+ variant="ghost"
1059
+ size="compact"
1060
+ data-testid="react-layout-toggle-grid-item"
1061
+ onClick={() => setOpen((value) => !value)}
1062
+ style={{ display: 'flex', margin: '2px auto 0' }}
1063
+ >
1064
+ {open ? '− grid item' : '··· grid item'}
1065
+ </Button>
1066
+ {open && (
1067
+ <div data-testid="react-layout-grid-item">
1068
+ {props.map((prop) => (
1069
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1070
+ ))}
1071
+ </div>
1072
+ )}
1073
+ </>
1074
+ );
1075
+ }
1076
+
1077
+ /** Split a `background-image` value into its top-level comma layers (CSS
1078
+ * paints the FIRST layer on top). `none`/empty ⇒ []. */
1079
+ function splitFillLayers(value: string): string[] {
1080
+ const t = value.trim();
1081
+ if (!t || t === 'none') return [];
1082
+ const layers: string[] = [];
1083
+ let depth = 0;
1084
+ let start = 0;
1085
+ for (let i = 0; i < t.length; i++) {
1086
+ const ch = t[i];
1087
+ if (ch === '(') depth++;
1088
+ else if (ch === ')') depth--;
1089
+ else if (ch === ',' && depth === 0) {
1090
+ layers.push(t.slice(start, i).trim());
1091
+ start = i + 1;
1092
+ }
1093
+ }
1094
+ layers.push(t.slice(start).trim());
1095
+ return layers.filter(Boolean);
1096
+ }
1097
+
1098
+ /** One fill layer's URL, when the layer is an image paint. */
1099
+ function fillLayerUrl(layer: string): string | null {
1100
+ const m = /^url\((['"]?)([\s\S]*)\1\)$/.exec(layer.trim());
1101
+ return m?.[2] ?? null;
1102
+ }
1103
+
1104
+ /** Background paints in CSS order: image/gradient layers above one solid
1105
+ * background color. Add, remove, and reorder rewrite the layer list. Image
1106
+ * layers have no independent CSS visibility or opacity property. */
1107
+ function SemanticFill({
1108
+ adapter,
1109
+ id,
1110
+ props,
1111
+ fonts,
1112
+ }: {
1113
+ adapter: AuthoringAdapter;
1114
+ id: string;
1115
+ props: readonly PropertyDescriptor[];
1116
+ fonts: readonly string[];
1117
+ }): React.ReactElement {
1118
+ const store = useEditorStore();
1119
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1120
+ const commit = (path: string, value: unknown): void => {
1121
+ adapter.inspector?.set(id, path, value);
1122
+ store.notifyIngestEdit();
1123
+ };
1124
+ const imageProp = byName.get('backgroundImage');
1125
+ const colorProp = byName.get('backgroundColor');
1126
+ const [expanded, setExpanded] = useState<number | null>(null);
1127
+ const [imageDraft, setImageDraft] = useState<string | null>(null);
1128
+ const raw = String(imageProp ? (adapter.inspector?.get(id, imageProp.path) ?? '') : '');
1129
+ const layers = splitFillLayers(raw);
1130
+ const write = (next: string[]): void => {
1131
+ if (!imageProp) return;
1132
+ if (next.length > 0) {
1133
+ commit(imageProp.path, next.join(', '));
1134
+ return;
1135
+ }
1136
+ // An emptied list REMOVES the member — `backgroundImage: 'none'` parked
1137
+ // in source is authored noise the element never had.
1138
+ adapter.inspector?.remove?.(id, imageProp.path);
1139
+ store.notifyIngestEdit();
1140
+ };
1141
+ const replaceLayer = (index: number, layer: string): void =>
1142
+ write(layers.map((l, j) => (j === index ? layer : l)));
1143
+ const removeLayer = (index: number): void => {
1144
+ setExpanded(null);
1145
+ write(layers.filter((_, j) => j !== index));
1146
+ };
1147
+ const moveLayer = (index: number, delta: number): void => {
1148
+ const next = [...layers];
1149
+ const swap = next[index + delta];
1150
+ if (swap === undefined || next[index] === undefined) return;
1151
+ next[index + delta] = next[index] as string;
1152
+ next[index] = swap;
1153
+ setExpanded(null);
1154
+ write(next);
1155
+ };
1156
+ // Use double-quoted URLs inside the writer's single-quoted style literals.
1157
+ const layerButton: React.CSSProperties = {
1158
+ background: 'none',
1159
+ border: 'none',
1160
+ color: THEME.textMuted,
1161
+ cursor: 'pointer',
1162
+ fontSize: fontSizeVar.base,
1163
+ padding: `${spaceVar[2]} ${spaceVar[3]}`,
1164
+ lineHeight: lineHeightVar.tight,
1165
+ };
1166
+ return (
1167
+ <div data-testid="react-fill-list" style={{ padding: '2px 8px 7px' }}>
1168
+ {layers.map((layer, index) => {
1169
+ const gradient = parseGradient(layer);
1170
+ const url = fillLayerUrl(layer);
1171
+ const label = gradient
1172
+ ? `${gradient.type} gradient`
1173
+ : url !== null
1174
+ ? (url.split('/').pop() ?? 'image')
1175
+ : layer.slice(0, 22);
1176
+ return (
1177
+ <div key={`${index}-${layer.slice(0, 24)}`} style={{ marginBottom: 3 }}>
1178
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
1179
+ <button
1180
+ type="button"
1181
+ data-testid={`react-fill-layer-${index}`}
1182
+ title={layer}
1183
+ onClick={() => setExpanded(expanded === index ? null : index)}
1184
+ style={{
1185
+ display: 'flex',
1186
+ alignItems: 'center',
1187
+ gap: spaceVar[3],
1188
+ flex: 1,
1189
+ minWidth: 0,
1190
+ background: 'none',
1191
+ border: 'none',
1192
+ cursor: 'pointer',
1193
+ padding: `${spaceVar[1]} 0`,
1194
+ color: THEME.text,
1195
+ fontSize: fontSizeVar.base,
1196
+ textAlign: 'left',
1197
+ }}
1198
+ >
1199
+ <span
1200
+ style={{
1201
+ width: 16,
1202
+ height: 16,
1203
+ flexShrink: 0,
1204
+ borderRadius: radius.sm,
1205
+ border: `1px solid ${THEME.border}`,
1206
+ background: layer,
1207
+ backgroundSize: 'cover',
1208
+ }}
1209
+ />
1210
+ <span
1211
+ style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
1212
+ >
1213
+ {label}
1214
+ </span>
1215
+ </button>
1216
+ {index > 0 && (
1217
+ <button
1218
+ type="button"
1219
+ data-testid={`react-fill-up-${index}`}
1220
+ title="Raise this fill"
1221
+ onClick={() => moveLayer(index, -1)}
1222
+ style={layerButton}
1223
+ >
1224
+ ↑
1225
+ </button>
1226
+ )}
1227
+ {index < layers.length - 1 && (
1228
+ <button
1229
+ type="button"
1230
+ data-testid={`react-fill-down-${index}`}
1231
+ title="Lower this fill"
1232
+ onClick={() => moveLayer(index, 1)}
1233
+ style={layerButton}
1234
+ >
1235
+ ↓
1236
+ </button>
1237
+ )}
1238
+ <button
1239
+ type="button"
1240
+ data-testid={`react-fill-remove-${index}`}
1241
+ title="Remove this fill (CSS has no hidden-layer state — removing is Figma's eye)"
1242
+ onClick={() => removeLayer(index)}
1243
+ style={layerButton}
1244
+ >
1245
+ ✕
1246
+ </button>
1247
+ </div>
1248
+ {expanded === index && gradient && (
1249
+ <GradientEditor
1250
+ testId={`react-fill-gradient-${index}`}
1251
+ value={gradient}
1252
+ onChange={(g) => replaceLayer(index, composeGradient(g))}
1253
+ onChangeEnd={(g) => replaceLayer(index, composeGradient(g))}
1254
+ />
1255
+ )}
1256
+ {expanded === index && url !== null && (
1257
+ <TextInput
1258
+ data-testid={`react-fill-url-${index}`}
1259
+ defaultValue={url}
1260
+ placeholder="image url"
1261
+ onBlur={(event) => replaceLayer(index, `url("${event.target.value.trim()}")`)}
1262
+ onKeyDown={(event) => {
1263
+ if (event.key === 'Enter')
1264
+ replaceLayer(index, `url("${event.currentTarget.value.trim()}")`);
1265
+ }}
1266
+ style={{ width: '100%', marginTop: spaceVar[1] }}
1267
+ />
1268
+ )}
1269
+ </div>
1270
+ );
1271
+ })}
1272
+ {imageDraft !== null && (
1273
+ <TextInput
1274
+ data-testid="react-fill-image-draft"
1275
+ value={imageDraft}
1276
+ placeholder="image url — Enter to add"
1277
+ autoFocus
1278
+ onChange={(event) => setImageDraft(event.target.value)}
1279
+ onKeyDown={(event) => {
1280
+ if (event.key === 'Enter' && imageDraft.trim()) {
1281
+ write([`url("${imageDraft.trim()}")`, ...layers]);
1282
+ setImageDraft(null);
1283
+ }
1284
+ if (event.key === 'Escape') setImageDraft(null);
1285
+ }}
1286
+ style={{ width: '100%', marginBottom: 3 }}
1287
+ />
1288
+ )}
1289
+ <div style={{ display: 'flex', gap: spaceVar[2] }}>
1290
+ <Button
1291
+ type="button"
1292
+ variant="ghost"
1293
+ size="compact"
1294
+ data-testid="react-fill-add-gradient"
1295
+ onClick={() => {
1296
+ write([composeGradient(DEFAULT_GRADIENT), ...layers]);
1297
+ setExpanded(0);
1298
+ }}
1299
+ >
1300
+ + gradient
1301
+ </Button>
1302
+ <Button
1303
+ type="button"
1304
+ variant="ghost"
1305
+ size="compact"
1306
+ data-testid="react-fill-add-image"
1307
+ onClick={() => setImageDraft('')}
1308
+ >
1309
+ + image
1310
+ </Button>
1311
+ </div>
1312
+ {layers.some((layer) => fillLayerUrl(layer) !== null) &&
1313
+ ['backgroundSize', 'backgroundPosition', 'backgroundRepeat']
1314
+ .map((name) => byName.get(name))
1315
+ .filter((prop): prop is PropertyDescriptor => !!prop)
1316
+ .map((prop) => (
1317
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1318
+ ))}
1319
+ {colorProp && <SingleRow adapter={adapter} id={id} prop={colorProp} fonts={fonts} />}
1320
+ </div>
1321
+ );
1322
+ }
1323
+
1324
+ function SemanticPosition({
1325
+ adapter,
1326
+ id,
1327
+ props,
1328
+ fonts,
1329
+ }: {
1330
+ adapter: AuthoringAdapter;
1331
+ id: string;
1332
+ props: readonly PropertyDescriptor[];
1333
+ fonts: readonly string[];
1334
+ }): React.ReactElement {
1335
+ const store = useEditorStore();
1336
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1337
+ const position = byName.get('position');
1338
+ const positionValue = String(
1339
+ position ? (adapter.inspector?.get(id, position.path) ?? 'static') : 'static',
1340
+ );
1341
+ const commit = (path: string, value: unknown): void => {
1342
+ adapter.inspector?.set(id, path, value);
1343
+ store.notifyIngestEdit();
1344
+ };
1345
+ const offsets = ['top', 'right', 'bottom', 'left'];
1346
+ const itemProps = ['zIndex', 'flexGrow', 'flexShrink', 'flexBasis', 'alignSelf']
1347
+ .map((name) => byName.get(name))
1348
+ .filter((prop): prop is PropertyDescriptor => !!prop);
1349
+ return (
1350
+ <div style={{ padding: '2px 8px 7px' }}>
1351
+ {position && (
1352
+ <div
1353
+ data-testid={testIdFor(position.path)}
1354
+ style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: spaceVar[1] }}
1355
+ >
1356
+ {(position.options ?? []).map((option) => {
1357
+ const value = String(option);
1358
+ const active = value === positionValue;
1359
+ return (
1360
+ <Button
1361
+ key={value}
1362
+ type="button"
1363
+ variant={active ? 'primary' : 'secondary'}
1364
+ size="compact"
1365
+ aria-pressed={active}
1366
+ title={value}
1367
+ disabled={position.readonly}
1368
+ onClick={() => commit(position.path, value)}
1369
+ >
1370
+ {value.slice(0, 3)}
1371
+ </Button>
1372
+ );
1373
+ })}
1374
+ </div>
1375
+ )}
1376
+ {positionValue !== 'static' && (
1377
+ <div
1378
+ data-testid="react-position-offsets"
1379
+ style={{
1380
+ display: 'grid',
1381
+ gridTemplateColumns: '1fr 1fr',
1382
+ gap: spaceVar[2],
1383
+ marginTop: spaceVar[3],
1384
+ }}
1385
+ >
1386
+ {offsets.map((name) =>
1387
+ compactScrub(adapter, id, byName.get(name), name[0]!.toUpperCase(), commit),
1388
+ )}
1389
+ </div>
1390
+ )}
1391
+ {itemProps.length > 0 && (
1392
+ <div style={{ borderTop: `1px solid ${THEME.border}`, marginTop: 7, paddingTop: 3 }}>
1393
+ <div style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs, padding: '2px 0 1px' }}>
1394
+ STACKING &amp; FLEX ITEM
1395
+ </div>
1396
+ {itemProps.map((prop) => (
1397
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1398
+ ))}
1399
+ </div>
1400
+ )}
1401
+ </div>
1402
+ );
1403
+ }
1404
+
1405
+ function SemanticSpacing({
1406
+ adapter,
1407
+ id,
1408
+ props,
1409
+ }: {
1410
+ adapter: AuthoringAdapter;
1411
+ id: string;
1412
+ props: readonly PropertyDescriptor[];
1413
+ }): React.ReactElement {
1414
+ const store = useEditorStore();
1415
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1416
+ const commit = (path: string, value: unknown): void => {
1417
+ adapter.inspector?.set(id, path, value);
1418
+ store.notifyIngestEdit();
1419
+ };
1420
+ // Nested rings identify margin and padding sides by their position.
1421
+ const slot = (name: string): React.ReactElement => {
1422
+ const prop = byName.get(name);
1423
+ if (!prop) return <span key={name} />;
1424
+ return (
1425
+ <ScrubbableInput
1426
+ key={prop.path}
1427
+ testId={testIdFor(prop.path)}
1428
+ label=""
1429
+ value={numericValue(adapter.inspector?.get(id, prop.path))}
1430
+ unit="px"
1431
+ disabled={prop.readonly}
1432
+ onChange={(value) => commit(prop.path, value)}
1433
+ onChangeEnd={(value) => commit(prop.path, value)}
1434
+ style={{ width: 46, minWidth: 0, flex: 'none' }}
1435
+ />
1436
+ );
1437
+ };
1438
+ const ringLabel = (text: string): React.ReactElement => (
1439
+ <span
1440
+ style={{
1441
+ position: 'absolute',
1442
+ top: 2,
1443
+ left: 6,
1444
+ fontSize: 8,
1445
+ letterSpacing: '.08em',
1446
+ color: THEME.textMuted,
1447
+ pointerEvents: 'none',
1448
+ }}
1449
+ >
1450
+ {text}
1451
+ </span>
1452
+ );
1453
+ const centered: React.CSSProperties = {
1454
+ display: 'flex',
1455
+ alignItems: 'center',
1456
+ justifyContent: 'center',
1457
+ };
1458
+ return (
1459
+ <div
1460
+ style={{ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[4]}` }}
1461
+ data-testid="react-spacing-box"
1462
+ >
1463
+ <div
1464
+ style={{
1465
+ position: 'relative',
1466
+ border: `1px solid ${THEME.border}`,
1467
+ borderRadius: 4,
1468
+ padding: '11px 4px 3px',
1469
+ }}
1470
+ >
1471
+ {ringLabel('MARGIN')}
1472
+ <div style={centered}>{slot('marginTop')}</div>
1473
+ <div style={{ display: 'flex', alignItems: 'center', gap: spaceVar[1] }}>
1474
+ {slot('marginLeft')}
1475
+ <div
1476
+ style={{
1477
+ position: 'relative',
1478
+ flex: 1,
1479
+ minWidth: 0,
1480
+ border: `1px dashed ${THEME.border}`,
1481
+ borderRadius: 4,
1482
+ padding: '11px 4px 3px',
1483
+ }}
1484
+ >
1485
+ {ringLabel('PADDING')}
1486
+ <div style={centered}>{slot('paddingTop')}</div>
1487
+ <div
1488
+ style={{
1489
+ display: 'flex',
1490
+ alignItems: 'center',
1491
+ justifyContent: 'space-between',
1492
+ gap: spaceVar[1],
1493
+ }}
1494
+ >
1495
+ {slot('paddingLeft')}
1496
+ {slot('paddingRight')}
1497
+ </div>
1498
+ <div style={centered}>{slot('paddingBottom')}</div>
1499
+ </div>
1500
+ {slot('marginRight')}
1501
+ </div>
1502
+ <div style={centered}>{slot('marginBottom')}</div>
1503
+ </div>
1504
+ </div>
1505
+ );
1506
+ }
1507
+
1508
+ /**
1509
+ * ONE group's body — the widget-mapped rows for one descriptor group, with no
1510
+ * chrome of its own: the group IS an identified `InspectionSection` now
1511
+ * (see `reactInspectorSections`), so its title, icon, order and collapse
1512
+ * affordance belong to the projection rendering it, and repeating them here
1513
+ * would title the same block twice.
1514
+ */
1515
+ function GroupBody({
1516
+ name,
1517
+ groupId,
1518
+ props,
1519
+ adapter,
1520
+ id,
1521
+ fonts,
1522
+ }: {
1523
+ name: string;
1524
+ groupId: string;
1525
+ props: readonly PropertyDescriptor[];
1526
+ adapter: AuthoringAdapter;
1527
+ id: string;
1528
+ fonts: readonly string[];
1529
+ }): React.ReactElement {
1530
+ const [showConstraints, setShowConstraints] = useState(false);
1531
+ const rows = buildPropertyRows(props);
1532
+ return (
1533
+ <div data-testid={`react-inspector-group-${groupId}`}>
1534
+ {name === 'Layout' && (
1535
+ <SemanticLayout
1536
+ adapter={adapter}
1537
+ id={id}
1538
+ props={props}
1539
+ fonts={fonts}
1540
+ showConstraints={showConstraints}
1541
+ onToggleConstraints={() => setShowConstraints((value) => !value)}
1542
+ />
1543
+ )}
1544
+ {name === 'Position' && (
1545
+ <SemanticPosition adapter={adapter} id={id} props={props} fonts={fonts} />
1546
+ )}
1547
+ {name === 'Spacing' && <SemanticSpacing adapter={adapter} id={id} props={props} />}
1548
+ {name === 'Fill' && <SemanticFill adapter={adapter} id={id} props={props} fonts={fonts} />}
1549
+ {name !== 'Layout' &&
1550
+ name !== 'Position' &&
1551
+ name !== 'Spacing' &&
1552
+ name !== 'Fill' &&
1553
+ rows.map((row) =>
1554
+ row.kind === 'single' ? (
1555
+ <SingleRow key={rowKey(row)} adapter={adapter} id={id} prop={row.prop} fonts={fonts} />
1556
+ ) : (
1557
+ <ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
1558
+ ),
1559
+ )}
1560
+ </div>
1561
+ );
1562
+ }
1563
+
1564
+ function BreadcrumbBar({
1565
+ adapter,
1566
+ node,
1567
+ }: {
1568
+ adapter: AuthoringAdapter;
1569
+ node: EditorNode;
1570
+ }): React.ReactElement {
1571
+ const chain: EditorNode[] = [];
1572
+ let current: EditorNode | null = node;
1573
+ let guard = 0;
1574
+ while (current && guard++ < 100) {
1575
+ chain.unshift(current);
1576
+ current = current.parentId ? adapter.hierarchy.node(current.parentId) : null;
1577
+ }
1578
+ return (
1579
+ <nav
1580
+ aria-label="Selection ancestry"
1581
+ data-testid="react-inspector-breadcrumbs"
1582
+ style={{
1583
+ display: 'flex',
1584
+ alignItems: 'center',
1585
+ gap: 3,
1586
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
1587
+ overflowX: 'auto',
1588
+ borderBottom: `1px solid ${THEME.border}`,
1589
+ whiteSpace: 'nowrap',
1590
+ }}
1591
+ >
1592
+ {chain.map((entry, index) => (
1593
+ <span key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
1594
+ {index > 0 && <span style={{ color: THEME.textMuted }}>/</span>}
1595
+ <Button
1596
+ type="button"
1597
+ variant="ghost"
1598
+ size="compact"
1599
+ aria-current={entry.id === node.id ? 'page' : undefined}
1600
+ title={entry.secondaryLabel ?? entry.label}
1601
+ onClick={() => setAuthoringSelection(adapter, [entry.id])}
1602
+ >
1603
+ {entry.label}
1604
+ </Button>
1605
+ </span>
1606
+ ))}
1607
+ </nav>
1608
+ );
1609
+ }
1610
+
1611
+ function StoryActions({
1612
+ owner,
1613
+ node,
1614
+ }: {
1615
+ owner: ReactRootAuthoringAdapter;
1616
+ node: EditorNode;
1617
+ }): React.ReactElement {
1618
+ const args = owner.portableArgs(node.id);
1619
+ // The CSF WRITE half: save-the-current-args as a new story, rename, delete
1620
+ // — inline inputs, never window.prompt (banned), refusals rendered where
1621
+ // the gesture happened.
1622
+ const [mode, setMode] = useState<'idle' | 'save-as' | 'rename' | 'confirm-delete'>('idle');
1623
+ const [draftName, setDraftName] = useState('');
1624
+ const [notice, setNotice] = useState<string | null>(null);
1625
+ const run = async (op: Parameters<ReactRootAuthoringAdapter['writeStory']>[1]): Promise<void> => {
1626
+ const result = await owner.writeStory(node.id, op);
1627
+ setNotice(result.changed ? null : (result.error ?? 'refused'));
1628
+ if (result.changed) {
1629
+ setMode('idle');
1630
+ setDraftName('');
1631
+ }
1632
+ };
1633
+ return (
1634
+ <div
1635
+ data-testid="react-story-controls-header"
1636
+ style={{ borderBottom: `1px solid ${THEME.border}` }}
1637
+ >
1638
+ <div
1639
+ style={{
1640
+ display: 'flex',
1641
+ alignItems: 'center',
1642
+ justifyContent: 'space-between',
1643
+ gap: spaceVar[4],
1644
+ padding: '7px 8px',
1645
+ }}
1646
+ >
1647
+ <div style={{ minWidth: 0 }}>
1648
+ <div style={{ color: THEME.text, fontSize: fontSizeVar.md, fontWeight: 600 }}>
1649
+ {node.label}
1650
+ </div>
1651
+ <div style={{ color: THEME.textMuted, fontSize: fontSizeVar.sm }}>
1652
+ {args ? `${Object.keys(args).length} args · session controls` : 'Portable CSF story'}
1653
+ </div>
1654
+ </div>
1655
+ <Button
1656
+ type="button"
1657
+ variant="secondary"
1658
+ size="compact"
1659
+ data-testid="react-story-reset-args"
1660
+ onClick={() => owner.resetPortableArgs(node.id)}
1661
+ >
1662
+ Reset
1663
+ </Button>
1664
+ </div>
1665
+ <div
1666
+ style={{
1667
+ display: 'flex',
1668
+ gap: spaceVar[2],
1669
+ padding: `0 ${spaceVar[4]} ${spaceVar[3]}`,
1670
+ flexWrap: 'wrap',
1671
+ }}
1672
+ >
1673
+ <Button
1674
+ type="button"
1675
+ variant="ghost"
1676
+ size="compact"
1677
+ data-testid="react-story-save-as"
1678
+ title="Write the CURRENT session args as a new story export in this file"
1679
+ onClick={() => {
1680
+ setNotice(null);
1681
+ setDraftName('');
1682
+ setMode(mode === 'save-as' ? 'idle' : 'save-as');
1683
+ }}
1684
+ >
1685
+ Save as…
1686
+ </Button>
1687
+ <Button
1688
+ type="button"
1689
+ variant="ghost"
1690
+ size="compact"
1691
+ data-testid="react-story-rename"
1692
+ onClick={() => {
1693
+ setNotice(null);
1694
+ setDraftName('');
1695
+ setMode(mode === 'rename' ? 'idle' : 'rename');
1696
+ }}
1697
+ >
1698
+ Rename…
1699
+ </Button>
1700
+ {mode === 'confirm-delete' ? (
1701
+ <Button
1702
+ type="button"
1703
+ variant="secondary"
1704
+ size="compact"
1705
+ data-testid="react-story-delete-confirm"
1706
+ onClick={() => void run({ kind: 'delete' })}
1707
+ >
1708
+ Delete story?
1709
+ </Button>
1710
+ ) : (
1711
+ <Button
1712
+ type="button"
1713
+ variant="ghost"
1714
+ size="compact"
1715
+ data-testid="react-story-delete"
1716
+ onClick={() => {
1717
+ setNotice(null);
1718
+ setMode('confirm-delete');
1719
+ }}
1720
+ >
1721
+ Delete
1722
+ </Button>
1723
+ )}
1724
+ </div>
1725
+ {(mode === 'save-as' || mode === 'rename') && (
1726
+ <div
1727
+ style={{
1728
+ display: 'flex',
1729
+ gap: spaceVar[2],
1730
+ padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
1731
+ alignItems: 'center',
1732
+ }}
1733
+ >
1734
+ <input
1735
+ data-testid="react-story-name-input"
1736
+ // Focus the name field when Save as or Rename opens it.
1737
+ // biome-ignore lint/a11y/noAutofocus: the input exists only because the user just asked for it.
1738
+ autoFocus
1739
+ value={draftName}
1740
+ placeholder={mode === 'save-as' ? 'NewStoryName' : `Rename ${node.label}`}
1741
+ onChange={(event) => setDraftName(event.target.value)}
1742
+ onKeyDown={(event) => {
1743
+ if (event.key === 'Enter' && draftName.trim()) {
1744
+ void run(
1745
+ mode === 'save-as'
1746
+ ? { kind: 'save-as', name: draftName.trim() }
1747
+ : { kind: 'rename', newName: draftName.trim() },
1748
+ );
1749
+ }
1750
+ if (event.key === 'Escape') setMode('idle');
1751
+ }}
1752
+ style={{
1753
+ flex: 1,
1754
+ minWidth: 0,
1755
+ background: THEME.neutralHover,
1756
+ border: `1px solid ${THEME.border}`,
1757
+ borderRadius: 4,
1758
+ color: THEME.text,
1759
+ fontSize: fontSizeVar.base,
1760
+ padding: '3px 6px',
1761
+ }}
1762
+ />
1763
+ <Button
1764
+ type="button"
1765
+ variant="secondary"
1766
+ size="compact"
1767
+ data-testid="react-story-name-commit"
1768
+ onClick={() =>
1769
+ void run(
1770
+ mode === 'save-as'
1771
+ ? { kind: 'save-as', name: draftName.trim() }
1772
+ : { kind: 'rename', newName: draftName.trim() },
1773
+ )
1774
+ }
1775
+ >
1776
+ {mode === 'save-as' ? 'Save' : 'Rename'}
1777
+ </Button>
1778
+ </div>
1779
+ )}
1780
+ {notice && (
1781
+ <div
1782
+ data-testid="react-story-write-notice"
1783
+ style={{
1784
+ padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
1785
+ color: themeDanger(),
1786
+ fontSize: fontSizeVar.sm,
1787
+ lineHeight: lineHeightVar.normal,
1788
+ }}
1789
+ >
1790
+ {notice}
1791
+ </div>
1792
+ )}
1793
+ </div>
1794
+ );
1795
+ }
1796
+
1797
+ /** Class tokens, their scope-local use counts, and apply/create/remove
1798
+ * controls. A class backed by a first-party single-class rule is editable. */
1799
+ function ClassesBlock({
1800
+ owner,
1801
+ nodeId,
1802
+ }: {
1803
+ owner: ReactRootAuthoringAdapter;
1804
+ nodeId: string;
1805
+ }): React.ReactElement {
1806
+ const classes = owner.elementClasses(nodeId);
1807
+ const [draft, setDraft] = useState('');
1808
+ const [notice, setNotice] = useState<string | null>(null);
1809
+ const commit = async (): Promise<void> => {
1810
+ const className = draft.trim().replace(/^\./, '');
1811
+ if (!className) return;
1812
+ const result = await owner.writeNamedStyle(nodeId, { kind: 'set', className });
1813
+ setNotice(result.changed ? null : (result.error ?? 'refused'));
1814
+ if (result.changed) setDraft('');
1815
+ };
1816
+ return (
1817
+ <div
1818
+ data-testid="react-element-classes"
1819
+ style={{
1820
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
1821
+ borderBottom: `1px solid ${THEME.border}`,
1822
+ }}
1823
+ >
1824
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: spaceVar[2], alignItems: 'center' }}>
1825
+ <span
1826
+ style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted, marginRight: spaceVar[1] }}
1827
+ >
1828
+ Style class
1829
+ </span>
1830
+ {classes.map((cls) => (
1831
+ <span
1832
+ key={cls.name}
1833
+ data-testid={`react-class-chip-${cls.name}`}
1834
+ title={
1835
+ cls.styledIn
1836
+ ? `.${cls.name} — ${cls.count} element${cls.count === 1 ? '' : 's'} in this game ` +
1837
+ `share this style (${cls.styledIn.split('/').pop() ?? cls.styledIn})`
1838
+ : `.${cls.name} — utility/generated class (no first-party rule to edit)`
1839
+ }
1840
+ style={{
1841
+ display: 'inline-flex',
1842
+ alignItems: 'center',
1843
+ gap: 3,
1844
+ fontFamily: FONT_MONO,
1845
+ fontSize: fontSizeVar.sm,
1846
+ color: cls.styledIn ? THEME.text : THEME.textMuted,
1847
+ background: THEME.neutralHover,
1848
+ border: `1px solid ${THEME.border}`,
1849
+ borderRadius: 4,
1850
+ padding: '1px 4px',
1851
+ }}
1852
+ >
1853
+ .{cls.name}
1854
+ {cls.count > 1 && (
1855
+ <span style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs }}>×{cls.count}</span>
1856
+ )}
1857
+ <button
1858
+ type="button"
1859
+ data-testid={`react-class-remove-${cls.name}`}
1860
+ title={`Remove .${cls.name} from this element`}
1861
+ onClick={() =>
1862
+ void owner
1863
+ .writeNamedStyle(nodeId, { kind: 'remove', className: cls.name })
1864
+ .then((result) => setNotice(result.changed ? null : (result.error ?? 'refused')))
1865
+ }
1866
+ style={{
1867
+ background: 'none',
1868
+ border: 'none',
1869
+ color: THEME.textMuted,
1870
+ cursor: 'pointer',
1871
+ fontSize: fontSizeVar.base,
1872
+ padding: '3px 5px',
1873
+ margin: '-3px -4px -3px -1px',
1874
+ lineHeight: lineHeightVar.tight,
1875
+ }}
1876
+ >
1877
+ ✕
1878
+ </button>
1879
+ </span>
1880
+ ))}
1881
+ <input
1882
+ data-testid="react-class-input"
1883
+ value={draft}
1884
+ placeholder={classes.length === 0 ? 'create style…' : 'add class…'}
1885
+ title="Type a class name — an existing first-party class is applied; a new name creates a style from this element's inline declarations"
1886
+ onChange={(event) => {
1887
+ setDraft(event.target.value);
1888
+ if (notice) setNotice(null);
1889
+ }}
1890
+ onKeyDown={(event) => {
1891
+ if (event.key === 'Enter') void commit();
1892
+ if (event.key === 'Escape') setDraft('');
1893
+ }}
1894
+ style={{
1895
+ flex: 1,
1896
+ minWidth: 72,
1897
+ background: 'transparent',
1898
+ border: `1px dashed ${THEME.border}`,
1899
+ borderRadius: 4,
1900
+ color: THEME.text,
1901
+ fontSize: fontSizeVar.sm,
1902
+ fontFamily: FONT_MONO,
1903
+ padding: '2px 5px',
1904
+ }}
1905
+ />
1906
+ </div>
1907
+ {notice && (
1908
+ <div
1909
+ data-testid="react-class-notice"
1910
+ style={{
1911
+ paddingTop: spaceVar[2],
1912
+ color: themeDanger(),
1913
+ fontSize: fontSizeVar.sm,
1914
+ lineHeight: lineHeightVar.normal,
1915
+ }}
1916
+ >
1917
+ {notice}
1918
+ </div>
1919
+ )}
1920
+ <BreakpointRow />
1921
+ </div>
1922
+ );
1923
+ }
1924
+
1925
+ /** Keep the active breakpoint in session state across adapter recreation. */
1926
+ function BreakpointRow(): React.ReactElement {
1927
+ const bp = useSyncExternalStore(subscribeBreakpoint, activeBreakpoint);
1928
+ return (
1929
+ <>
1930
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingTop: 5 }}>
1931
+ <span style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted }}>Breakpoint</span>
1932
+ <Select
1933
+ data-testid="react-breakpoint-select"
1934
+ value={bp ?? ''}
1935
+ onChange={(event) => setActiveBreakpoint(event.target.value || null)}
1936
+ style={{
1937
+ fontSize: fontSizeVar.sm,
1938
+ flex: 1,
1939
+ ...(bp ? { color: THEME.accent, borderColor: THEME.accent } : {}),
1940
+ }}
1941
+ >
1942
+ {BREAKPOINT_OPTIONS.map((option) => (
1943
+ <option key={option.label} value={option.media ?? ''}>
1944
+ {option.label}
1945
+ </option>
1946
+ ))}
1947
+ </Select>
1948
+ </div>
1949
+ {bp && (
1950
+ <div
1951
+ data-testid="react-breakpoint-note"
1952
+ style={{
1953
+ paddingTop: 3,
1954
+ color: THEME.textMuted,
1955
+ fontSize: fontSizeVar.xs,
1956
+ lineHeight: lineHeightVar.normal,
1957
+ }}
1958
+ >
1959
+ Style edits write @media {bp} — previewed live by frame width.
1960
+ </div>
1961
+ )}
1962
+ </>
1963
+ );
1964
+ }
1965
+
1966
+ /** Webflow's cascade tiers as `@media` conditions. Base is the unscoped rule;
1967
+ * each tier is max-width, so narrower inherits from wider — the same
1968
+ * downward cascade Webflow teaches. A project's own additional conditions
1969
+ * are honored wherever they already exist in its css; this list is only
1970
+ * what the SELECT offers. */
1971
+ const BREAKPOINT_OPTIONS: ReadonlyArray<{ label: string; media: string | null }> = [
1972
+ { label: 'Base', media: null },
1973
+ { label: 'Tablet ≤991', media: '(max-width: 991px)' },
1974
+ { label: 'Phone L ≤767', media: '(max-width: 767px)' },
1975
+ { label: 'Phone ≤479', media: '(max-width: 479px)' },
1976
+ ];
1977
+
1978
+ /** The danger tone for a story-write refusal. */
1979
+ function themeDanger(): string {
1980
+ return '#e06c75';
1981
+ }
1982
+
1983
+ /** Copy a source file/line locator from a mapped DOM element. */
1984
+ function SourceLocationRow({ entry }: { entry: OidEntry }): React.ReactElement {
1985
+ const [copied, setCopied] = useState(false);
1986
+ const filePath = `${entry.file}:${entry.line}`;
1987
+ const short = `${entry.file.split('/').pop() ?? entry.file}:${entry.line}`;
1988
+ const copy = async (): Promise<void> => {
1989
+ try {
1990
+ await navigator.clipboard.writeText(filePath);
1991
+ setCopied(true);
1992
+ setTimeout(() => setCopied(false), 1500);
1993
+ } catch {
1994
+ // Clipboard API unavailable — the row still shows the path as text.
1995
+ }
1996
+ };
1997
+ return (
1998
+ <button
1999
+ type="button"
2000
+ data-testid="react-source-location"
2001
+ title={`${filePath} — click to copy the path`}
2002
+ onClick={() => void copy()}
2003
+ style={{
2004
+ fontFamily: FONT_MONO,
2005
+ fontSize: 'inherit',
2006
+ color: THEME.textMuted,
2007
+ cursor: 'pointer',
2008
+ overflow: 'hidden',
2009
+ textOverflow: 'ellipsis',
2010
+ whiteSpace: 'nowrap',
2011
+ minWidth: 0,
2012
+ background: 'none',
2013
+ border: 'none',
2014
+ padding: 0,
2015
+ textAlign: 'left',
2016
+ }}
2017
+ >
2018
+ {copied ? 'Copied' : short}
2019
+ </button>
2020
+ );
2021
+ }
2022
+
2023
+ function LaneDisclosure({
2024
+ owner,
2025
+ node,
2026
+ nodeId,
2027
+ }: {
2028
+ owner: ReactRootAuthoringAdapter;
2029
+ node: EditorNode;
2030
+ nodeId: string;
2031
+ }): React.ReactElement {
2032
+ // The source adapter maps the node OID to its indexed file and line.
2033
+ const sourceEntry = owner.sourceLocation(nodeId);
2034
+ // Keep provenance in a compact row; its tooltip explains source-write scope.
2035
+ const short =
2036
+ node.role === 'story'
2037
+ ? `Story · ${node.label}`
2038
+ : node.role === 'document'
2039
+ ? `CSF · ${node.secondaryLabel ?? node.label}`
2040
+ : 'Source';
2041
+ const explain =
2042
+ node.role === 'story'
2043
+ ? `${node.label} is the active portable CSF design state.`
2044
+ : node.role === 'document'
2045
+ ? `${node.secondaryLabel ?? node.label} owns this root's design states.`
2046
+ : `Edits change ${node.label}'s source — every rendered ` +
2047
+ 'instance updates.';
2048
+ return (
2049
+ <div
2050
+ data-testid="react-lane-b-disclosure"
2051
+ title={explain}
2052
+ style={{
2053
+ display: 'flex',
2054
+ alignItems: 'center',
2055
+ gap: spaceVar[3],
2056
+ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[3]}`,
2057
+ borderBottom: `1px solid ${THEME.border}`,
2058
+ color: THEME.textMuted,
2059
+ fontSize: fontSizeVar.sm,
2060
+ lineHeight: lineHeightVar.normal,
2061
+ minWidth: 0,
2062
+ }}
2063
+ >
2064
+ <span style={{ flexShrink: 0 }}>{short}</span>
2065
+ {sourceEntry && <SourceLocationRow entry={sourceEntry} />}
2066
+ </div>
2067
+ );
2068
+ }
2069
+
2070
+ /** The `element` facet's body: where it sits (ancestry), which lane owns it,
2071
+ * and where its source is. The subject's own name and type live on the
2072
+ * shell's identity row, not here. */
2073
+ function ElementSection({
2074
+ adapter,
2075
+ node,
2076
+ nodeId,
2077
+ owner,
2078
+ }: {
2079
+ adapter: AuthoringAdapter;
2080
+ node: EditorNode;
2081
+ nodeId: string;
2082
+ owner: ReactRootAuthoringAdapter;
2083
+ }): React.ReactElement {
2084
+ return (
2085
+ <div data-testid="react-inspector-section">
2086
+ <BreadcrumbBar adapter={adapter} node={node} />
2087
+ <ClassesBlock owner={owner} nodeId={nodeId} />
2088
+ <LaneDisclosure owner={owner} node={node} nodeId={nodeId} />
2089
+ </div>
2090
+ );
2091
+ }
2092
+
2093
+ /** The Style/Layout/Position/Spacing/component-prop group bodies re-render on
2094
+ * the shell's own subscription: every `SingleRow`/`ComboRow` calls
2095
+ * `store.notifyIngestEdit()` on commit (its own `useEditorStore()`), which
2096
+ * re-renders the inspector tree including these sections. */
2097
+ function GroupSectionBody({
2098
+ adapter,
2099
+ id,
2100
+ name,
2101
+ groupId,
2102
+ props,
2103
+ }: {
2104
+ adapter: AuthoringAdapter;
2105
+ id: string;
2106
+ name: string;
2107
+ groupId: string;
2108
+ props: readonly PropertyDescriptor[];
2109
+ }): React.ReactElement {
2110
+ const fonts = useMemo(() => getAvailableFonts(), []);
2111
+ return (
2112
+ <GroupBody
2113
+ name={name}
2114
+ groupId={groupId}
2115
+ props={props}
2116
+ adapter={adapter}
2117
+ id={id}
2118
+ fonts={fonts}
2119
+ />
2120
+ );
2121
+ }
2122
+
2123
+ /** Produce one identified inspection section per node property group.
2124
+ * Shared section IDs replace the generic property presentation. */
2125
+ function reactInspectorSections(
2126
+ node: EditorNode | null,
2127
+ adapter: AuthoringAdapter,
2128
+ ): readonly InspectionSection[] {
2129
+ const owner = resolveReactOwner(node, adapter);
2130
+ if (!owner || !node) return [];
2131
+ const nodeId = node.id;
2132
+ const props = (adapter.inspector?.properties(nodeId) ?? []).filter((p) => p.path !== 'name');
2133
+ const { ungrouped, groups } = groupProperties(props);
2134
+ const sections: InspectionSection[] = [
2135
+ {
2136
+ id: REACT_ELEMENT_SECTION_ID,
2137
+ // The facet's name, not the object's: what KIND of element this is
2138
+ // surfaces on the subject's identity row. This adapter sets no
2139
+ // `typeLabel`, so the row's kind label falls back to `node.kind` (the tag
2140
+ // name) — the `typeLabel ?? kind` chain in `compose.ts`'s `kindLabel`.
2141
+ title: 'Element',
2142
+ icon: faCode,
2143
+ order: REACT_ELEMENT_SECTION_ORDER,
2144
+ body: {
2145
+ kind: 'custom',
2146
+ render: () => createElement(ElementSection, { adapter, node, nodeId, owner }),
2147
+ // Publish class chips and the writable breakpoint for editor.inspect()
2148
+ // and editor.setField(). Class add/remove uses writeNamedStyle.
2149
+ data: {
2150
+ classes: owner.elementClasses(nodeId),
2151
+ breakpoint: activeBreakpoint(),
2152
+ },
2153
+ editable: {
2154
+ fields: [
2155
+ {
2156
+ path: 'element.breakpoint',
2157
+ label: 'Breakpoint',
2158
+ type: 'enum' as const,
2159
+ options: BREAKPOINT_OPTIONS.map((option) => option.media ?? ''),
2160
+ },
2161
+ ],
2162
+ io: {
2163
+ get: (path: string) =>
2164
+ path === 'element.breakpoint' ? (activeBreakpoint() ?? '') : undefined,
2165
+ set: (path: string, value: unknown) => {
2166
+ if (path === 'element.breakpoint') {
2167
+ setActiveBreakpoint(typeof value === 'string' && value ? value : null);
2168
+ }
2169
+ },
2170
+ },
2171
+ },
2172
+ },
2173
+ },
2174
+ ];
2175
+ if (node.role === 'story') {
2176
+ sections.push({
2177
+ id: REACT_STORY_SECTION_ID,
2178
+ title: 'Story',
2179
+ // The single selected story's own controls — a DIFFERENT facet from the
2180
+ // built-in `stories` collection/switcher (`faBookOpen`), and both can
2181
+ // compose on a story node. The compact card tells tabs apart by glyph
2182
+ // alone, so this one wears a distinct single-story glyph.
2183
+ icon: faBookBookmark,
2184
+ order: REACT_STORY_SECTION_ORDER,
2185
+ body: { kind: 'custom', render: () => createElement(StoryActions, { owner, node }) },
2186
+ });
2187
+ }
2188
+ if (ungrouped.length > 0) {
2189
+ sections.push({
2190
+ id: PROPERTIES_SECTION_ID,
2191
+ title: 'Style',
2192
+ icon: faPalette,
2193
+ order: PROPERTIES_SECTION_ORDER,
2194
+ defaultOpen: true,
2195
+ body: {
2196
+ kind: 'custom',
2197
+ render: () =>
2198
+ createElement(GroupSectionBody, {
2199
+ adapter,
2200
+ id: nodeId,
2201
+ name: 'Style',
2202
+ groupId: 'style',
2203
+ props: ungrouped,
2204
+ }),
2205
+ },
2206
+ });
2207
+ }
2208
+ groups.forEach((group, index) => {
2209
+ sections.push({
2210
+ id: groupSectionId(group.groupId),
2211
+ title: group.name,
2212
+ icon: groupIcon(group.name),
2213
+ order: GROUP_SECTION_ORDER + index,
2214
+ defaultOpen: true,
2215
+ body: {
2216
+ kind: 'custom',
2217
+ render: () =>
2218
+ createElement(GroupSectionBody, {
2219
+ adapter,
2220
+ id: nodeId,
2221
+ name: group.name,
2222
+ groupId: group.groupId,
2223
+ props: group.properties,
2224
+ }),
2225
+ },
2226
+ });
2227
+ });
2228
+ return sections;
2229
+ }
2230
+
2231
+ const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'react-inspector-section');
2232
+
2233
+ /** Register this surface's Inspector sections through its HMR registration group.
2234
+ * The editor-ui service calls this during activation; importing the module alone
2235
+ * leaves the registry unchanged. */
2236
+ export function ensureReactInspectorSectionRegistered(): void {
2237
+ registrationGroup.ensure((track) => {
2238
+ track(
2239
+ registerInspectorSections({
2240
+ match: isDomSelection,
2241
+ sections: reactInspectorSections,
2242
+ // These blocks own the node's properties; omit the generic descriptor grid.
2243
+ claims: [PROPERTIES_SECTION_ID],
2244
+ }),
2245
+ );
2246
+ });
2247
+ }