@volter/editor-sdk 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Pure curve math for `CurveEditor` (W1b) — piecewise cubic-bezier
3
+ * value-over-normalized-time curves, structurally identical to the engine's
4
+ * `PiecewiseBezier` schema shape (`schema/particles.ts`) and to three.quarks'
5
+ * own `PiecewiseBezier` JSON: segment `i` spans `[start_i, start_{i+1}]`
6
+ * (the last segment ends at 1), and `p0..p3` are VALUE control points over
7
+ * the segment's normalized time — i.e. the plotted curve is an exact SVG
8
+ * cubic with x-controls at 1/3 and 2/3 of the segment width.
9
+ *
10
+ * Kept editor-generic (no particle imports): any future consumer uses the
11
+ * same helpers.
12
+ */
13
+
14
+ export interface CurveBezier {
15
+ p0: number;
16
+ p1: number;
17
+ p2: number;
18
+ p3: number;
19
+ }
20
+
21
+ export interface CurveSegment {
22
+ function: CurveBezier;
23
+ start: number;
24
+ }
25
+
26
+ export interface CurveKey {
27
+ time: number;
28
+ value: number;
29
+ }
30
+
31
+ /** End time of segment `i` — the next segment's start, or 1 for the last. */
32
+ export function segmentEnd(segments: CurveSegment[], i: number): number {
33
+ return i + 1 < segments.length ? segments[i + 1]!.start : 1;
34
+ }
35
+
36
+ /**
37
+ * The curve's keys (n segments → n+1 keys): key `i` sits at segment `i`'s
38
+ * start with value `p0`; the final key sits at t=1 with the last `p3`.
39
+ */
40
+ export function curveKeys(segments: CurveSegment[]): CurveKey[] {
41
+ const keys: CurveKey[] = segments.map((s) => ({ time: s.start, value: s.function.p0 }));
42
+ const last = segments[segments.length - 1];
43
+ if (last) keys.push({ time: 1, value: last.function.p3 });
44
+ return keys;
45
+ }
46
+
47
+ function cloneSegments(segments: CurveSegment[]): CurveSegment[] {
48
+ return segments.map((s) => ({ start: s.start, function: { ...s.function } }));
49
+ }
50
+
51
+ const MIN_SEGMENT_WIDTH = 0.01;
52
+
53
+ /**
54
+ * Move key `keyIndex` to (time, value). Endpoint keys are time-locked (first
55
+ * to its own start, last to 1); interior key times clamp between neighbors.
56
+ * Tangent control values (p1/p2) shift WITH their endpoint so the curve's
57
+ * local shape is preserved.
58
+ */
59
+ export function moveKey(
60
+ segments: CurveSegment[],
61
+ keyIndex: number,
62
+ time: number,
63
+ value: number,
64
+ ): CurveSegment[] {
65
+ const next = cloneSegments(segments);
66
+ const n = next.length;
67
+ if (keyIndex < 0 || keyIndex > n) return next;
68
+
69
+ if (keyIndex < n) {
70
+ // Key owns segment keyIndex's start/p0 (and the previous segment's p3).
71
+ const seg = next[keyIndex]!;
72
+ const dv = value - seg.function.p0;
73
+ seg.function.p0 = value;
74
+ seg.function.p1 += dv;
75
+ if (keyIndex > 0) {
76
+ const prev = next[keyIndex - 1]!;
77
+ const dvPrev = value - prev.function.p3;
78
+ prev.function.p3 = value;
79
+ prev.function.p2 += dvPrev;
80
+ // Interior keys may move in time, clamped between neighbors.
81
+ const lo = prev.start + MIN_SEGMENT_WIDTH;
82
+ const hi = segmentEnd(next, keyIndex) - MIN_SEGMENT_WIDTH;
83
+ seg.start = Math.min(hi, Math.max(lo, time));
84
+ }
85
+ } else {
86
+ // Final key: last segment's p3, time locked at 1.
87
+ const seg = next[n - 1]!;
88
+ const dv = value - seg.function.p3;
89
+ seg.function.p3 = value;
90
+ seg.function.p2 += dv;
91
+ }
92
+ return next;
93
+ }
94
+
95
+ /**
96
+ * Move a tangent control value of key `keyIndex`: `out` = its segment's p1
97
+ * (exists for every key but the last), `in` = the previous segment's p2
98
+ * (exists for every key but the first).
99
+ */
100
+ export function moveTangent(
101
+ segments: CurveSegment[],
102
+ keyIndex: number,
103
+ which: 'in' | 'out',
104
+ value: number,
105
+ ): CurveSegment[] {
106
+ const next = cloneSegments(segments);
107
+ if (which === 'out' && keyIndex < next.length) {
108
+ next[keyIndex]!.function.p1 = value;
109
+ } else if (which === 'in' && keyIndex > 0 && keyIndex <= next.length) {
110
+ next[keyIndex - 1]!.function.p2 = value;
111
+ }
112
+ return next;
113
+ }
114
+
115
+ function lerp(a: number, b: number, u: number): number {
116
+ return a + (b - a) * u;
117
+ }
118
+
119
+ /** Evaluate the curve at normalized time t (matches quarks' genValue). */
120
+ export function sampleCurve(segments: CurveSegment[], t: number): number {
121
+ for (let i = 0; i < segments.length; i++) {
122
+ const start = segments[i]!.start;
123
+ const end = segmentEnd(segments, i);
124
+ if (t >= start && t <= end) {
125
+ const u = end > start ? (t - start) / (end - start) : 0;
126
+ const { p0, p1, p2, p3 } = segments[i]!.function;
127
+ const b01 = lerp(p0, p1, u);
128
+ const b12 = lerp(p1, p2, u);
129
+ const b23 = lerp(p2, p3, u);
130
+ const b012 = lerp(b01, b12, u);
131
+ const b123 = lerp(b12, b23, u);
132
+ return lerp(b012, b123, u);
133
+ }
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ /**
139
+ * Insert a key at time `t` by de Casteljau subdivision of the containing
140
+ * segment — the curve's shape is EXACTLY preserved. Returns the input
141
+ * unchanged when `t` falls outside the curve or too close to an existing key.
142
+ */
143
+ export function splitAt(segments: CurveSegment[], t: number): CurveSegment[] {
144
+ for (let i = 0; i < segments.length; i++) {
145
+ const start = segments[i]!.start;
146
+ const end = segmentEnd(segments, i);
147
+ if (t <= start + MIN_SEGMENT_WIDTH || t >= end - MIN_SEGMENT_WIDTH) continue;
148
+ if (t < start || t > end) continue;
149
+ const u = (t - start) / (end - start);
150
+ const { p0, p1, p2, p3 } = segments[i]!.function;
151
+ const b01 = lerp(p0, p1, u);
152
+ const b12 = lerp(p1, p2, u);
153
+ const b23 = lerp(p2, p3, u);
154
+ const b012 = lerp(b01, b12, u);
155
+ const b123 = lerp(b12, b23, u);
156
+ const mid = lerp(b012, b123, u);
157
+ const next = cloneSegments(segments);
158
+ next.splice(
159
+ i,
160
+ 1,
161
+ { start, function: { p0, p1: b01, p2: b012, p3: mid } },
162
+ { start: t, function: { p0: mid, p1: b123, p2: b23, p3 } },
163
+ );
164
+ return next;
165
+ }
166
+ return segments;
167
+ }
168
+
169
+ /**
170
+ * Delete interior key `keyIndex`, merging its two adjacent segments. The
171
+ * merged bezier keeps both outer endpoints and rescales the outer tangent
172
+ * controls so endpoint SLOPES are preserved (the interior wiggle is
173
+ * re-fitted — same approximation Unity's curve editor makes). Endpoint keys
174
+ * are not deletable; returns the input unchanged for them.
175
+ */
176
+ export function deleteKey(segments: CurveSegment[], keyIndex: number): CurveSegment[] {
177
+ if (keyIndex <= 0 || keyIndex >= segments.length) return segments;
178
+ const left = segments[keyIndex - 1]!;
179
+ const right = segments[keyIndex]!;
180
+ const leftDur = right.start - left.start;
181
+ const rightDur = segmentEnd(segments, keyIndex) - right.start;
182
+ const total = leftDur + rightDur;
183
+ if (total <= 0) return segments;
184
+ const lf = left.function;
185
+ const rf = right.function;
186
+ const merged: CurveSegment = {
187
+ start: left.start,
188
+ function: {
189
+ p0: lf.p0,
190
+ p1: lf.p0 + (lf.p1 - lf.p0) * (leftDur > 0 ? total / leftDur : 1),
191
+ p2: rf.p3 + (rf.p2 - rf.p3) * (rightDur > 0 ? total / rightDur : 1),
192
+ p3: rf.p3,
193
+ },
194
+ };
195
+ const next = cloneSegments(segments);
196
+ next.splice(keyIndex - 1, 2, merged);
197
+ return next;
198
+ }
199
+
200
+ /** Min/max over every control value (bezier hull bounds the curve). */
201
+ export function curveValueRange(segments: CurveSegment[]): { min: number; max: number } {
202
+ let min = Number.POSITIVE_INFINITY;
203
+ let max = Number.NEGATIVE_INFINITY;
204
+ for (const s of segments) {
205
+ for (const v of [s.function.p0, s.function.p1, s.function.p2, s.function.p3]) {
206
+ if (v < min) min = v;
207
+ if (v > max) max = v;
208
+ }
209
+ }
210
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: 0, max: 1 };
211
+ return { min, max };
212
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The figma-grade inspector widget kit (spec 27 §5 C1). PURE,
3
+ * contract-driven components — `value` + `onChange` (+ optional
4
+ * `onChangeEnd` for the commit tick, see `shared.tsx`'s `ChangeHandlers<T>`
5
+ * doc comment). The kit stays contract-pure: widgets never import editor
6
+ * state, which is what lets the whole directory sit in the Apache face.
7
+ *
8
+ * This is an INTERNAL barrel — `../index.ts` is the published door
9
+ * (`@volter/editor-sdk/widgets`), and it re-exports this whole surface.
10
+ */
11
+
12
+ export * from './AlignmentGrid';
13
+ export * from './AssetSlotPicker';
14
+ export * from './BorderEditor';
15
+ export * from './ColorPicker';
16
+ export * from './CurveEditor';
17
+ export type { ColorFormat } from './color-utils';
18
+ export * from './curve-utils';
19
+ export * from './FilterEditor';
20
+ export * from './FontPicker';
21
+ export * from './GradientEditor';
22
+ export * from './ScrubbableInput';
23
+ export * from './ShadowEditor';
24
+ export * from './shared';
@@ -0,0 +1,140 @@
1
+ import { Button } from '../design-system';
2
+ /**
3
+ * Shared theme + tiny primitives for the inspector widget kit (spec 27 §5
4
+ * C1). Values are now sourced from `../../theme.ts` (editor-style-polish
5
+ * U0) — the editor's single canonical token module — rather than
6
+ * independently retyped here, which is how this kit's accent had drifted
7
+ * from the app's semantic accent (C-2).
8
+ * `THEME.accent` now follows whichever palette is composed with the active
9
+ * material. `FONT` also drops
10
+ * `'Inter'` (never loaded anywhere — 0 `@font-face` hits — so it only ever
11
+ * silently fell back to its own fallback chain, per finding I-29); `FONT`/
12
+ * `FONT_MONO` now resolve through the editor theme variables. Interactive
13
+ * controls themselves come from the canonical design-system primitives so
14
+ * this helper module cannot create a competing inline paint recipe.
15
+ */
16
+
17
+ import type React from 'react';
18
+ import { themeVars } from '../design-system';
19
+
20
+ export const THEME = {
21
+ bg: themeVars.surface.panel,
22
+ surface: themeVars.surface.chrome,
23
+ surfaceHover: themeVars.surface.raised,
24
+ border: themeVars.boundary.default,
25
+ text: themeVars.content.primary,
26
+ textMuted: themeVars.content.muted,
27
+ onAccent: themeVars.content.onAccent,
28
+ accent: themeVars.accent.default,
29
+ neutralHover: themeVars.neutralOverlay.hover,
30
+ radiusSmall: themeVars.shape.small,
31
+ radiusMedium: themeVars.shape.medium,
32
+ inputBg: themeVars.surface.inset,
33
+ dynamic: themeVars.semantic.dynamic,
34
+ dynamicBg: themeVars.semantic.dynamicMuted,
35
+ } as const;
36
+
37
+ export const FONT = themeVars.typography.sans;
38
+ export const FONT_MONO = themeVars.typography.mono;
39
+
40
+ /**
41
+ * Every widget's change contract (report §"prop shape" for the C2 wiring
42
+ * step): `onChange` fires on every interaction tick (drag/scrub/keystroke —
43
+ * the LIVE PREVIEW a caller can apply immediately, mirroring `boxEdit.apply`/
44
+ * `inspector.set`'s optimistic echo, T0 §2/A4); `onChangeEnd` fires once per
45
+ * gesture (pointerup/blur/Enter — the COMMIT a caller should push through
46
+ * `writeStyleAuto`/`inspector.set` as a single undo step, mirroring
47
+ * `BoxEditProvider.end`). A caller that only cares about single-shot edits
48
+ * (a `<Select>`, a click) may omit `onChangeEnd` — widgets fall back to
49
+ * treating `onChange` as the commit in that case.
50
+ */
51
+ export interface ChangeHandlers<T> {
52
+ value: T;
53
+ onChange: (value: T) => void;
54
+ onChangeEnd?: ((value: T) => void) | undefined;
55
+ disabled?: boolean | undefined;
56
+ }
57
+
58
+ export function fireChange<T>(handlers: Pick<ChangeHandlers<T>, 'onChange'>, value: T): void {
59
+ handlers.onChange(value);
60
+ }
61
+
62
+ export function fireEnd<T>(
63
+ handlers: Pick<ChangeHandlers<T>, 'onChange' | 'onChangeEnd'>,
64
+ value: T,
65
+ ): void {
66
+ (handlers.onChangeEnd ?? handlers.onChange)(value);
67
+ }
68
+
69
+ export const rowStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 4 };
70
+
71
+ /* ------------------------------------------------------------------------ */
72
+ /* Inspector-section style kit (editor-style-polish U3, I-8/I-10/I-12/I-13/ */
73
+ /* I-17/I-22) — the ONE home for the style objects the first-party sections */
74
+ /* used to hand-duplicate ~15 times each with tiny unintentional drift. */
75
+ /* ------------------------------------------------------------------------ */
76
+
77
+ /** Stacked field label ("label on its own line, field below" grammar) —
78
+ * replaces the `{fontSize:10, color:'#8c8c8c', marginBottom:2}` literal
79
+ * re-authored in nearly every `components/inspectors/*` section (I-8). */
80
+ export const fieldLabelStyle: React.CSSProperties = {
81
+ fontSize: 'var(--vgai-font-sm)',
82
+ color: themeVars.content.muted,
83
+ marginBottom: 2,
84
+ };
85
+
86
+ /** Sub-header / group label ("label over a related cluster of fields") — the
87
+ * ONE treatment for what used to be four (I-12): `Inspector.tsx`'s generic
88
+ * group header (11/600/uppercase/ls 0.4/#9aa0a6 — the incumbent this
89
+ * standardizes on), `ParticleSection.subHeaderStyle`,
90
+ * `PostProcessingSection.effectLabel`, and `MaterialSection`'s Textures. */
91
+ export const groupLabelStyle: React.CSSProperties = {
92
+ fontSize: 'var(--vgai-font-base)',
93
+ fontWeight: 600,
94
+ color: themeVars.content.muted,
95
+ textTransform: 'uppercase',
96
+ letterSpacing: 0.4,
97
+ };
98
+
99
+ /** One label-column width for every label-left/field-right row (I-13 —
100
+ * replaces the 56/64/70/76/80 ladder across Inspector.tsx/`Field`/
101
+ * `ComponentsSection`). */
102
+ export const LABEL_COL_WIDTH = 72;
103
+
104
+ export function ToggleButton({
105
+ label,
106
+ active,
107
+ disabled,
108
+ onClick,
109
+ testId,
110
+ }: {
111
+ label: React.ReactNode;
112
+ active: boolean;
113
+ disabled?: boolean | undefined;
114
+ onClick: () => void;
115
+ testId?: string | undefined;
116
+ }): React.ReactElement {
117
+ return (
118
+ <Button
119
+ variant={active ? 'primary' : 'secondary'}
120
+ size="compact"
121
+ aria-pressed={active}
122
+ disabled={disabled}
123
+ data-testid={testId}
124
+ onClick={() => {
125
+ if (!disabled) onClick();
126
+ }}
127
+ style={{ flex: 1 }}
128
+ >
129
+ {label}
130
+ </Button>
131
+ );
132
+ }
133
+
134
+ /** `parseNumericValue('12.5px') -> {num: 12.5, unit: 'px'}` (visual-edit :98). */
135
+ export function parseNumericValue(value: string): { num: number; unit: string } {
136
+ const match = value.match(/^(-?\d+\.?\d*)\s*(px|em|rem|%|vh|vw|deg|s|ms)?$/);
137
+ if (match) return { num: Number.parseFloat(match[1] ?? '0'), unit: match[2] || '' };
138
+ const num = Number.parseFloat(value);
139
+ return { num: Number.isNaN(num) ? 0 : num, unit: '' };
140
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Interactive-edit (undo-gesture) scope — the ONE thing the shared input
3
+ * primitives (`primitives/NumberInput.tsx` / `primitives/ColorInput.tsx`)
4
+ * need from the editor: "a continuous gesture (scrub / color drag) starts /
5
+ * ends now, coalesce it into a single undo step".
6
+ *
7
+ * Split out of the editor's `EditorContext.tsx`: the widget kit is a public
8
+ * export (`@volter/editor-sdk/widgets`) that scaffolded PROJECTS typecheck — importing
9
+ * `EditorContext` (and through it `EditorShellStore` and most of the editor)
10
+ * from a widget would drag the whole editor source graph into every project's
11
+ * `tsc`. This module's closure is react-only; `EditorProvider` bridges it to
12
+ * the real store (`beginInteractiveEdit`/`endInteractiveEdit`).
13
+ *
14
+ * Widgets consume it optionally (`useInteractiveEditScope()` returns `null`
15
+ * outside a provider — e.g. the widget gallery, or a project tool rendered
16
+ * in some future host): no scope simply means no undo coalescing.
17
+ */
18
+
19
+ import { createContext, useContext } from 'react';
20
+
21
+ /** Begin/end a coalesced interactive edit gesture (one undo step). Both are
22
+ * idempotent in the editor's implementation — safe to call defensively. */
23
+ export interface InteractiveEditScope {
24
+ begin(): void;
25
+ end(): void;
26
+ }
27
+
28
+ export const InteractiveEditScopeContext = createContext<InteractiveEditScope | null>(null);
29
+
30
+ /** The ambient gesture scope, or `null` when no host provides one. */
31
+ export function useInteractiveEditScope(): InteractiveEditScope | null {
32
+ return useContext(InteractiveEditScopeContext);
33
+ }
@@ -0,0 +1,129 @@
1
+ import { type HTMLAttributes, type LabelHTMLAttributes, type ReactNode, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Actions, Inline, Stack } from '../primitives/Layout';
4
+ import { Text } from '../primitives/Text';
5
+
6
+ function classes(base: string, className?: string): string {
7
+ return className ? `${base} ${className}` : base;
8
+ }
9
+
10
+ export interface DialogProps extends HTMLAttributes<HTMLDivElement> {
11
+ labelledBy: string;
12
+ onDismiss?: () => void;
13
+ size?: 'compact' | 'default' | 'wide';
14
+ variant?: 'default' | 'command';
15
+ }
16
+
17
+ /** Modal editor surface. The scrim, sizing, elevation, and spacing are one recipe. */
18
+ export function Dialog({
19
+ labelledBy,
20
+ onDismiss,
21
+ size = 'default',
22
+ variant = 'default',
23
+ className,
24
+ ...props
25
+ }: DialogProps) {
26
+ // F10/F11 (U6.5): the `position: fixed` scrim must anchor to the window, not
27
+ // to whatever ancestor happens to be its containing block. Rendered inline it
28
+ // sat inside `#editor-root` (z-index 10), so the islands-mode footer/session
29
+ // tray (`#editor-bottom`, z-index 100) painted OVER the scrim (F10); and under
30
+ // a glass theme a card's `backdrop-filter` became the fixed scrim's containing
31
+ // block, collapsing `inset: 0` to a ~40px box and shoving the dialog off the
32
+ // top edge (F11). Portaling to the filter-free theme root — a sibling of
33
+ // `#editor-bottom` — makes `inset: 0` resolve to the viewport (full-cover
34
+ // scrim, correctly centered dialog) and lets the scrim's z-index win. Falls
35
+ // back to inline rendering when no theme root is present (isolated unit tests).
36
+ //
37
+ // R2 / 42:608 (P6-U7): resolve the portal root SYNCHRONOUSLY on the first
38
+ // render (lazy `useState` init) rather than in a post-mount layout effect.
39
+ // The former effect-based null→portal flip re-committed the scrim ONE render
40
+ // after mount, which — because switching an element between inline and a
41
+ // `createPortal` boundary remounts its DOM subtree — destroyed and recreated
42
+ // any child input right after a consumer's mount-time autofocus ran. That is
43
+ // exactly why Ctrl+K opened the Command Palette with its search box unfocused
44
+ // (focus fell to <body>), so typed text and Enter went nowhere and no
45
+ // document opened. The editor mounts a single `.vgai-editor-theme` root at
46
+ // app init, long before any Dialog opens, so a plain `document.querySelector`
47
+ // finds it on render 1 — the scrim portals from the start, the input mounts
48
+ // once, and focus is preserved.
49
+ const [portalRoot] = useState<Element | null>(() =>
50
+ typeof document === 'undefined' ? null : document.querySelector('.vgai-editor-theme'),
51
+ );
52
+
53
+ const scrim = (
54
+ <div
55
+ className="vgai-dialog-scrim"
56
+ data-variant={variant}
57
+ onPointerDown={(event) => {
58
+ if (event.target === event.currentTarget) onDismiss?.();
59
+ }}
60
+ >
61
+ <div
62
+ {...props}
63
+ role="dialog"
64
+ aria-modal="true"
65
+ aria-labelledby={labelledBy}
66
+ data-size={size}
67
+ data-variant={variant}
68
+ className={classes('vgai-dialog', className)}
69
+ />
70
+ </div>
71
+ );
72
+
73
+ return portalRoot ? createPortal(scrim, portalRoot) : scrim;
74
+ }
75
+
76
+ export function DialogHeader({
77
+ titleId,
78
+ title,
79
+ description,
80
+ }: {
81
+ titleId: string;
82
+ title: ReactNode;
83
+ description?: ReactNode;
84
+ }) {
85
+ return (
86
+ <Stack className="vgai-dialog-header" gap={2}>
87
+ <Text id={titleId} as="h2" variant="heading">
88
+ {title}
89
+ </Text>
90
+ {description && (
91
+ <Text as="p" tone="muted">
92
+ {description}
93
+ </Text>
94
+ )}
95
+ </Stack>
96
+ );
97
+ }
98
+
99
+ export function DialogBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
100
+ return <Stack {...props} className={classes('vgai-dialog-body', className)} />;
101
+ }
102
+
103
+ export function DialogFooter({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
104
+ return (
105
+ <Actions {...props} className={classes('vgai-dialog-footer', className)}>
106
+ {children}
107
+ </Actions>
108
+ );
109
+ }
110
+
111
+ export function DialogField({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
112
+ return <label {...props} className={classes('vgai-dialog-field', className)} />;
113
+ }
114
+
115
+ export function DialogFieldLabel({ children }: { children: ReactNode }) {
116
+ return (
117
+ <Text variant="label" tone="muted">
118
+ {children}
119
+ </Text>
120
+ );
121
+ }
122
+
123
+ export function JoinedField({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
124
+ return <Inline {...props} className={classes('vgai-joined-field', className)} />;
125
+ }
126
+
127
+ export function JoinedFieldSuffix({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
128
+ return <span {...props} className={classes('vgai-joined-field-suffix', className)} />;
129
+ }
@@ -0,0 +1,44 @@
1
+ import type { HTMLAttributes, LabelHTMLAttributes, ReactNode } from 'react';
2
+
3
+ function classes(base: string, className?: string): string {
4
+ return className ? `${base} ${className}` : base;
5
+ }
6
+
7
+ export function FieldGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
8
+ return <div {...props} className={classes('vgai-field-group', className)} />;
9
+ }
10
+
11
+ export interface FieldRowProps extends HTMLAttributes<HTMLDivElement> {
12
+ label: ReactNode;
13
+ htmlFor?: string;
14
+ hint?: ReactNode;
15
+ compact?: boolean;
16
+ }
17
+
18
+ export function FieldRow({
19
+ label,
20
+ htmlFor,
21
+ hint,
22
+ compact = false,
23
+ className,
24
+ children,
25
+ ...props
26
+ }: FieldRowProps) {
27
+ return (
28
+ <div
29
+ {...props}
30
+ data-compact={compact || undefined}
31
+ className={classes('vgai-field-row', className)}
32
+ >
33
+ <label htmlFor={htmlFor} className="vgai-field-label">
34
+ {label}
35
+ </label>
36
+ <div className="vgai-field-control">{children}</div>
37
+ {hint && <div className="vgai-field-hint">{hint}</div>}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function FieldLabel({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
43
+ return <label {...props} className={classes('vgai-field-label', className)} />;
44
+ }
@@ -0,0 +1,25 @@
1
+ import { type ButtonHTMLAttributes, forwardRef } from 'react';
2
+
3
+ export interface EditorListButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
4
+ selected?: boolean;
5
+ accent?: 'bar' | 'none';
6
+ }
7
+
8
+ /** Selectable list row with canonical hover, focus, disabled, and selection paint. */
9
+ export const EditorListButton = forwardRef<HTMLButtonElement, EditorListButtonProps>(
10
+ function EditorListButton(
11
+ { selected = false, accent = 'bar', className, type = 'button', ...props },
12
+ ref,
13
+ ) {
14
+ return (
15
+ <button
16
+ {...props}
17
+ ref={ref}
18
+ type={type}
19
+ data-selected={selected || undefined}
20
+ data-accent={accent}
21
+ className={className ? `vgai-list-button ${className}` : 'vgai-list-button'}
22
+ />
23
+ );
24
+ },
25
+ );
@@ -0,0 +1,40 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export type StateSurfaceTone = 'neutral' | 'loading' | 'error' | 'success';
4
+
5
+ export interface StateSurfaceProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
6
+ tone?: StateSurfaceTone;
7
+ icon?: ReactNode;
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ action?: ReactNode;
11
+ compact?: boolean;
12
+ }
13
+
14
+ export function StateSurface({
15
+ tone = 'neutral',
16
+ icon,
17
+ title,
18
+ description,
19
+ action,
20
+ compact = false,
21
+ className,
22
+ ...props
23
+ }: StateSurfaceProps) {
24
+ const defaultRole =
25
+ tone === 'error' ? 'alert' : tone === 'loading' || tone === 'success' ? 'status' : undefined;
26
+ return (
27
+ <div
28
+ {...props}
29
+ role={props.role ?? defaultRole}
30
+ data-tone={tone}
31
+ data-compact={compact || undefined}
32
+ className={className ? `vgai-state-surface ${className}` : 'vgai-state-surface'}
33
+ >
34
+ {icon && <div className="vgai-state-surface-icon">{icon}</div>}
35
+ <strong className="vgai-state-surface-title">{title}</strong>
36
+ {description && <div className="vgai-state-surface-description">{description}</div>}
37
+ {action && <div className="vgai-state-surface-action">{action}</div>}
38
+ </div>
39
+ );
40
+ }