@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,549 @@
1
+ import { Button, EditorPopover, TextInput } from '../design-system';
2
+ /**
3
+ * ColorPicker — HSV area + hue/alpha sliders, hex↔rgb↔hsl format cycle,
4
+ * eyedropper, recent colors (spec 27 §5 C1). Ported from
5
+ * `visual-edit/inspector.tsx`'s `ColorPicker` (:1043-1421). The reference
6
+ * threaded `onCommit`/`onClose`/an imperative `onActivateEyedropper` callback
7
+ * that the HOST app wired to its own canvas-hit-test eyedropper mode; this
8
+ * port instead feature-detects the native `EyeDropper` API directly (per the
9
+ * spec's explicit ask) and falls back to a caller-supplied
10
+ * `eyedropperFallback` (wired at the C2 step to
11
+ * `ui-source/inspect.ts:effectiveColorFromChain` over whatever background
12
+ * chain the host resolves at the click point — this pure widget has no DOM
13
+ * reach of its own, Rule zero). `value`/`onChange`/`onChangeEnd` are the
14
+ * kit-wide contract (`shared.tsx`); `onClose` is optional (a bare popover
15
+ * caller can omit it and just unmount the picker on outside click itself).
16
+ *
17
+ * D3.d (spec 27 §6) completes that fallback: `onEyedropperPick`, when given,
18
+ * is preferred over the synchronous `eyedropperFallback` and begins an
19
+ * INTERACTIVE canvas-pick session (`../../authoring/eyedropper-session.ts`) —
20
+ * this widget hands the session ITS OWN `applySampledColor` callback and
21
+ * returns immediately; the canvas (`RootSelectionOverlay`) shows a
22
+ * cursor-following swatch and resolves the session on click/Escape, which
23
+ * invokes the callback this widget passed in (or not at all, on cancel).
24
+ * `eyedropperFallback` stays as the synchronous, no-canvas-context fallback
25
+ * (e.g. a widget gallery/story with neither native EyeDropper nor a host that
26
+ * wires the session).
27
+ */
28
+
29
+ import { useCallback, useEffect, useRef, useState } from 'react';
30
+ import {
31
+ type ColorFormat,
32
+ colorToString,
33
+ hexToRgb,
34
+ hslToRgb,
35
+ hsvToRgb,
36
+ parseAlpha,
37
+ rgbToHsv,
38
+ toHex,
39
+ } from './color-utils';
40
+ import { fireChange, fireEnd, THEME } from './shared';
41
+
42
+ const PICKER_W = 200;
43
+ const PICKER_H = 150;
44
+ const HUE_H = 14;
45
+
46
+ export interface ColorPickerProps {
47
+ value: string;
48
+ onChange: (color: string) => void;
49
+ onChangeEnd?: ((color: string) => void) | undefined;
50
+ onClose?: (() => void) | undefined;
51
+ recentColors?: string[] | undefined;
52
+ onAddRecentColor?: ((color: string) => void) | undefined;
53
+ /** Feature-detected native browser eyedropper, with a caller-supplied
54
+ * fallback (see file doc comment) for browsers without the API. */
55
+ eyedropperFallback?: (() => string | null) | undefined;
56
+ /** D3.d — preferred over `eyedropperFallback` when given: begins an
57
+ * interactive canvas-pick session instead of reading one static value (see
58
+ * file doc comment). */
59
+ onEyedropperPick?: ((apply: (color: string | null) => void) => void) | undefined;
60
+ disabled?: boolean | undefined;
61
+ }
62
+
63
+ export function ColorPicker({
64
+ value,
65
+ onChange,
66
+ onChangeEnd,
67
+ onClose,
68
+ recentColors,
69
+ onAddRecentColor,
70
+ eyedropperFallback,
71
+ onEyedropperPick,
72
+ disabled,
73
+ }: ColorPickerProps): React.ReactElement {
74
+ const initialHex = toHex(value);
75
+ const initialRgb = hexToRgb(initialHex);
76
+ const [hsv, setHsv] = useState<[number, number, number]>(() => rgbToHsv(...initialRgb));
77
+ const [alpha, setAlpha] = useState(() => parseAlpha(value));
78
+ const [format, setFormat] = useState<ColorFormat>('hex');
79
+ const [colorInput, setColorInput] = useState(initialHex);
80
+ const containerRef = useRef<HTMLDivElement>(null);
81
+ const satCanvasRef = useRef<HTMLCanvasElement>(null);
82
+ const hueCanvasRef = useRef<HTMLCanvasElement>(null);
83
+ const alphaCanvasRef = useRef<HTMLCanvasElement>(null);
84
+ const draggingRef = useRef<'sat' | 'hue' | 'alpha' | null>(null);
85
+
86
+ const currentColor = useCallback(
87
+ (h = hsv, a = alpha, f = format): string => {
88
+ const [r, g, b] = hsvToRgb(h[0], h[1], h[2]);
89
+ return colorToString(r, g, b, a, f);
90
+ },
91
+ [hsv, alpha, format],
92
+ );
93
+
94
+ useEffect(() => {
95
+ setColorInput(currentColor());
96
+ }, [currentColor]);
97
+
98
+ // Saturation/value canvas.
99
+ useEffect(() => {
100
+ const canvas = satCanvasRef.current;
101
+ const ctx = canvas?.getContext('2d');
102
+ if (!canvas || !ctx) return;
103
+ const [hr, hg, hb] = hsvToRgb(hsv[0], 1, 1);
104
+ const gradH = ctx.createLinearGradient(0, 0, PICKER_W, 0);
105
+ gradH.addColorStop(0, '#ffffff');
106
+ gradH.addColorStop(1, `rgb(${hr},${hg},${hb})`);
107
+ ctx.fillStyle = gradH;
108
+ ctx.fillRect(0, 0, PICKER_W, PICKER_H);
109
+ const gradV = ctx.createLinearGradient(0, 0, 0, PICKER_H);
110
+ gradV.addColorStop(0, 'rgba(0,0,0,0)');
111
+ gradV.addColorStop(1, 'rgba(0,0,0,1)');
112
+ ctx.fillStyle = gradV;
113
+ ctx.fillRect(0, 0, PICKER_W, PICKER_H);
114
+ }, [hsv[0]]);
115
+
116
+ // Hue slider.
117
+ useEffect(() => {
118
+ const canvas = hueCanvasRef.current;
119
+ const ctx = canvas?.getContext('2d');
120
+ if (!canvas || !ctx) return;
121
+ const grad = ctx.createLinearGradient(0, 0, PICKER_W, 0);
122
+ for (let i = 0; i <= 6; i++) {
123
+ const [r, g, b] = hsvToRgb(i / 6, 1, 1);
124
+ grad.addColorStop(i / 6, `rgb(${r},${g},${b})`);
125
+ }
126
+ ctx.fillStyle = grad;
127
+ ctx.fillRect(0, 0, PICKER_W, HUE_H);
128
+ }, []);
129
+
130
+ // Alpha slider.
131
+ useEffect(() => {
132
+ const canvas = alphaCanvasRef.current;
133
+ const ctx = canvas?.getContext('2d');
134
+ if (!canvas || !ctx) return;
135
+ const size = 4;
136
+ for (let y = 0; y < HUE_H; y += size) {
137
+ for (let x = 0; x < PICKER_W; x += size) {
138
+ ctx.fillStyle = (x / size + y / size) % 2 === 0 ? '#ffffff' : '#cccccc';
139
+ ctx.fillRect(x, y, size, size);
140
+ }
141
+ }
142
+ const [r, g, b] = hsvToRgb(hsv[0], hsv[1], hsv[2]);
143
+ const grad = ctx.createLinearGradient(0, 0, PICKER_W, 0);
144
+ grad.addColorStop(0, `rgba(${r},${g},${b},0)`);
145
+ grad.addColorStop(1, `rgba(${r},${g},${b},1)`);
146
+ ctx.fillStyle = grad;
147
+ ctx.fillRect(0, 0, PICKER_W, HUE_H);
148
+ }, [hsv]);
149
+
150
+ useEffect(() => {
151
+ if (!onClose) return;
152
+ function handleKey(e: KeyboardEvent): void {
153
+ if (e.key === 'Escape') onClose?.();
154
+ }
155
+ function handleClick(e: MouseEvent): void {
156
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) onClose?.();
157
+ }
158
+ document.addEventListener('keydown', handleKey);
159
+ document.addEventListener('mousedown', handleClick, true);
160
+ return () => {
161
+ document.removeEventListener('keydown', handleKey);
162
+ document.removeEventListener('mousedown', handleClick, true);
163
+ };
164
+ }, [onClose]);
165
+
166
+ /** sat/hue/alpha canvases all resolve `{hsv?, alpha?}` from a point the
167
+ * SAME way (ratio-of-rect, sat also inverts Y) — one function both
168
+ * `beginDrag` (mousedown, `e.currentTarget`) and the module-level
169
+ * `mousemove`/`mouseup` listener (no `currentTarget`, refs instead) call,
170
+ * keeping each of THOSE under biome's cognitive-complexity ceiling. */
171
+ const applyDragPoint = useCallback(
172
+ (
173
+ which: 'sat' | 'hue' | 'alpha',
174
+ canvas: HTMLCanvasElement | null,
175
+ clientX: number,
176
+ clientY: number,
177
+ ): void => {
178
+ const rect = canvas?.getBoundingClientRect();
179
+ if (!rect) return;
180
+ if (which === 'sat') {
181
+ const next: [number, number, number] = [
182
+ hsv[0],
183
+ ratio(clientX, rect.left, rect.width),
184
+ invRatio(clientY, rect.top, rect.height),
185
+ ];
186
+ setHsv(next);
187
+ fireChange({ onChange }, currentColor(next));
188
+ } else if (which === 'hue') {
189
+ const next: [number, number, number] = [
190
+ ratio(clientX, rect.left, rect.width),
191
+ hsv[1],
192
+ hsv[2],
193
+ ];
194
+ setHsv(next);
195
+ fireChange({ onChange }, currentColor(next));
196
+ } else {
197
+ const a = Math.round(ratio(clientX, rect.left, rect.width) * 100) / 100;
198
+ setAlpha(a);
199
+ fireChange({ onChange }, currentColor(hsv, a));
200
+ }
201
+ },
202
+ [hsv, currentColor, onChange],
203
+ );
204
+
205
+ useEffect(() => {
206
+ function handleMove(e: MouseEvent): void {
207
+ const which = draggingRef.current;
208
+ if (!which) return;
209
+ e.preventDefault();
210
+ const canvas =
211
+ which === 'sat'
212
+ ? satCanvasRef.current
213
+ : which === 'hue'
214
+ ? hueCanvasRef.current
215
+ : alphaCanvasRef.current;
216
+ applyDragPoint(which, canvas, e.clientX, e.clientY);
217
+ }
218
+ function handleUp(): void {
219
+ if (draggingRef.current) {
220
+ draggingRef.current = null;
221
+ const c = currentColor();
222
+ fireEnd({ onChange, onChangeEnd }, c);
223
+ onAddRecentColor?.(c);
224
+ }
225
+ }
226
+ window.addEventListener('mousemove', handleMove);
227
+ window.addEventListener('mouseup', handleUp);
228
+ return () => {
229
+ window.removeEventListener('mousemove', handleMove);
230
+ window.removeEventListener('mouseup', handleUp);
231
+ };
232
+ }, [applyDragPoint, currentColor, onChange, onChangeEnd, onAddRecentColor]);
233
+
234
+ const beginDrag = (
235
+ which: 'sat' | 'hue' | 'alpha',
236
+ e: React.MouseEvent<HTMLCanvasElement>,
237
+ ): void => {
238
+ if (disabled) return;
239
+ e.preventDefault();
240
+ draggingRef.current = which;
241
+ applyDragPoint(which, e.currentTarget, e.clientX, e.clientY);
242
+ };
243
+
244
+ const handleColorInputSubmit = (): void => {
245
+ const val = colorInput.trim();
246
+ const hex = parseHexInput(val);
247
+ const parsed = hex
248
+ ? { r: hex[0], g: hex[1], b: hex[2], a: 1 }
249
+ : (parseRgbaInput(val) ?? parseHslaInput(val));
250
+ if (!parsed) return;
251
+ setHsv(rgbToHsv(parsed.r, parsed.g, parsed.b));
252
+ setAlpha(parsed.a);
253
+ const c = hex ? val : colorToString(parsed.r, parsed.g, parsed.b, parsed.a, format);
254
+ fireChange({ onChange }, c);
255
+ fireEnd({ onChange, onChangeEnd }, c);
256
+ onAddRecentColor?.(c);
257
+ };
258
+
259
+ const cycleFormat = (): void => {
260
+ setFormat((f) => (f === 'hex' ? 'rgb' : f === 'rgb' ? 'hsl' : 'hex'));
261
+ };
262
+
263
+ const canEyedrop = typeof (globalThis as { EyeDropper?: unknown }).EyeDropper === 'function';
264
+
265
+ const applySampledColor = (sampled: string): void => {
266
+ const h = toHex(sampled);
267
+ setHsv(rgbToHsv(...hexToRgb(h)));
268
+ setAlpha(parseAlpha(sampled));
269
+ fireChange({ onChange }, sampled);
270
+ fireEnd({ onChange, onChangeEnd }, sampled);
271
+ };
272
+
273
+ const handleEyedropper = async (): Promise<void> => {
274
+ if (canEyedrop) {
275
+ try {
276
+ const ED = (
277
+ globalThis as unknown as {
278
+ EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> };
279
+ }
280
+ ).EyeDropper;
281
+ const res = await new ED().open();
282
+ applySampledColor(res.sRGBHex);
283
+ } catch {
284
+ /* cancelled */
285
+ }
286
+ return;
287
+ }
288
+ // D3.d — no native EyeDropper: prefer the interactive canvas-pick session
289
+ // when the host wired one; a canvas-less caller (gallery/story) falls
290
+ // back to the synchronous, single-value `eyedropperFallback`.
291
+ if (onEyedropperPick) {
292
+ onEyedropperPick((color) => {
293
+ if (color) applySampledColor(color);
294
+ });
295
+ return;
296
+ }
297
+ const fallback = eyedropperFallback?.();
298
+ if (fallback) applySampledColor(fallback);
299
+ };
300
+
301
+ const [r, g, b] = hsvToRgb(hsv[0], hsv[1], hsv[2]);
302
+ const previewBg = `rgba(${r},${g},${b},${alpha})`;
303
+
304
+ return (
305
+ <EditorPopover
306
+ ref={containerRef}
307
+ data-testid="inspector-color-picker"
308
+ onMouseDown={(e) => e.stopPropagation()}
309
+ style={{
310
+ padding: 10,
311
+ width: PICKER_W + 16,
312
+ boxSizing: 'border-box',
313
+ }}
314
+ >
315
+ <canvas
316
+ ref={satCanvasRef}
317
+ data-testid="color-picker-sv"
318
+ width={PICKER_W}
319
+ height={PICKER_H}
320
+ onMouseDown={(e) => beginDrag('sat', e)}
321
+ style={{ cursor: 'crosshair', borderRadius: THEME.radiusSmall, display: 'block' }}
322
+ />
323
+ <canvas
324
+ ref={hueCanvasRef}
325
+ data-testid="color-picker-hue"
326
+ width={PICKER_W}
327
+ height={HUE_H}
328
+ onMouseDown={(e) => beginDrag('hue', e)}
329
+ style={{
330
+ cursor: 'pointer',
331
+ borderRadius: THEME.radiusSmall,
332
+ display: 'block',
333
+ marginTop: 6,
334
+ }}
335
+ />
336
+ <canvas
337
+ ref={alphaCanvasRef}
338
+ data-testid="color-picker-alpha"
339
+ width={PICKER_W}
340
+ height={HUE_H}
341
+ onMouseDown={(e) => beginDrag('alpha', e)}
342
+ style={{
343
+ cursor: 'pointer',
344
+ borderRadius: THEME.radiusSmall,
345
+ display: 'block',
346
+ marginTop: 6,
347
+ }}
348
+ />
349
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginTop: 8 }}>
350
+ <div
351
+ style={{
352
+ width: 22,
353
+ height: 22,
354
+ borderRadius: THEME.radiusSmall,
355
+ flexShrink: 0,
356
+ border: `1px solid ${THEME.border}`,
357
+ background: previewBg,
358
+ }}
359
+ />
360
+ <TextInput
361
+ type="text"
362
+ data-testid="color-picker-text"
363
+ value={colorInput}
364
+ onChange={(e) => setColorInput(e.target.value)}
365
+ onKeyDown={(e) => {
366
+ if (e.key === 'Enter') handleColorInputSubmit();
367
+ }}
368
+ onBlur={handleColorInputSubmit}
369
+ data-density="compact"
370
+ data-monospace="true"
371
+ style={{ flex: 1 }}
372
+ />
373
+ <Button
374
+ type="button"
375
+ variant="secondary"
376
+ size="compact"
377
+ data-testid="color-format-toggle"
378
+ onClick={cycleFormat}
379
+ title="Cycle color format"
380
+ style={{ flexShrink: 0 }}
381
+ >
382
+ {format.toUpperCase()}
383
+ </Button>
384
+ {(canEyedrop || eyedropperFallback || onEyedropperPick) && (
385
+ <Button
386
+ type="button"
387
+ variant="secondary"
388
+ size="compact"
389
+ data-testid="color-picker-eyedropper"
390
+ onClick={handleEyedropper}
391
+ title="Eyedropper"
392
+ style={{ flexShrink: 0 }}
393
+ >
394
+
395
+ </Button>
396
+ )}
397
+ </div>
398
+ {recentColors && recentColors.length > 0 && (
399
+ <div
400
+ style={{
401
+ display: 'flex',
402
+ flexWrap: 'wrap',
403
+ gap: 3,
404
+ marginTop: 8,
405
+ paddingTop: 6,
406
+ borderTop: `1px solid ${THEME.border}`,
407
+ }}
408
+ >
409
+ {recentColors.map((rc, i) => (
410
+ <div
411
+ key={`${rc}-${i}`}
412
+ data-testid="color-picker-recent"
413
+ onClick={() => {
414
+ const h = toHex(rc);
415
+ setHsv(rgbToHsv(...hexToRgb(h)));
416
+ setAlpha(parseAlpha(rc));
417
+ fireChange({ onChange }, rc);
418
+ fireEnd({ onChange, onChangeEnd }, rc);
419
+ }}
420
+ title={rc}
421
+ style={{
422
+ width: 16,
423
+ height: 16,
424
+ borderRadius: THEME.radiusSmall,
425
+ cursor: 'pointer',
426
+ background: rc,
427
+ border: `1px solid ${THEME.border}`,
428
+ }}
429
+ />
430
+ ))}
431
+ </div>
432
+ )}
433
+ </EditorPopover>
434
+ );
435
+ }
436
+
437
+ /** A compact swatch that opens a `ColorPicker` popover — the row-style usage
438
+ * most inspector sections want (visual-edit's `ColorRow`/`StrokeColorSwatch`,
439
+ * :1425-1512 — folded into one configurable component here since both were
440
+ * the identical swatch+popover shell around different labels). */
441
+ export interface ColorSwatchProps extends ColorPickerProps {
442
+ label?: string | undefined;
443
+ testId?: string | undefined;
444
+ }
445
+
446
+ export function ColorSwatch({
447
+ label,
448
+ value,
449
+ onChange,
450
+ onChangeEnd,
451
+ recentColors,
452
+ onAddRecentColor,
453
+ eyedropperFallback,
454
+ onEyedropperPick,
455
+ disabled,
456
+ testId,
457
+ }: ColorSwatchProps): React.ReactElement {
458
+ const [open, setOpen] = useState(false);
459
+ const hex = toHex(value);
460
+ return (
461
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, position: 'relative' }}>
462
+ <div
463
+ data-testid={testId ? `${testId}-swatch` : 'color-swatch'}
464
+ onClick={() => {
465
+ if (!disabled) setOpen((v) => !v);
466
+ }}
467
+ style={{
468
+ width: 20,
469
+ height: 20,
470
+ borderRadius: THEME.radiusSmall,
471
+ flexShrink: 0,
472
+ background: hex,
473
+ border: `1px solid ${THEME.border}`,
474
+ cursor: disabled ? 'not-allowed' : 'pointer',
475
+ }}
476
+ />
477
+ {label && (
478
+ <span style={{ fontSize: 10, color: THEME.textMuted, flexShrink: 0 }}>{label}</span>
479
+ )}
480
+ {open && !disabled && (
481
+ <div
482
+ style={{
483
+ position: 'absolute',
484
+ zIndex: 'var(--vgai-z-dropdown)',
485
+ top: '100%',
486
+ left: 0,
487
+ marginTop: 4,
488
+ }}
489
+ >
490
+ <ColorPicker
491
+ value={value}
492
+ onChange={onChange}
493
+ onChangeEnd={onChangeEnd}
494
+ onClose={() => setOpen(false)}
495
+ recentColors={recentColors}
496
+ onAddRecentColor={onAddRecentColor}
497
+ eyedropperFallback={eyedropperFallback}
498
+ onEyedropperPick={onEyedropperPick}
499
+ />
500
+ </div>
501
+ )}
502
+ </div>
503
+ );
504
+ }
505
+
506
+ function clamp01(n: number): number {
507
+ return Math.max(0, Math.min(1, n));
508
+ }
509
+
510
+ /** `clientPos` as a 0..1 fraction of `[start, start+size]`. */
511
+ function ratio(clientPos: number, start: number, size: number): number {
512
+ return clamp01(size ? (clientPos - start) / size : 0);
513
+ }
514
+
515
+ /** Same as `ratio`, but Y-inverted — canvas top is `v=1` for the sat/value area. */
516
+ function invRatio(clientPos: number, start: number, size: number): number {
517
+ return clamp01(size ? 1 - (clientPos - start) / size : 0);
518
+ }
519
+
520
+ /** `"#rrggbb"` -> its RGB triple, or `null` if `val` isn't a bare 6-digit hex. */
521
+ function parseHexInput(val: string): [number, number, number] | null {
522
+ return /^#[0-9a-fA-F]{6}$/.test(val) ? hexToRgb(val) : null;
523
+ }
524
+
525
+ /** `"rgb(a)(r, g, b[, a])"` -> `{r,g,b,a}`, or `null` if it doesn't match. */
526
+ function parseRgbaInput(val: string): { r: number; g: number; b: number; a: number } | null {
527
+ const m = val.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
528
+ if (!m) return null;
529
+ const [, rs, gs, bs, as] = m;
530
+ return {
531
+ r: Number.parseInt(rs ?? '0', 10),
532
+ g: Number.parseInt(gs ?? '0', 10),
533
+ b: Number.parseInt(bs ?? '0', 10),
534
+ a: as ? Number.parseFloat(as) : 1,
535
+ };
536
+ }
537
+
538
+ /** `"hsl(a)(h, s%, l%[, a])"` -> `{r,g,b,a}` (converted to RGB), or `null`. */
539
+ function parseHslaInput(val: string): { r: number; g: number; b: number; a: number } | null {
540
+ const m = val.match(/hsla?\((\d+),\s*(\d+)%?,\s*(\d+)%?(?:,\s*([\d.]+))?\)/);
541
+ if (!m) return null;
542
+ const [, hs, ss, ls, as] = m;
543
+ const [r, g, b] = hslToRgb(
544
+ Number.parseInt(hs ?? '0', 10) / 360,
545
+ Number.parseInt(ss ?? '0', 10) / 100,
546
+ Number.parseInt(ls ?? '0', 10) / 100,
547
+ );
548
+ return { r, g, b, a: as ? Number.parseFloat(as) : 1 };
549
+ }