@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,623 @@
1
+ import { Button, NumberInput } from '../design-system';
2
+ /**
3
+ * GradientEditor — solid/gradient toggle, stop list (add/remove/drag),
4
+ * linear/radial toggle, angle (spec 27 §5 C1). Ported from
5
+ * `visual-edit/inspector.tsx`'s `GradientEditor` (:823-989) — kept as a
6
+ * gradient-only value editor here (the reference's solid-vs-gradient mode
7
+ * switch that also drove `backgroundColor` is a Fill-SECTION concern; C2
8
+ * wires the section's solid/gradient toggle around this + `ColorSwatch`).
9
+ *
10
+ * W1b promotion: the draggable-stop TRACK is factored out as
11
+ * `GradientStopsTrack` and shared by TWO value adapters over the one core —
12
+ * this CSS `GradientValue` editor (unchanged contract + test-ids) and
13
+ * `QuarksGradientEditor`, which edits the particle system's
14
+ * three.quarks-shaped `Gradient` JSON (separate color keys `{value:{r,g,b},
15
+ * pos}` and alpha keys `{value:number, pos}` — the engine schema's
16
+ * `GradientSchema`, typed structurally here so the kit stays decoupled).
17
+ * One component, parameterized — not a fork.
18
+ */
19
+
20
+ import { useState } from 'react';
21
+ import { ColorSwatch } from './ColorPicker';
22
+ import { rgbStringToHex } from './color-utils';
23
+ import { ScrubbableInput } from './ScrubbableInput';
24
+ import { type ChangeHandlers, fireChange, fireEnd, THEME, ToggleButton } from './shared';
25
+
26
+ export interface GradientStop {
27
+ color: string;
28
+ position: number; // 0..1
29
+ }
30
+
31
+ export interface GradientValue {
32
+ type: 'linear' | 'radial';
33
+ angle: number; // degrees, linear only
34
+ stops: GradientStop[];
35
+ }
36
+
37
+ export function composeGradient(g: GradientValue): string {
38
+ const stopStr = g.stops.map((s) => `${s.color} ${Math.round(s.position * 100)}%`).join(', ');
39
+ return g.type === 'radial'
40
+ ? `radial-gradient(circle, ${stopStr})`
41
+ : `linear-gradient(${g.angle}deg, ${stopStr})`;
42
+ }
43
+
44
+ /** The `linear-gradient(...)`/`radial-gradient(...)` outer match -> `{type, inner}`,
45
+ * or `null` if `value` isn't either. Split out of `parseGradient` to keep
46
+ * each step under biome's cognitive-complexity ceiling. */
47
+ function matchGradientShell(value: string): { type: GradientValue['type']; inner: string } | null {
48
+ const linearMatch = value.match(/^linear-gradient\((.+)\)$/);
49
+ if (linearMatch?.[1] !== undefined) return { type: 'linear', inner: linearMatch[1] };
50
+ const radialMatch = value.match(/^radial-gradient\((.+)\)$/);
51
+ if (radialMatch?.[1] !== undefined) return { type: 'radial', inner: radialMatch[1] };
52
+ return null;
53
+ }
54
+
55
+ /** Strips a leading `<N>deg,`/`to <dir>,` prefix off a linear gradient's
56
+ * inner string -> `{angle, colorPart}` (angle stays 180 — CSS's own default
57
+ * — when neither prefix is present). */
58
+ function stripLinearAngle(inner: string): { angle: number; colorPart: string } {
59
+ const angleMatch = inner.match(/^(\d+)deg\s*,\s*/);
60
+ if (angleMatch) {
61
+ return {
62
+ angle: Number.parseInt(angleMatch[1] ?? '180', 10),
63
+ colorPart: inner.slice(angleMatch[0].length),
64
+ };
65
+ }
66
+ if (inner.startsWith('to ')) {
67
+ const dirMatch = inner.match(/^to\s+\w+\s*,\s*/);
68
+ if (dirMatch) return { angle: 180, colorPart: inner.slice(dirMatch[0].length) };
69
+ }
70
+ return { angle: 180, colorPart: inner };
71
+ }
72
+
73
+ /** `"#fff 0%, #000 100%"` -> stops, auto-spreading any stop that omitted its
74
+ * `%` evenly across the run (matches the CSS spec's own auto-position rule). */
75
+ function parseGradientStops(colorPart: string): GradientStop[] {
76
+ const stops: GradientStop[] = colorPart.split(/,\s*(?![^(]*\))/).map((part) => {
77
+ const trimmed = part.trim();
78
+ const pctMatch = trimmed.match(/\s+(\d+)%$/);
79
+ const position = pctMatch ? Number.parseInt(pctMatch[1] ?? '0', 10) / 100 : -1;
80
+ const color = pctMatch ? trimmed.slice(0, -pctMatch[0].length).trim() : trimmed;
81
+ return { color, position };
82
+ });
83
+ for (let i = 0; i < stops.length; i++) {
84
+ const stop = stops[i];
85
+ if (stop && stop.position < 0) stop.position = stops.length > 1 ? i / (stops.length - 1) : 0;
86
+ }
87
+ return stops;
88
+ }
89
+
90
+ export function parseGradient(value: string): GradientValue | null {
91
+ if (!value || value === 'none') return null;
92
+ const shell = matchGradientShell(value);
93
+ if (!shell) return null;
94
+ const { angle, colorPart } =
95
+ shell.type === 'linear'
96
+ ? stripLinearAngle(shell.inner)
97
+ : { angle: 180, colorPart: shell.inner };
98
+ const stops = parseGradientStops(colorPart);
99
+ if (stops.length < 2) return null;
100
+ return { type: shell.type, angle, stops };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Shared draggable-stops track (W1b) — the one core under both adapters.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export interface TrackStop {
108
+ /** 0..1 position along the track. */
109
+ position: number;
110
+ /** CSS color painted on the stop dot. */
111
+ css: string;
112
+ }
113
+
114
+ export interface GradientStopsTrackProps {
115
+ stops: TrackStop[];
116
+ selected: number;
117
+ onSelect: (index: number) => void;
118
+ /** Fires with the dragged stop's index + clamped 0..1 position; `commit`
119
+ * is false on every move, true once on release (the caller's undo step). */
120
+ onMove: (index: number, position: number, commit: boolean) => void;
121
+ disabled?: boolean | undefined;
122
+ /** Per-dot test ids are `${testIdPrefix}-${i}`. */
123
+ testIdPrefix: string;
124
+ }
125
+
126
+ export function GradientStopsTrack({
127
+ stops,
128
+ selected,
129
+ onSelect,
130
+ onMove,
131
+ disabled,
132
+ testIdPrefix,
133
+ }: GradientStopsTrackProps): React.ReactElement {
134
+ return (
135
+ <div style={{ position: 'relative', height: 12, marginTop: 2 }}>
136
+ {stops.map((stop, i) => (
137
+ <div
138
+ key={i}
139
+ data-testid={`${testIdPrefix}-${i}`}
140
+ onClick={() => onSelect(i)}
141
+ onMouseDown={(e) => {
142
+ if (disabled) return;
143
+ e.preventDefault();
144
+ onSelect(i);
145
+ const parent = (e.target as HTMLElement).parentElement;
146
+ const rect = parent?.getBoundingClientRect();
147
+ const posAt = (clientX: number): number =>
148
+ rect?.width
149
+ ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
150
+ : stop.position;
151
+ const onMoveEv = (me: MouseEvent): void => onMove(i, posAt(me.clientX), false);
152
+ const onUp = (me: MouseEvent): void => {
153
+ onMove(i, posAt(me.clientX), true);
154
+ window.removeEventListener('mousemove', onMoveEv);
155
+ window.removeEventListener('mouseup', onUp);
156
+ };
157
+ window.addEventListener('mousemove', onMoveEv);
158
+ window.addEventListener('mouseup', onUp);
159
+ }}
160
+ style={{
161
+ position: 'absolute',
162
+ left: `${stop.position * 100}%`,
163
+ top: 0,
164
+ width: 10,
165
+ height: 10,
166
+ borderRadius: '50%',
167
+ background: stop.css,
168
+ border: `2px solid ${i === selected ? THEME.accent : '#fff'}`,
169
+ transform: 'translateX(-50%)',
170
+ cursor: disabled ? 'not-allowed' : 'pointer',
171
+ }}
172
+ />
173
+ ))}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ export interface GradientEditorProps extends ChangeHandlers<GradientValue> {
179
+ recentColors?: string[] | undefined;
180
+ onAddRecentColor?: ((color: string) => void) | undefined;
181
+ /** Root-element test id. Defaults to the stable `gradient-editor` (the gallery
182
+ * / C1 contract); a caller that renders MORE THAN ONE gradient editor on a
183
+ * node (the inspector's `prop.background` + `style.backgroundImage` rows)
184
+ * passes a per-row id so the two roots don't collide (spec 27 §5 C2, S1). */
185
+ testId?: string | undefined;
186
+ }
187
+
188
+ export function GradientEditor({
189
+ value,
190
+ onChange,
191
+ onChangeEnd,
192
+ disabled,
193
+ recentColors,
194
+ onAddRecentColor,
195
+ testId = 'gradient-editor',
196
+ }: GradientEditorProps): React.ReactElement {
197
+ const [selectedStop, setSelectedStop] = useState(0);
198
+
199
+ const apply = (next: GradientValue, commit: boolean): void => {
200
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
201
+ else fireChange({ onChange }, next);
202
+ };
203
+
204
+ const setStop = (index: number, partial: Partial<GradientStop>, commit: boolean): void => {
205
+ const stops = value.stops.map((s, i) => (i === index ? { ...s, ...partial } : s));
206
+ apply({ ...value, stops }, commit);
207
+ };
208
+
209
+ const addStop = (): void => {
210
+ const last = value.stops[value.stops.length - 1]?.position ?? 1;
211
+ const first = value.stops[0]?.position ?? 0;
212
+ const pos = (last + first) / 2;
213
+ const stops = [...value.stops, { color: '#888888', position: pos }].sort(
214
+ (a, b) => a.position - b.position,
215
+ );
216
+ setSelectedStop(stops.length - 1);
217
+ apply({ ...value, stops }, true);
218
+ };
219
+
220
+ const removeStop = (index: number): void => {
221
+ if (value.stops.length <= 2) return;
222
+ const stops = value.stops.filter((_, i) => i !== index);
223
+ setSelectedStop(Math.min(selectedStop, stops.length - 1));
224
+ apply({ ...value, stops }, true);
225
+ };
226
+
227
+ const stopColor = value.stops[selectedStop]?.color ?? '#000000';
228
+ const stopHex = stopColor.startsWith('#') ? stopColor : rgbStringToHex(stopColor);
229
+
230
+ return (
231
+ <div data-testid={testId}>
232
+ <div style={{ position: 'relative', marginBottom: 8 }}>
233
+ <div
234
+ data-testid="gradient-preview"
235
+ style={{
236
+ height: 20,
237
+ borderRadius: THEME.radiusSmall,
238
+ border: `1px solid ${THEME.border}`,
239
+ background: composeGradient(value),
240
+ }}
241
+ />
242
+ <GradientStopsTrack
243
+ stops={value.stops.map((s) => ({ position: s.position, css: s.color }))}
244
+ selected={selectedStop}
245
+ onSelect={setSelectedStop}
246
+ onMove={(i, position, commit) => setStop(i, { position }, commit)}
247
+ disabled={disabled}
248
+ testIdPrefix="gradient-stop"
249
+ />
250
+ </div>
251
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
252
+ <ColorSwatch
253
+ testId="gradient-selected-color"
254
+ value={stopHex}
255
+ disabled={disabled}
256
+ onChange={(c) => setStop(selectedStop, { color: c }, false)}
257
+ onChangeEnd={(c) => setStop(selectedStop, { color: c }, true)}
258
+ recentColors={recentColors}
259
+ onAddRecentColor={onAddRecentColor}
260
+ />
261
+ <span style={{ fontSize: 9, color: THEME.textMuted }}>Stop {selectedStop + 1}</span>
262
+ {value.stops.length > 2 && (
263
+ <Button
264
+ type="button"
265
+ variant="ghost"
266
+ size="compact"
267
+ aria-label="Remove gradient stop"
268
+ data-testid="gradient-remove-stop"
269
+ onClick={() => removeStop(selectedStop)}
270
+ >
271
+ ×
272
+ </Button>
273
+ )}
274
+ <Button
275
+ type="button"
276
+ variant="ghost"
277
+ size="compact"
278
+ data-testid="gradient-add-stop"
279
+ onClick={addStop}
280
+ >
281
+ + stop
282
+ </Button>
283
+ </div>
284
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
285
+ <div style={{ display: 'flex', gap: 1, flex: 1 }}>
286
+ <ToggleButton
287
+ testId="gradient-type-linear"
288
+ label="Linear"
289
+ active={value.type === 'linear'}
290
+ disabled={disabled}
291
+ onClick={() => apply({ ...value, type: 'linear' }, true)}
292
+ />
293
+ <ToggleButton
294
+ testId="gradient-type-radial"
295
+ label="Radial"
296
+ active={value.type === 'radial'}
297
+ disabled={disabled}
298
+ onClick={() => apply({ ...value, type: 'radial' }, true)}
299
+ />
300
+ </div>
301
+ {value.type === 'linear' && (
302
+ <ScrubbableInput
303
+ testId="gradient-angle"
304
+ label="°"
305
+ value={value.angle}
306
+ unit="deg"
307
+ disabled={disabled}
308
+ onChange={(v) => apply({ ...value, angle: v }, false)}
309
+ onChangeEnd={(v) => apply({ ...value, angle: v }, true)}
310
+ style={{ width: 70 }}
311
+ />
312
+ )}
313
+ </div>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // QuarksGradientEditor (W1b) — the same stops-track core over the particle
320
+ // system's three.quarks `Gradient` JSON (engine `GradientSchema`, typed
321
+ // structurally): color keys {value:{r,g,b}, pos} + alpha keys {value, pos}.
322
+ // ---------------------------------------------------------------------------
323
+
324
+ export interface QuarksColorKey {
325
+ value: { r: number; g: number; b: number };
326
+ pos: number;
327
+ }
328
+
329
+ export interface QuarksAlphaKey {
330
+ value: number;
331
+ pos: number;
332
+ }
333
+
334
+ export interface QuarksGradientValue {
335
+ type: 'Gradient';
336
+ color: { type: 'CLinearFunction'; subType: 'Color'; keys: QuarksColorKey[] };
337
+ alpha: { type: 'CLinearFunction'; subType: 'Number'; keys: QuarksAlphaKey[] };
338
+ }
339
+
340
+ export interface QuarksGradientEditorProps extends ChangeHandlers<QuarksGradientValue> {
341
+ testId?: string | undefined;
342
+ }
343
+
344
+ function rgb01ToHex(c: { r: number; g: number; b: number }): string {
345
+ const h = (v: number): string =>
346
+ Math.round(Math.max(0, Math.min(1, v)) * 255)
347
+ .toString(16)
348
+ .padStart(2, '0');
349
+ return `#${h(c.r)}${h(c.g)}${h(c.b)}`;
350
+ }
351
+
352
+ function hexToRgb01(hex: string): { r: number; g: number; b: number } {
353
+ return {
354
+ r: Number.parseInt(hex.slice(1, 3), 16) / 255,
355
+ g: Number.parseInt(hex.slice(3, 5), 16) / 255,
356
+ b: Number.parseInt(hex.slice(5, 7), 16) / 255,
357
+ };
358
+ }
359
+
360
+ /** Piecewise-linear sample of {value, pos} keys (keys sorted by pos). */
361
+ function sampleKeys<V>(
362
+ keys: { value: V; pos: number }[],
363
+ pos: number,
364
+ lerpValue: (a: V, b: V, u: number) => V,
365
+ ): V | undefined {
366
+ if (keys.length === 0) return undefined;
367
+ if (pos <= keys[0]!.pos) return keys[0]!.value;
368
+ const last = keys[keys.length - 1]!;
369
+ if (pos >= last.pos) return last.value;
370
+ for (let i = 0; i + 1 < keys.length; i++) {
371
+ const a = keys[i]!;
372
+ const b = keys[i + 1]!;
373
+ if (pos >= a.pos && pos <= b.pos) {
374
+ const u = b.pos > a.pos ? (pos - a.pos) / (b.pos - a.pos) : 0;
375
+ return lerpValue(a.value, b.value, u);
376
+ }
377
+ }
378
+ return last.value;
379
+ }
380
+
381
+ const lerpNum = (a: number, b: number, u: number): number => a + (b - a) * u;
382
+ const lerpRgb = (
383
+ a: { r: number; g: number; b: number },
384
+ b: { r: number; g: number; b: number },
385
+ u: number,
386
+ ): { r: number; g: number; b: number } => ({
387
+ r: lerpNum(a.r, b.r, u),
388
+ g: lerpNum(a.g, b.g, u),
389
+ b: lerpNum(a.b, b.b, u),
390
+ });
391
+
392
+ /** Move key `i` to `pos`, keeping the list sorted; returns the moved key's new index. */
393
+ function moveKeyPos<K extends { pos: number }>(
394
+ keys: K[],
395
+ i: number,
396
+ pos: number,
397
+ ): { keys: K[]; index: number } {
398
+ const moved = { ...keys[i]!, pos };
399
+ const next = [...keys.filter((_, j) => j !== i), moved].sort((a, b) => a.pos - b.pos);
400
+ return { keys: next, index: next.indexOf(moved) };
401
+ }
402
+
403
+ export function QuarksGradientEditor({
404
+ value,
405
+ onChange,
406
+ onChangeEnd,
407
+ disabled,
408
+ testId = 'quarks-gradient',
409
+ }: QuarksGradientEditorProps): React.ReactElement {
410
+ const [sel, setSel] = useState<{ track: 'color' | 'alpha'; i: number }>({ track: 'color', i: 0 });
411
+
412
+ const apply = (next: QuarksGradientValue, commit: boolean): void => {
413
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
414
+ else fireChange({ onChange }, next);
415
+ };
416
+
417
+ const withColorKeys = (keys: QuarksColorKey[]): QuarksGradientValue => ({
418
+ ...value,
419
+ color: { ...value.color, keys },
420
+ });
421
+ const withAlphaKeys = (keys: QuarksAlphaKey[]): QuarksGradientValue => ({
422
+ ...value,
423
+ alpha: { ...value.alpha, keys },
424
+ });
425
+
426
+ // Combined rgba preview: sample color+alpha at the union of key positions.
427
+ const positions = [
428
+ ...new Set([...value.color.keys.map((k) => k.pos), ...value.alpha.keys.map((k) => k.pos)]),
429
+ ].sort((a, b) => a - b);
430
+ const previewStops = positions
431
+ .map((pos) => {
432
+ const c = sampleKeys(value.color.keys, pos, lerpRgb) ?? { r: 1, g: 1, b: 1 };
433
+ const a = sampleKeys(value.alpha.keys, pos, lerpNum) ?? 1;
434
+ return `rgba(${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)},${Math.round(a * 1000) / 1000}) ${Math.round(pos * 100)}%`;
435
+ })
436
+ .join(', ');
437
+
438
+ const selColorKey = value.color.keys[sel.track === 'color' ? sel.i : 0];
439
+ const selAlphaKey = value.alpha.keys[sel.track === 'alpha' ? sel.i : 0];
440
+
441
+ return (
442
+ <div data-testid={testId}>
443
+ {/* alpha track (Unity-style: above the bar) */}
444
+ <GradientStopsTrack
445
+ stops={value.alpha.keys.map((k) => {
446
+ const g = Math.round(Math.max(0, Math.min(1, k.value)) * 255);
447
+ return { position: k.pos, css: `rgb(${g},${g},${g})` };
448
+ })}
449
+ selected={sel.track === 'alpha' ? sel.i : -1}
450
+ onSelect={(i) => setSel({ track: 'alpha', i })}
451
+ onMove={(i, pos, commit) => {
452
+ const { keys, index } = moveKeyPos(value.alpha.keys, i, pos);
453
+ setSel({ track: 'alpha', i: index });
454
+ apply(withAlphaKeys(keys), commit);
455
+ }}
456
+ disabled={disabled}
457
+ testIdPrefix={`${testId}-alpha-stop`}
458
+ />
459
+
460
+ {/* checkerboard + rgba gradient preview */}
461
+ <div
462
+ data-testid={`${testId}-preview`}
463
+ style={{
464
+ height: 20,
465
+ borderRadius: THEME.radiusSmall,
466
+ border: `1px solid ${THEME.border}`,
467
+ backgroundImage: `linear-gradient(90deg, ${previewStops}), repeating-linear-gradient(45deg, #777 0 5px, #aaa 5px 10px)`,
468
+ }}
469
+ />
470
+
471
+ {/* color track */}
472
+ <GradientStopsTrack
473
+ stops={value.color.keys.map((k) => ({ position: k.pos, css: rgb01ToHex(k.value) }))}
474
+ selected={sel.track === 'color' ? sel.i : -1}
475
+ onSelect={(i) => setSel({ track: 'color', i })}
476
+ onMove={(i, pos, commit) => {
477
+ const { keys, index } = moveKeyPos(value.color.keys, i, pos);
478
+ setSel({ track: 'color', i: index });
479
+ apply(withColorKeys(keys), commit);
480
+ }}
481
+ disabled={disabled}
482
+ testIdPrefix={`${testId}-color-stop`}
483
+ />
484
+
485
+ {/* selected-stop controls */}
486
+ {sel.track === 'color' && selColorKey && (
487
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
488
+ <ColorSwatch
489
+ testId={`${testId}-color-swatch`}
490
+ value={rgb01ToHex(selColorKey.value)}
491
+ disabled={disabled}
492
+ onChange={(c) =>
493
+ apply(
494
+ withColorKeys(
495
+ value.color.keys.map((k, j) =>
496
+ j === sel.i ? { ...k, value: hexToRgb01(c) } : k,
497
+ ),
498
+ ),
499
+ false,
500
+ )
501
+ }
502
+ onChangeEnd={(c) =>
503
+ apply(
504
+ withColorKeys(
505
+ value.color.keys.map((k, j) =>
506
+ j === sel.i ? { ...k, value: hexToRgb01(c) } : k,
507
+ ),
508
+ ),
509
+ true,
510
+ )
511
+ }
512
+ />
513
+ <span style={{ fontSize: 9, color: THEME.textMuted }}>
514
+ Color {sel.i + 1}/{value.color.keys.length}
515
+ </span>
516
+ {value.color.keys.length > 2 && (
517
+ <Button
518
+ type="button"
519
+ variant="ghost"
520
+ size="compact"
521
+ aria-label="Remove color stop"
522
+ data-testid={`${testId}-remove-color-stop`}
523
+ onClick={() => {
524
+ const keys = value.color.keys.filter((_, j) => j !== sel.i);
525
+ setSel({ track: 'color', i: Math.min(sel.i, keys.length - 1) });
526
+ apply(withColorKeys(keys), true);
527
+ }}
528
+ >
529
+ ×
530
+ </Button>
531
+ )}
532
+ <Button
533
+ type="button"
534
+ variant="ghost"
535
+ size="compact"
536
+ data-testid={`${testId}-add-color-stop`}
537
+ onClick={() => {
538
+ const pos = 0.5;
539
+ const color = sampleKeys(value.color.keys, pos, lerpRgb) ?? { r: 1, g: 1, b: 1 };
540
+ const { keys, index } = moveKeyPos(
541
+ [...value.color.keys, { value: color, pos }],
542
+ value.color.keys.length,
543
+ pos,
544
+ );
545
+ setSel({ track: 'color', i: index });
546
+ apply(withColorKeys(keys), true);
547
+ }}
548
+ >
549
+ + stop
550
+ </Button>
551
+ </div>
552
+ )}
553
+ {sel.track === 'alpha' && selAlphaKey && (
554
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
555
+ <NumberInput
556
+ label="a"
557
+ step={0.05}
558
+ testId={`${testId}-alpha-value`}
559
+ value={Math.round(selAlphaKey.value * 1000) / 1000}
560
+ onChange={(v) =>
561
+ apply(
562
+ withAlphaKeys(
563
+ value.alpha.keys.map((k, j) =>
564
+ j === sel.i ? { ...k, value: Math.max(0, Math.min(1, v)) } : k,
565
+ ),
566
+ ),
567
+ false,
568
+ )
569
+ }
570
+ onChangeEnd={(v) =>
571
+ apply(
572
+ withAlphaKeys(
573
+ value.alpha.keys.map((k, j) =>
574
+ j === sel.i ? { ...k, value: Math.max(0, Math.min(1, v)) } : k,
575
+ ),
576
+ ),
577
+ true,
578
+ )
579
+ }
580
+ />
581
+ <span style={{ fontSize: 9, color: THEME.textMuted }}>
582
+ Alpha {sel.i + 1}/{value.alpha.keys.length}
583
+ </span>
584
+ {value.alpha.keys.length > 2 && (
585
+ <Button
586
+ type="button"
587
+ variant="ghost"
588
+ size="compact"
589
+ aria-label="Remove alpha stop"
590
+ data-testid={`${testId}-remove-alpha-stop`}
591
+ onClick={() => {
592
+ const keys = value.alpha.keys.filter((_, j) => j !== sel.i);
593
+ setSel({ track: 'alpha', i: Math.min(sel.i, keys.length - 1) });
594
+ apply(withAlphaKeys(keys), true);
595
+ }}
596
+ >
597
+ ×
598
+ </Button>
599
+ )}
600
+ <Button
601
+ type="button"
602
+ variant="ghost"
603
+ size="compact"
604
+ data-testid={`${testId}-add-alpha-stop`}
605
+ onClick={() => {
606
+ const pos = 0.5;
607
+ const alpha = sampleKeys(value.alpha.keys, pos, lerpNum) ?? 1;
608
+ const { keys, index } = moveKeyPos(
609
+ [...value.alpha.keys, { value: alpha, pos }],
610
+ value.alpha.keys.length,
611
+ pos,
612
+ );
613
+ setSel({ track: 'alpha', i: index });
614
+ apply(withAlphaKeys(keys), true);
615
+ }}
616
+ >
617
+ + stop
618
+ </Button>
619
+ </div>
620
+ )}
621
+ </div>
622
+ );
623
+ }