@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,180 @@
1
+ import { TextInput } from '../design-system';
2
+ /**
3
+ * ScrubbableInput — a numeric input whose LABEL is a drag-to-scrub handle
4
+ * (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s `ScrubbableInput`
5
+ * (:107-219): drag the label left/right to change the number, Shift = ×10,
6
+ * Alt = ×0.1, Escape cancels back to the drag-start value. Unlike the
7
+ * reference (which threaded a `prop` string + string value through a
8
+ * shared style-bag callback), this widget's contract is the kit-wide pure
9
+ * `value: number` / `onChange` / `onChangeEnd` (see `shared.tsx`'s doc
10
+ * comment) — `unit` is display-only (appended to the rendered text, stripped
11
+ * on parse), keeping the value itself a plain number for callers to compose
12
+ * into whatever CSS string they write.
13
+ */
14
+
15
+ import { type ReactNode, useMemo, useRef, useState } from 'react';
16
+ import { type ChangeHandlers, fireChange, fireEnd, rowStyle, THEME } from './shared';
17
+
18
+ export interface ScrubbableInputProps extends ChangeHandlers<number> {
19
+ label: ReactNode;
20
+ unit?: string | undefined;
21
+ step?: number | undefined;
22
+ min?: number | undefined;
23
+ max?: number | undefined;
24
+ precision?: number | undefined;
25
+ testId?: string | undefined;
26
+ style?: React.CSSProperties | undefined;
27
+ }
28
+
29
+ export function ScrubbableInput({
30
+ label,
31
+ value,
32
+ unit = '',
33
+ step = 1,
34
+ min,
35
+ max,
36
+ precision,
37
+ disabled,
38
+ onChange,
39
+ onChangeEnd,
40
+ testId,
41
+ style: extraStyle,
42
+ }: ScrubbableInputProps): React.ReactElement {
43
+ const dragRef = useRef<{ startX: number; startValue: number; last: number } | null>(null);
44
+
45
+ const effectivePrecision = precision ?? (step < 1 ? 2 : step >= 100 ? -2 : 0);
46
+
47
+ const round = useMemo(
48
+ () =>
49
+ (n: number): number => {
50
+ if (effectivePrecision < 0) {
51
+ const factor = 10 ** -effectivePrecision;
52
+ return Math.round(n / factor) * factor;
53
+ }
54
+ const factor = 10 ** effectivePrecision;
55
+ return Math.round(n * factor) / factor;
56
+ },
57
+ [effectivePrecision],
58
+ );
59
+
60
+ const clamp = (n: number): number => {
61
+ let v = n;
62
+ if (min !== undefined) v = Math.max(min, v);
63
+ if (max !== undefined) v = Math.min(max, v);
64
+ return round(v);
65
+ };
66
+
67
+ const handleScrubStart = (e: React.MouseEvent): void => {
68
+ if (disabled) return;
69
+ e.preventDefault();
70
+ dragRef.current = { startX: e.clientX, startValue: value, last: value };
71
+ document.body.style.cursor = 'ew-resize';
72
+
73
+ const handleMove = (me: MouseEvent): void => {
74
+ if (!dragRef.current) return;
75
+ me.preventDefault();
76
+ const delta = me.clientX - dragRef.current.startX;
77
+ const multiplier = me.shiftKey ? 10 : me.altKey ? 0.1 : 1;
78
+ const next = clamp(dragRef.current.startValue + delta * step * multiplier);
79
+ dragRef.current.last = next;
80
+ fireChange({ onChange }, next);
81
+ };
82
+
83
+ const cleanup = (): void => {
84
+ document.body.style.cursor = '';
85
+ window.removeEventListener('mousemove', handleMove);
86
+ window.removeEventListener('mouseup', handleUp);
87
+ window.removeEventListener('keydown', handleKeyDuringScrub);
88
+ };
89
+
90
+ const handleUp = (): void => {
91
+ if (dragRef.current) {
92
+ const final = dragRef.current.last;
93
+ dragRef.current = null;
94
+ fireEnd({ onChange, onChangeEnd }, final);
95
+ }
96
+ cleanup();
97
+ };
98
+
99
+ const handleKeyDuringScrub = (ke: KeyboardEvent): void => {
100
+ if (ke.key === 'Escape' && dragRef.current) {
101
+ const original = clamp(dragRef.current.startValue);
102
+ dragRef.current = null;
103
+ fireChange({ onChange }, original);
104
+ fireEnd({ onChange, onChangeEnd }, original);
105
+ cleanup();
106
+ }
107
+ };
108
+
109
+ window.addEventListener('mousemove', handleMove);
110
+ window.addEventListener('mouseup', handleUp);
111
+ window.addEventListener('keydown', handleKeyDuringScrub);
112
+ };
113
+
114
+ // TYPING IS A DRAFT, COMMITTED ONCE. The text field used to be controlled
115
+ // by the live value and fire `onChange` per keystroke, so the first digit
116
+ // was WRITTEN, the write re-projected the board, the field re-rendered
117
+ // from the new value (and lost focus with it) and every later
118
+ // digit went nowhere — typing 257 into a width left it at 2 (measured on
119
+ // production build 59; runhuman pass 135: "it changed to 237.3"). While
120
+ // focused the field shows its own draft, the whole value selected on
121
+ // entry; Enter or blur commits, Escape cancels. Scrubbing the label is
122
+ // unchanged — that path IS live by design.
123
+ const [draft, setDraft] = useState<string | null>(null);
124
+ const cancelled = useRef(false);
125
+ const commitText = (raw: string): void => {
126
+ const n = Number.parseFloat(raw);
127
+ if (Number.isNaN(n)) return;
128
+ const next = clamp(n);
129
+ if (next === value) return;
130
+ fireEnd({ onChange, onChangeEnd }, next);
131
+ };
132
+
133
+ return (
134
+ <div style={{ ...rowStyle, ...extraStyle }}>
135
+ <span
136
+ className="vgai-scrub-label"
137
+ data-testid={testId ? `${testId}-label` : undefined}
138
+ onMouseDown={handleScrubStart}
139
+ data-disabled={disabled || undefined}
140
+ style={{
141
+ fontSize: 11,
142
+ // Resting/hover color comes from `.vgai-scrub-label` in theme.css —
143
+ // an inline color here forced the hover rule into `!important`.
144
+ ...(disabled ? { color: THEME.dynamic } : {}),
145
+ flexShrink: 0,
146
+ cursor: disabled ? 'not-allowed' : 'ew-resize',
147
+ userSelect: 'none',
148
+ fontWeight: 500,
149
+ }}
150
+ >
151
+ {label}
152
+ </span>
153
+ <TextInput
154
+ data-testid={testId}
155
+ type="text"
156
+ value={draft ?? `${value}${unit}`}
157
+ readOnly={disabled}
158
+ data-dynamic={disabled || undefined}
159
+ onFocus={(e) => {
160
+ setDraft(`${value}${unit}`);
161
+ e.target.select();
162
+ }}
163
+ onChange={(e) => setDraft(e.target.value)}
164
+ onBlur={(e) => {
165
+ if (!cancelled.current) commitText(e.target.value);
166
+ cancelled.current = false;
167
+ setDraft(null);
168
+ }}
169
+ onKeyDown={(e) => {
170
+ if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
171
+ if (e.key === 'Escape') {
172
+ cancelled.current = true;
173
+ (e.target as HTMLInputElement).blur();
174
+ }
175
+ }}
176
+ style={{ flex: 1, width: 0 }}
177
+ />
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,319 @@
1
+ import { Button, Checkbox } from '../design-system';
2
+ /**
3
+ * ShadowEditor / TextShadowEditor — a list of box/text shadows (add/remove,
4
+ * x/y/blur/(spread)/color, inset for box-shadow) (spec 27 §5 C1). Ported from
5
+ * `visual-edit/inspector.tsx`'s `ShadowEditor`/`ShadowRow` (:632-769) and
6
+ * `TextShadowEditor`/`TextShadowRow` (:1622-1788) — folded into one generic
7
+ * `ShadowListEditor` since the two are the same list-of-rows shape with
8
+ * `spread`/`inset` present only for box-shadow.
9
+ */
10
+
11
+ import { ColorSwatch } from './ColorPicker';
12
+ import { rgbStringToHex } from './color-utils';
13
+ import { ScrubbableInput } from './ScrubbableInput';
14
+ import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
15
+
16
+ export interface ShadowValue {
17
+ x: number;
18
+ y: number;
19
+ blur: number;
20
+ spread: number;
21
+ color: string;
22
+ inset: boolean;
23
+ enabled: boolean;
24
+ }
25
+
26
+ export const DEFAULT_SHADOW: ShadowValue = {
27
+ x: 0,
28
+ y: 4,
29
+ blur: 8,
30
+ spread: 0,
31
+ color: 'rgba(0,0,0,0.25)',
32
+ inset: false,
33
+ enabled: true,
34
+ };
35
+
36
+ export const DEFAULT_TEXT_SHADOW: ShadowValue = {
37
+ x: 0,
38
+ y: 2,
39
+ blur: 4,
40
+ spread: 0,
41
+ color: 'rgba(0,0,0,0.3)',
42
+ inset: false,
43
+ enabled: true,
44
+ };
45
+
46
+ function composeOne(s: ShadowValue, withSpreadInset: boolean): string {
47
+ const parts = withSpreadInset
48
+ ? [s.inset ? 'inset' : '', `${s.x}px`, `${s.y}px`, `${s.blur}px`, `${s.spread}px`, s.color]
49
+ : [`${s.x}px`, `${s.y}px`, `${s.blur}px`, s.color];
50
+ return parts.filter(Boolean).join(' ');
51
+ }
52
+
53
+ /** `boxShadow`/`textShadow` CSS string <-> the widget's `ShadowValue[]`. */
54
+ export function composeShadows(shadows: ShadowValue[], withSpreadInset = true): string {
55
+ const enabled = shadows.filter((s) => s.enabled);
56
+ if (enabled.length === 0) return 'none';
57
+ return enabled.map((s) => composeOne(s, withSpreadInset)).join(', ');
58
+ }
59
+
60
+ /** Pulls the color token (`rgba(...)`/hex) out of a shadow's numeric-part
61
+ * tail -> `{color, nums}` (nums = whatever's left to parse as x/y/blur/
62
+ * spread). Split out of `parseOne` to keep it under biome's
63
+ * cognitive-complexity ceiling. */
64
+ function extractShadowColor(rest: string): { color: string; nums: string } {
65
+ const rgbMatch = rest.match(/(rgba?\([^)]+\))/);
66
+ if (rgbMatch?.[1]) return { color: rgbMatch[1], nums: rest.replace(rgbMatch[1], '').trim() };
67
+ const hexMatch = rest.match(/(#[0-9a-fA-F]{3,8})/);
68
+ if (hexMatch?.[1]) return { color: hexMatch[1], nums: rest.replace(hexMatch[1], '').trim() };
69
+ return { color: '#000000', nums: rest };
70
+ }
71
+
72
+ function parseOne(value: string, withSpreadInset: boolean): Omit<ShadowValue, 'enabled'> | null {
73
+ if (!value || value === 'none') return null;
74
+ const v = value.trim();
75
+ const inset = withSpreadInset && v.startsWith('inset');
76
+ const rest = inset ? v.slice(5).trim() : v;
77
+ const { color, nums } = extractShadowColor(rest);
78
+ const parts = nums
79
+ .split(/\s+/)
80
+ .map((s) => Number.parseFloat(s))
81
+ .filter((n) => !Number.isNaN(n));
82
+ if (withSpreadInset) {
83
+ return {
84
+ inset,
85
+ x: parts[0] ?? 0,
86
+ y: parts[1] ?? 0,
87
+ blur: parts[2] ?? 0,
88
+ spread: parts[3] ?? 0,
89
+ color,
90
+ };
91
+ }
92
+ if (parts.length < 2) return null;
93
+ return {
94
+ inset: false,
95
+ x: parts[0] ?? 0,
96
+ y: parts[1] ?? 0,
97
+ blur: parts[2] ?? 0,
98
+ spread: 0,
99
+ color,
100
+ };
101
+ }
102
+
103
+ export function parseShadows(value: string, withSpreadInset = true): ShadowValue[] {
104
+ if (!value || value === 'none') return [];
105
+ const parts = value.split(/,\s*(?![^(]*\))/);
106
+ const result: ShadowValue[] = [];
107
+ for (const part of parts) {
108
+ const p = parseOne(part.trim(), withSpreadInset);
109
+ if (p) result.push({ ...p, enabled: true });
110
+ }
111
+ return result;
112
+ }
113
+
114
+ export interface ShadowListEditorProps extends ChangeHandlers<ShadowValue[]> {
115
+ /** `false` for text-shadow: hides the spread/inset controls. */
116
+ withSpreadInset?: boolean | undefined;
117
+ label?: string | undefined;
118
+ testId?: string | undefined;
119
+ recentColors?: string[] | undefined;
120
+ onAddRecentColor?: ((color: string) => void) | undefined;
121
+ }
122
+
123
+ export function ShadowListEditor({
124
+ value,
125
+ onChange,
126
+ onChangeEnd,
127
+ disabled,
128
+ withSpreadInset = true,
129
+ label = 'Shadow',
130
+ testId = 'shadow-editor',
131
+ recentColors,
132
+ onAddRecentColor,
133
+ }: ShadowListEditorProps): React.ReactElement {
134
+ const apply = (next: ShadowValue[], commit: boolean): void => {
135
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
136
+ else fireChange({ onChange }, next);
137
+ };
138
+
139
+ const update = (index: number, partial: Partial<ShadowValue>, commit = false): void => {
140
+ apply(
141
+ value.map((s, i) => (i === index ? { ...s, ...partial } : s)),
142
+ commit,
143
+ );
144
+ };
145
+
146
+ const remove = (index: number): void => {
147
+ apply(
148
+ value.filter((_, i) => i !== index),
149
+ true,
150
+ );
151
+ };
152
+
153
+ const add = (): void => {
154
+ apply([...value, { ...(withSpreadInset ? DEFAULT_SHADOW : DEFAULT_TEXT_SHADOW) }], true);
155
+ };
156
+
157
+ return (
158
+ <div data-testid={testId}>
159
+ <div
160
+ style={{
161
+ display: 'flex',
162
+ alignItems: 'center',
163
+ gap: 6,
164
+ marginBottom: value.length > 0 ? 4 : 0,
165
+ }}
166
+ >
167
+ <span style={{ fontSize: 9, color: THEME.textMuted }}>{label}</span>
168
+ <div style={{ flex: 1 }} />
169
+ {!disabled && (
170
+ <Button
171
+ type="button"
172
+ variant="ghost"
173
+ size="compact"
174
+ data-testid={`${testId}-add`}
175
+ onClick={add}
176
+ >
177
+ +
178
+ </Button>
179
+ )}
180
+ </div>
181
+ {value.map((shadow, i) => {
182
+ const hexColor = shadow.color.startsWith('#') ? shadow.color : rgbStringToHex(shadow.color);
183
+ return (
184
+ <div
185
+ key={i}
186
+ data-testid={`${testId}-row-${i}`}
187
+ style={{ marginBottom: 6, padding: '4px 0', borderBottom: `1px solid ${THEME.border}` }}
188
+ >
189
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
190
+ <Button
191
+ type="button"
192
+ variant="ghost"
193
+ size="compact"
194
+ aria-pressed={shadow.enabled}
195
+ data-testid={`${testId}-toggle-${i}`}
196
+ onClick={() => {
197
+ if (!disabled) update(i, { enabled: !shadow.enabled }, true);
198
+ }}
199
+ >
200
+ {shadow.enabled ? '●' : '○'}
201
+ </Button>
202
+ <ColorSwatch
203
+ testId={`${testId}-color-${i}`}
204
+ value={hexColor}
205
+ disabled={disabled}
206
+ onChange={(c) => update(i, { color: c })}
207
+ onChangeEnd={(c) => update(i, { color: c }, true)}
208
+ recentColors={recentColors}
209
+ onAddRecentColor={onAddRecentColor}
210
+ />
211
+ {withSpreadInset && (
212
+ <label
213
+ style={{
214
+ display: 'flex',
215
+ alignItems: 'center',
216
+ gap: 2,
217
+ fontSize: 8,
218
+ color: THEME.textMuted,
219
+ cursor: 'pointer',
220
+ }}
221
+ >
222
+ <Checkbox
223
+ // Standard checkbox size via the shared class (I-30: this
224
+ // was the one checkbox in the codebase with its own 10px
225
+ // explicit dimensions).
226
+ className="vgai-checkbox"
227
+ data-testid={`${testId}-inset-${i}`}
228
+ checked={shadow.inset}
229
+ disabled={disabled}
230
+ onChange={(e) => update(i, { inset: e.target.checked }, true)}
231
+ />
232
+ in
233
+ </label>
234
+ )}
235
+ <div style={{ flex: 1 }} />
236
+ {!disabled && (
237
+ <Button
238
+ type="button"
239
+ variant="ghost"
240
+ size="compact"
241
+ aria-label={`Remove ${label.toLowerCase()} ${i + 1}`}
242
+ data-testid={`${testId}-remove-${i}`}
243
+ onClick={() => remove(i)}
244
+ >
245
+ ×
246
+ </Button>
247
+ )}
248
+ </div>
249
+ {shadow.enabled && (
250
+ <div style={{ display: 'flex', gap: 4 }}>
251
+ <ScrubbableInput
252
+ label="X"
253
+ value={shadow.x}
254
+ disabled={disabled}
255
+ onChange={(v) => update(i, { x: v })}
256
+ onChangeEnd={(v) => update(i, { x: v }, true)}
257
+ style={{ flex: 1 }}
258
+ />
259
+ <ScrubbableInput
260
+ label="Y"
261
+ value={shadow.y}
262
+ disabled={disabled}
263
+ onChange={(v) => update(i, { y: v })}
264
+ onChangeEnd={(v) => update(i, { y: v }, true)}
265
+ style={{ flex: 1 }}
266
+ />
267
+ <ScrubbableInput
268
+ label="B"
269
+ value={shadow.blur}
270
+ min={0}
271
+ disabled={disabled}
272
+ onChange={(v) => update(i, { blur: Math.max(0, v) })}
273
+ onChangeEnd={(v) => update(i, { blur: Math.max(0, v) }, true)}
274
+ style={{ flex: 1 }}
275
+ />
276
+ {withSpreadInset && (
277
+ <ScrubbableInput
278
+ label="S"
279
+ value={shadow.spread}
280
+ disabled={disabled}
281
+ onChange={(v) => update(i, { spread: v })}
282
+ onChangeEnd={(v) => update(i, { spread: v }, true)}
283
+ style={{ flex: 1 }}
284
+ />
285
+ )}
286
+ </div>
287
+ )}
288
+ </div>
289
+ );
290
+ })}
291
+ </div>
292
+ );
293
+ }
294
+
295
+ export function ShadowEditor(
296
+ props: Omit<ShadowListEditorProps, 'withSpreadInset' | 'testId'>,
297
+ ): React.ReactElement {
298
+ return (
299
+ <ShadowListEditor
300
+ {...props}
301
+ withSpreadInset
302
+ testId="box-shadow-editor"
303
+ label={props.label ?? 'Shadow'}
304
+ />
305
+ );
306
+ }
307
+
308
+ export function TextShadowEditor(
309
+ props: Omit<ShadowListEditorProps, 'withSpreadInset' | 'testId'>,
310
+ ): React.ReactElement {
311
+ return (
312
+ <ShadowListEditor
313
+ {...props}
314
+ withSpreadInset={false}
315
+ testId="text-shadow-editor"
316
+ label={props.label ?? 'Text Shadow'}
317
+ />
318
+ );
319
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Color math for the inspector widget kit (spec 27 §5 C1). Ported from
3
+ * `visual-edit/inspector.tsx`'s free-floating hex/rgb/hsv/hsl helpers
4
+ * (993-1035:2781-2855) — pure functions, zero deps, zero DOM. Colour-CHAIN
5
+ * resolution (the eyedropper's "what's the effective background here" logic)
6
+ * is NOT re-implemented here — that's `effectiveColorFromChain` in
7
+ * `ui-source/inspect.ts`, reused as-is by `ColorPicker.tsx`.
8
+ */
9
+
10
+ export type ColorFormat = 'hex' | 'rgb' | 'hsl';
11
+
12
+ export function hexToRgb(hex: string): [number, number, number] {
13
+ let h = hex.replace('#', '');
14
+ if (h.length === 3)
15
+ h = `${h[0] ?? '0'}${h[0] ?? '0'}${h[1] ?? '0'}${h[1] ?? '0'}${h[2] ?? '0'}${h[2] ?? '0'}`;
16
+ const n = Number.parseInt(h, 16);
17
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
18
+ }
19
+
20
+ export function rgbToHex(r: number, g: number, b: number): string {
21
+ return `#${[r, g, b]
22
+ .map((c) =>
23
+ Math.round(Math.max(0, Math.min(255, c)))
24
+ .toString(16)
25
+ .padStart(2, '0'),
26
+ )
27
+ .join('')}`;
28
+ }
29
+
30
+ export function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
31
+ const rn = r / 255;
32
+ const gn = g / 255;
33
+ const bn = b / 255;
34
+ const max = Math.max(rn, gn, bn);
35
+ const min = Math.min(rn, gn, bn);
36
+ const d = max - min;
37
+ let h = 0;
38
+ if (d !== 0) {
39
+ if (max === rn) h = ((((gn - bn) / d) % 6) + 6) % 6;
40
+ else if (max === gn) h = (bn - rn) / d + 2;
41
+ else h = (rn - gn) / d + 4;
42
+ h /= 6;
43
+ }
44
+ const s = max === 0 ? 0 : d / max;
45
+ return [h, s, max];
46
+ }
47
+
48
+ export function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
49
+ const i = Math.floor(h * 6);
50
+ const f = h * 6 - i;
51
+ const p = v * (1 - s);
52
+ const q = v * (1 - f * s);
53
+ const t = v * (1 - (1 - f) * s);
54
+ let r = 0;
55
+ let g = 0;
56
+ let b = 0;
57
+ switch (i % 6) {
58
+ case 0:
59
+ r = v;
60
+ g = t;
61
+ b = p;
62
+ break;
63
+ case 1:
64
+ r = q;
65
+ g = v;
66
+ b = p;
67
+ break;
68
+ case 2:
69
+ r = p;
70
+ g = v;
71
+ b = t;
72
+ break;
73
+ case 3:
74
+ r = p;
75
+ g = q;
76
+ b = v;
77
+ break;
78
+ case 4:
79
+ r = t;
80
+ g = p;
81
+ b = v;
82
+ break;
83
+ case 5:
84
+ r = v;
85
+ g = p;
86
+ b = q;
87
+ break;
88
+ default:
89
+ break;
90
+ }
91
+ return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
92
+ }
93
+
94
+ export function hsvToHex(h: number, s: number, v: number): string {
95
+ const [r, g, b] = hsvToRgb(h, s, v);
96
+ return rgbToHex(r, g, b);
97
+ }
98
+
99
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
100
+ let r: number;
101
+ let g: number;
102
+ let b: number;
103
+ if (s === 0) {
104
+ r = g = b = l;
105
+ } else {
106
+ const hue2rgb = (p: number, q: number, t0: number): number => {
107
+ let t = t0;
108
+ if (t < 0) t += 1;
109
+ if (t > 1) t -= 1;
110
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
111
+ if (t < 1 / 2) return q;
112
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
113
+ return p;
114
+ };
115
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
116
+ const p = 2 * l - q;
117
+ r = hue2rgb(p, q, h + 1 / 3);
118
+ g = hue2rgb(p, q, h);
119
+ b = hue2rgb(p, q, h - 1 / 3);
120
+ }
121
+ return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
122
+ }
123
+
124
+ export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
125
+ const rn = r / 255;
126
+ const gn = g / 255;
127
+ const bn = b / 255;
128
+ const max = Math.max(rn, gn, bn);
129
+ const min = Math.min(rn, gn, bn);
130
+ let h = 0;
131
+ let s = 0;
132
+ const l = (max + min) / 2;
133
+ if (max !== min) {
134
+ const d = max - min;
135
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
136
+ if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
137
+ else if (max === gn) h = ((bn - rn) / d + 2) / 6;
138
+ else h = ((rn - gn) / d + 4) / 6;
139
+ }
140
+ return [h, s, l];
141
+ }
142
+
143
+ /** Normalize any hex/rgb(a)/hsl(a) string to a 6-digit hex (alpha dropped). */
144
+ export function toHex(color: string): string {
145
+ if (color.startsWith('#') && (color.length === 4 || color.length === 7)) return color;
146
+ if (color.startsWith('#') && color.length === 9) return color.slice(0, 7); // strip alpha from #RRGGBBAA
147
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
148
+ if (m) {
149
+ const [, r, g, b] = m;
150
+ return `#${[r ?? '0', g ?? '0', b ?? '0'].map((c) => Number.parseInt(c, 10).toString(16).padStart(2, '0')).join('')}`;
151
+ }
152
+ const hsl = color.match(/hsla?\((\d+),\s*(\d+)%?,\s*(\d+)%?/);
153
+ if (hsl) {
154
+ const [r, g, b] = hslToRgb(
155
+ Number.parseInt(hsl[1] ?? '0', 10) / 360,
156
+ Number.parseInt(hsl[2] ?? '0', 10) / 100,
157
+ Number.parseInt(hsl[3] ?? '0', 10) / 100,
158
+ );
159
+ return rgbToHex(r, g, b);
160
+ }
161
+ return '#000000';
162
+ }
163
+
164
+ export function parseAlpha(color: string): number {
165
+ const rgbaMatch = color.match(/rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
166
+ if (rgbaMatch?.[1]) return Number.parseFloat(rgbaMatch[1]);
167
+ const hslaMatch = color.match(/hsla\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
168
+ if (hslaMatch?.[1]) return Number.parseFloat(hslaMatch[1]);
169
+ if (color.startsWith('#') && color.length === 9)
170
+ return Number.parseInt(color.slice(7, 9), 16) / 255;
171
+ return 1;
172
+ }
173
+
174
+ export function colorToString(
175
+ r: number,
176
+ g: number,
177
+ b: number,
178
+ a: number,
179
+ format: ColorFormat,
180
+ ): string {
181
+ if (format === 'hex') {
182
+ const hex = rgbToHex(r, g, b);
183
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})` : hex;
184
+ }
185
+ if (format === 'rgb') {
186
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})` : `rgb(${r}, ${g}, ${b})`;
187
+ }
188
+ const [h, s, l] = rgbToHsl(r, g, b);
189
+ const hDeg = Math.round(h * 360);
190
+ const sPct = Math.round(s * 100);
191
+ const lPct = Math.round(l * 100);
192
+ return a < 1
193
+ ? `hsla(${hDeg}, ${sPct}%, ${lPct}%, ${a.toFixed(2)})`
194
+ : `hsl(${hDeg}, ${sPct}%, ${lPct}%)`;
195
+ }
196
+
197
+ export function rgbStringToHex(rgb: string): string {
198
+ const m = rgb.match(/\d+/g);
199
+ if (!m || m.length < 3) return '#000000';
200
+ return `#${[m[0] ?? '0', m[1] ?? '0', m[2] ?? '0'].map((n) => Number.parseInt(n, 10).toString(16).padStart(2, '0')).join('')}`;
201
+ }