@agent-native/toolkit 0.6.0 → 0.7.0

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 (173) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/package.json +17 -1
  16. package/src/app-shell/header-actions.tsx +84 -0
  17. package/src/app-shell/index.ts +1 -0
  18. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  19. package/src/chat-history/ChatHistoryList.tsx +469 -0
  20. package/src/chat-history/index.ts +7 -0
  21. package/src/chat-history.css +314 -0
  22. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  23. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  24. package/src/collab-ui/PresenceBar.tsx +346 -0
  25. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  26. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  27. package/src/collab-ui/agent-identity.ts +16 -0
  28. package/src/collab-ui/index.ts +39 -0
  29. package/src/collab-ui/lead-client.ts +32 -0
  30. package/src/collab-ui/types.spec.ts +18 -0
  31. package/src/collab-ui/types.ts +96 -0
  32. package/src/composer/AgentComposerFrame.tsx +59 -0
  33. package/src/composer/ComposerPlusMenu.tsx +983 -0
  34. package/src/composer/MentionPopover.tsx +412 -0
  35. package/src/composer/PastedTextChip.tsx +150 -0
  36. package/src/composer/PromptComposer.spec.ts +73 -0
  37. package/src/composer/PromptComposer.tsx +718 -0
  38. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  39. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  40. package/src/composer/TiptapComposer.spec.ts +390 -0
  41. package/src/composer/TiptapComposer.tsx +2936 -0
  42. package/src/composer/VoiceButton.spec.ts +33 -0
  43. package/src/composer/VoiceButton.tsx +255 -0
  44. package/src/composer/asset-picker-url.spec.ts +56 -0
  45. package/src/composer/asset-picker-url.ts +80 -0
  46. package/src/composer/attachment-accept.spec.ts +22 -0
  47. package/src/composer/attachment-accept.ts +71 -0
  48. package/src/composer/draft-key.spec.ts +19 -0
  49. package/src/composer/draft-key.ts +7 -0
  50. package/src/composer/extensions/FileReference.tsx +55 -0
  51. package/src/composer/extensions/MentionReference.tsx +93 -0
  52. package/src/composer/extensions/SkillReference.tsx +56 -0
  53. package/src/composer/index.ts +79 -0
  54. package/src/composer/pasted-text.spec.ts +162 -0
  55. package/src/composer/pasted-text.ts +160 -0
  56. package/src/composer/prompt-attachments.spec.ts +80 -0
  57. package/src/composer/prompt-attachments.ts +93 -0
  58. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  59. package/src/composer/realtime-voice-audio-level.ts +64 -0
  60. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  61. package/src/composer/realtime-voice-transcript.ts +134 -0
  62. package/src/composer/runtime-adapters.tsx +343 -0
  63. package/src/composer/types.ts +66 -0
  64. package/src/composer/use-file-search.ts +64 -0
  65. package/src/composer/use-mention-search.ts +90 -0
  66. package/src/composer/use-skills.ts +45 -0
  67. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  68. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  69. package/src/composer/useVoiceDictation.spec.ts +45 -0
  70. package/src/composer/useVoiceDictation.ts +1191 -0
  71. package/src/context-ui/ContextMeter.tsx +101 -0
  72. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  73. package/src/context-ui/ContextTreemap.tsx +125 -0
  74. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  75. package/src/context-ui/format.ts +42 -0
  76. package/src/context-ui/index.ts +15 -0
  77. package/src/context-ui/types.ts +43 -0
  78. package/src/design-tweaks/index.ts +1 -0
  79. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  80. package/src/editor/BubbleToolbar.tsx +253 -0
  81. package/src/editor/CodeBlockNode.tsx +254 -0
  82. package/src/editor/DragHandle.spec.ts +588 -0
  83. package/src/editor/DragHandle.ts +1437 -0
  84. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  85. package/src/editor/ImageExtension.ts +306 -0
  86. package/src/editor/RegistryBlockContext.tsx +107 -0
  87. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  88. package/src/editor/RegistryBlockNode.tsx +652 -0
  89. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  90. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  91. package/src/editor/RichMarkdownEditor.tsx +81 -0
  92. package/src/editor/RunId.ts +61 -0
  93. package/src/editor/SharedRichEditor.tsx +321 -0
  94. package/src/editor/SlashCommandMenu.tsx +310 -0
  95. package/src/editor/disableHistory.spec.ts +85 -0
  96. package/src/editor/extensions.ts +402 -0
  97. package/src/editor/gfmDoc.spec.ts +230 -0
  98. package/src/editor/gfmDoc.ts +92 -0
  99. package/src/editor/index.ts +93 -0
  100. package/src/editor/registrySlashCommands.ts +131 -0
  101. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  102. package/src/editor/surgical-apply.spec.ts +279 -0
  103. package/src/editor/surgical-apply.ts +192 -0
  104. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  105. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  106. package/src/editor/useCollabReconcile.ts +589 -0
  107. package/src/editor.css +510 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/use-mobile.tsx +21 -0
  110. package/src/hooks/use-toast.ts +188 -0
  111. package/src/index.ts +8 -0
  112. package/src/onboarding/index.ts +1 -0
  113. package/src/provider.tsx +47 -0
  114. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  115. package/src/sharing/VisibilityBadge.tsx +37 -0
  116. package/src/sharing/index.ts +4 -0
  117. package/src/styles.css +21 -0
  118. package/src/ui/accordion.tsx +56 -0
  119. package/src/ui/alert-dialog.tsx +141 -0
  120. package/src/ui/alert.tsx +59 -0
  121. package/src/ui/aspect-ratio.tsx +5 -0
  122. package/src/ui/avatar.tsx +48 -0
  123. package/src/ui/badge.tsx +37 -0
  124. package/src/ui/breadcrumb.tsx +115 -0
  125. package/src/ui/button.tsx +78 -0
  126. package/src/ui/calendar.tsx +214 -0
  127. package/src/ui/card.tsx +86 -0
  128. package/src/ui/carousel.tsx +260 -0
  129. package/src/ui/chart.tsx +375 -0
  130. package/src/ui/checkbox.tsx +28 -0
  131. package/src/ui/collapsible.tsx +9 -0
  132. package/src/ui/command.spec.tsx +51 -0
  133. package/src/ui/command.tsx +174 -0
  134. package/src/ui/context-menu.tsx +202 -0
  135. package/src/ui/date-picker.tsx +64 -0
  136. package/src/ui/dialog.spec.tsx +43 -0
  137. package/src/ui/dialog.tsx +146 -0
  138. package/src/ui/drawer.tsx +126 -0
  139. package/src/ui/dropdown-menu.tsx +218 -0
  140. package/src/ui/form.tsx +177 -0
  141. package/src/ui/hover-card.tsx +27 -0
  142. package/src/ui/index.ts +52 -0
  143. package/src/ui/input-otp.tsx +69 -0
  144. package/src/ui/input.tsx +22 -0
  145. package/src/ui/label.tsx +24 -0
  146. package/src/ui/logo.tsx +36 -0
  147. package/src/ui/menubar.tsx +235 -0
  148. package/src/ui/navigation-menu.tsx +128 -0
  149. package/src/ui/pagination.tsx +121 -0
  150. package/src/ui/popover.tsx +44 -0
  151. package/src/ui/progress.tsx +26 -0
  152. package/src/ui/radio-group.tsx +42 -0
  153. package/src/ui/resizable.tsx +43 -0
  154. package/src/ui/scroll-area.tsx +46 -0
  155. package/src/ui/select.tsx +158 -0
  156. package/src/ui/separator.tsx +29 -0
  157. package/src/ui/sheet.tsx +160 -0
  158. package/src/ui/sidebar.tsx +777 -0
  159. package/src/ui/skeleton.tsx +18 -0
  160. package/src/ui/slider.tsx +26 -0
  161. package/src/ui/sonner.tsx +58 -0
  162. package/src/ui/spinner.tsx +17 -0
  163. package/src/ui/switch.tsx +27 -0
  164. package/src/ui/table.tsx +117 -0
  165. package/src/ui/tabs.tsx +53 -0
  166. package/src/ui/textarea.tsx +23 -0
  167. package/src/ui/toast.tsx +127 -0
  168. package/src/ui/toaster.tsx +33 -0
  169. package/src/ui/toggle-group.tsx +59 -0
  170. package/src/ui/toggle.tsx +43 -0
  171. package/src/ui/tooltip.tsx +53 -0
  172. package/src/ui/use-toast.ts +3 -0
  173. package/src/utils.ts +8 -0
@@ -0,0 +1,1084 @@
1
+ import { IconColorPicker } from "@tabler/icons-react";
2
+ import {
3
+ useEffect,
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type KeyboardEvent,
8
+ type PointerEvent,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
13
+ import { cn } from "../utils.js";
14
+
15
+ export type VisualControlValue = string | number | boolean;
16
+
17
+ export interface VisualControlOption {
18
+ label: string;
19
+ value: string;
20
+ color?: string;
21
+ }
22
+
23
+ export interface VisualTweakDefinition {
24
+ id: string;
25
+ label: string;
26
+ type: "color-swatch" | "color-swatches" | "segment" | "slider" | "toggle";
27
+ options?: VisualControlOption[];
28
+ min?: number;
29
+ max?: number;
30
+ step?: number;
31
+ defaultValue: VisualControlValue;
32
+ cssVar?: string;
33
+ unit?: string;
34
+ }
35
+
36
+ function clampNumber(value: number, min?: number, max?: number) {
37
+ let next = value;
38
+ if (typeof min === "number") next = Math.max(min, next);
39
+ if (typeof max === "number") next = Math.min(max, next);
40
+ return next;
41
+ }
42
+
43
+ function formatNumber(value: number, unit?: string) {
44
+ const rounded = Number.isInteger(value) ? value : Number(value.toFixed(2));
45
+ return unit ? `${rounded}${unit}` : String(rounded);
46
+ }
47
+
48
+ function parseDraftNumber(value: string, fallback: number) {
49
+ const match = value.trim().match(/-?\d+(?:\.\d+)?/);
50
+ if (!match) return fallback;
51
+ const next = Number(match[0]);
52
+ return Number.isFinite(next) ? next : fallback;
53
+ }
54
+
55
+ interface RgbaColor {
56
+ r: number;
57
+ g: number;
58
+ b: number;
59
+ a: number;
60
+ }
61
+
62
+ interface HsvaColor {
63
+ h: number;
64
+ s: number;
65
+ v: number;
66
+ a: number;
67
+ }
68
+
69
+ const CHECKERBOARD_IMAGE =
70
+ "linear-gradient(45deg, #d4d4d4 25%, transparent 25%), linear-gradient(-45deg, #d4d4d4 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d4d4d4 75%), linear-gradient(-45deg, transparent 75%, #d4d4d4 75%)";
71
+ const FALLBACK_RGBA: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
72
+
73
+ function normalizeRgba(color: RgbaColor): RgbaColor {
74
+ return {
75
+ r: clamp(color.r, 0, 255),
76
+ g: clamp(color.g, 0, 255),
77
+ b: clamp(color.b, 0, 255),
78
+ a: clampFloat(color.a, 0, 1),
79
+ };
80
+ }
81
+
82
+ function normalizeHexColor(value: string): string | null {
83
+ const trimmed = value.trim();
84
+ if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toUpperCase();
85
+ if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`.toUpperCase();
86
+ if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
87
+ return `#${trimmed
88
+ .slice(1)
89
+ .split("")
90
+ .map((char) => char + char)
91
+ .join("")}`.toUpperCase();
92
+ }
93
+ if (/^[0-9a-f]{3}$/i.test(trimmed)) {
94
+ return `#${trimmed
95
+ .split("")
96
+ .map((char) => char + char)
97
+ .join("")}`.toUpperCase();
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function parseCssColor(value: string): RgbaColor | null {
103
+ const trimmed = value.trim();
104
+ if (!trimmed) return null;
105
+ const normalizedHex = normalizeHexColor(trimmed);
106
+ if (normalizedHex) {
107
+ return normalizeRgba({
108
+ r: Number.parseInt(normalizedHex.slice(1, 3), 16),
109
+ g: Number.parseInt(normalizedHex.slice(3, 5), 16),
110
+ b: Number.parseInt(normalizedHex.slice(5, 7), 16),
111
+ a: 1,
112
+ });
113
+ }
114
+ const rgb = trimmed.match(
115
+ /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*([0-9.]+%?))?/i,
116
+ );
117
+ if (rgb) {
118
+ return normalizeRgba({
119
+ r: Number(rgb[1]),
120
+ g: Number(rgb[2]),
121
+ b: Number(rgb[3]),
122
+ a: parseAlpha(rgb[4]),
123
+ });
124
+ }
125
+ const lower = trimmed.toLowerCase();
126
+ if (lower === "transparent" || lower === "rgba(0, 0, 0, 0)") {
127
+ return { r: 0, g: 0, b: 0, a: 0 };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function channelToHex(value: number): string {
133
+ return clamp(value, 0, 255).toString(16).padStart(2, "0");
134
+ }
135
+
136
+ function rgbaToHex(color: RgbaColor): string {
137
+ const normalized = normalizeRgba(color);
138
+ return `#${channelToHex(normalized.r)}${channelToHex(normalized.g)}${channelToHex(normalized.b)}`.toUpperCase();
139
+ }
140
+
141
+ function rgbaToCss(color: RgbaColor): string {
142
+ const normalized = normalizeRgba(color);
143
+ if (normalized.a >= 1) return rgbaToHex(normalized);
144
+ return `rgba(${normalized.r}, ${normalized.g}, ${normalized.b}, ${trimNumber(normalized.a)})`;
145
+ }
146
+
147
+ function parseAlpha(value: string | undefined): number {
148
+ if (!value) return 1;
149
+ if (value.endsWith("%")) return Number(value.slice(0, -1)) / 100;
150
+ return Number(value);
151
+ }
152
+
153
+ function trimNumber(value: number) {
154
+ return Number(value.toFixed(2)).toString();
155
+ }
156
+
157
+ function rgbaToHsv(color: RgbaColor): HsvaColor {
158
+ const normalized = normalizeRgba(color);
159
+ const r = normalized.r / 255;
160
+ const g = normalized.g / 255;
161
+ const b = normalized.b / 255;
162
+ const max = Math.max(r, g, b);
163
+ const min = Math.min(r, g, b);
164
+ const delta = max - min;
165
+
166
+ let h = 0;
167
+ if (delta !== 0) {
168
+ if (max === r) h = ((g - b) / delta) % 6;
169
+ else if (max === g) h = (b - r) / delta + 2;
170
+ else h = (r - g) / delta + 4;
171
+ h *= 60;
172
+ if (h < 0) h += 360;
173
+ }
174
+
175
+ return {
176
+ h: Math.round(h),
177
+ s: max === 0 ? 0 : Math.round((delta / max) * 100),
178
+ v: Math.round(max * 100),
179
+ a: normalized.a,
180
+ };
181
+ }
182
+
183
+ function hsvToRgba(color: HsvaColor): RgbaColor {
184
+ const h = ((color.h % 360) + 360) % 360;
185
+ const s = clampFloat(color.s, 0, 100) / 100;
186
+ const v = clampFloat(color.v, 0, 100) / 100;
187
+ const chroma = v * s;
188
+ const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
189
+ const m = v - chroma;
190
+
191
+ let r = 0;
192
+ let g = 0;
193
+ let b = 0;
194
+ if (h < 60) [r, g, b] = [chroma, x, 0];
195
+ else if (h < 120) [r, g, b] = [x, chroma, 0];
196
+ else if (h < 180) [r, g, b] = [0, chroma, x];
197
+ else if (h < 240) [r, g, b] = [0, x, chroma];
198
+ else if (h < 300) [r, g, b] = [x, 0, chroma];
199
+ else [r, g, b] = [chroma, 0, x];
200
+
201
+ return normalizeRgba({
202
+ r: Math.round((r + m) * 255),
203
+ g: Math.round((g + m) * 255),
204
+ b: Math.round((b + m) * 255),
205
+ a: clampFloat(color.a, 0, 1),
206
+ });
207
+ }
208
+
209
+ function clamp(value: number, min: number, max: number): number {
210
+ if (!Number.isFinite(value)) return min;
211
+ return Math.max(min, Math.min(max, Math.round(value)));
212
+ }
213
+
214
+ function clampFloat(value: number, min: number, max: number): number {
215
+ if (!Number.isFinite(value)) return min;
216
+ return Math.max(min, Math.min(max, value));
217
+ }
218
+
219
+ function swatchBackground(value: string) {
220
+ if (value === "transparent" || value === "rgba(0, 0, 0, 0)") {
221
+ return {
222
+ background: CHECKERBOARD_IMAGE,
223
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0",
224
+ backgroundSize: "8px 8px",
225
+ };
226
+ }
227
+ return { background: value };
228
+ }
229
+
230
+ export function VisualInspectorPanel({
231
+ title,
232
+ subtitle,
233
+ children,
234
+ className,
235
+ headerAction,
236
+ }: {
237
+ title: ReactNode;
238
+ subtitle?: ReactNode;
239
+ children: ReactNode;
240
+ className?: string;
241
+ headerAction?: ReactNode;
242
+ }) {
243
+ return (
244
+ <aside
245
+ className={cn(
246
+ "w-64 overflow-hidden rounded-xl border border-border bg-card/95 text-card-foreground shadow-2xl shadow-black/35 backdrop-blur",
247
+ className,
248
+ )}
249
+ >
250
+ <div className="flex min-h-10 items-start justify-between gap-2 border-b border-border/70 px-3 py-2.5">
251
+ <div className="min-w-0">
252
+ <div className="truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
253
+ {title}
254
+ </div>
255
+ {subtitle && (
256
+ <div className="mt-0.5 truncate text-[12px] text-foreground">
257
+ {subtitle}
258
+ </div>
259
+ )}
260
+ </div>
261
+ {headerAction}
262
+ </div>
263
+ <div className="max-h-[min(680px,calc(100vh-7rem))] overflow-y-auto p-2">
264
+ {children}
265
+ </div>
266
+ </aside>
267
+ );
268
+ }
269
+
270
+ export function VisualInspectorSection({
271
+ title,
272
+ children,
273
+ className,
274
+ }: {
275
+ title: ReactNode;
276
+ children: ReactNode;
277
+ className?: string;
278
+ }) {
279
+ return (
280
+ <section className={cn("rounded-lg px-1.5 py-2", className)}>
281
+ <div className="mb-2 px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/75">
282
+ {title}
283
+ </div>
284
+ <div className="space-y-2">{children}</div>
285
+ </section>
286
+ );
287
+ }
288
+
289
+ export function VisualControlRow({
290
+ label,
291
+ children,
292
+ className,
293
+ }: {
294
+ label: ReactNode;
295
+ children: ReactNode;
296
+ className?: string;
297
+ }) {
298
+ return (
299
+ <div className={cn("grid gap-1", className)}>
300
+ <span className="text-[11px] text-muted-foreground">{label}</span>
301
+ {children}
302
+ </div>
303
+ );
304
+ }
305
+
306
+ export function VisualSwatchControl({
307
+ options,
308
+ value,
309
+ onChange,
310
+ columns = 8,
311
+ className,
312
+ }: {
313
+ options: VisualControlOption[];
314
+ value: string;
315
+ onChange: (value: string) => void;
316
+ columns?: number;
317
+ className?: string;
318
+ }) {
319
+ return (
320
+ <div
321
+ className={cn("grid gap-1.5", className)}
322
+ style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
323
+ >
324
+ {options.map((option) => {
325
+ const swatch = option.color ?? option.value;
326
+ const isTransparent = swatch === "transparent";
327
+ return (
328
+ <button
329
+ key={`${option.value}-${option.label}`}
330
+ type="button"
331
+ title={option.label}
332
+ aria-label={option.label}
333
+ onClick={() => onChange(option.value)}
334
+ className={cn(
335
+ "size-5 cursor-pointer rounded-md border border-border/70 transition-[transform,border-color] hover:scale-105 hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
336
+ value === option.value &&
337
+ "border-foreground/70 ring-2 ring-foreground/40 ring-offset-1 ring-offset-card",
338
+ )}
339
+ style={{
340
+ background: isTransparent
341
+ ? "linear-gradient(45deg, hsl(var(--muted)) 25%, transparent 25%), linear-gradient(-45deg, hsl(var(--muted)) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, hsl(var(--muted)) 75%), linear-gradient(-45deg, transparent 75%, hsl(var(--muted)) 75%)"
342
+ : swatch,
343
+ backgroundPosition: isTransparent
344
+ ? "0 0, 0 4px, 4px -4px, -4px 0"
345
+ : undefined,
346
+ backgroundSize: isTransparent ? "8px 8px" : undefined,
347
+ }}
348
+ />
349
+ );
350
+ })}
351
+ </div>
352
+ );
353
+ }
354
+
355
+ export function VisualColorPicker({
356
+ label,
357
+ value,
358
+ onChange,
359
+ documentColors = [],
360
+ allowTransparent = false,
361
+ hexLabel = "Hex",
362
+ documentColorsLabel = "Document colors",
363
+ transparentLabel = "Transparent",
364
+ className,
365
+ }: {
366
+ label: string;
367
+ value: string;
368
+ onChange: (value: string) => void;
369
+ documentColors?: string[];
370
+ allowTransparent?: boolean;
371
+ hexLabel?: string;
372
+ documentColorsLabel?: string;
373
+ transparentLabel?: string;
374
+ className?: string;
375
+ }) {
376
+ const [open, setOpen] = useState(false);
377
+ const color = parseCssColor(value) ?? FALLBACK_RGBA;
378
+ const colorHex = rgbaToHex(color);
379
+ const [draft, setDraft] = useState(colorHex.replace(/^#/, ""));
380
+ const [picking, setPicking] = useState(false);
381
+ const hasEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
382
+
383
+ useEffect(() => {
384
+ if (!open) setDraft(colorHex.replace(/^#/, ""));
385
+ }, [colorHex, open]);
386
+
387
+ const commitDraft = (nextDraft = draft) => {
388
+ const normalized = normalizeHexColor(nextDraft);
389
+ if (!normalized) {
390
+ setDraft(colorHex.replace(/^#/, ""));
391
+ return;
392
+ }
393
+ setDraft(normalized.replace(/^#/, ""));
394
+ onChange(normalized);
395
+ };
396
+
397
+ const emitHsv = (nextHsv: HsvaColor) => {
398
+ const nextColor = hsvToRgba({ ...nextHsv, a: color.a });
399
+ const nextValue = rgbaToCss(nextColor);
400
+ setDraft(rgbaToHex(nextColor).replace(/^#/, ""));
401
+ onChange(nextValue);
402
+ };
403
+
404
+ const pickScreenColor = async () => {
405
+ if (!hasEyeDropper || picking) return;
406
+ try {
407
+ setPicking(true);
408
+ const EyeDropperCtor = (
409
+ window as unknown as {
410
+ EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> };
411
+ }
412
+ ).EyeDropper;
413
+ if (!EyeDropperCtor) return;
414
+ const result = await new EyeDropperCtor().open();
415
+ if (result?.sRGBHex) {
416
+ const next = normalizeHexColor(result.sRGBHex);
417
+ if (next) {
418
+ setDraft(next.replace(/^#/, ""));
419
+ onChange(next);
420
+ }
421
+ }
422
+ } finally {
423
+ setPicking(false);
424
+ }
425
+ };
426
+
427
+ const uniqueDocumentColors = Array.from(
428
+ new Set(
429
+ documentColors
430
+ .map((color) =>
431
+ color === "transparent" ? color : normalizeHexColor(color),
432
+ )
433
+ .filter(Boolean) as string[],
434
+ ),
435
+ ).slice(0, 16);
436
+ const displayValue =
437
+ value === "transparent" || color.a === 0
438
+ ? transparentLabel
439
+ : colorHex.replace(/^#/, "");
440
+ const hsv = rgbaToHsv(color);
441
+
442
+ return (
443
+ <Popover open={open} onOpenChange={setOpen}>
444
+ <PopoverTrigger asChild>
445
+ <button
446
+ type="button"
447
+ aria-label={label}
448
+ className={cn(
449
+ "flex h-7 w-full cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background/70 px-2 text-[11px] shadow-none transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
450
+ className,
451
+ )}
452
+ >
453
+ <span
454
+ className="size-4 shrink-0 rounded-[3px] border border-border/70"
455
+ style={swatchBackground(value)}
456
+ />
457
+ <span className="min-w-0 flex-1 truncate text-left font-medium tabular-nums text-foreground">
458
+ {displayValue}
459
+ </span>
460
+ <span
461
+ aria-hidden="true"
462
+ className="size-0 border-x-[4px] border-t-[5px] border-x-transparent border-t-muted-foreground/70"
463
+ />
464
+ </button>
465
+ </PopoverTrigger>
466
+ <PopoverContent
467
+ side="left"
468
+ align="start"
469
+ sideOffset={8}
470
+ className="z-[10000] w-[252px] p-0 text-[11px] shadow-xl"
471
+ onFocusOutside={(event) => event.preventDefault()}
472
+ >
473
+ <div className="rounded-md bg-popover text-popover-foreground">
474
+ <VisualSaturationBrightnessField
475
+ hsv={hsv}
476
+ label={label}
477
+ onChange={emitHsv}
478
+ />
479
+
480
+ <div className="mt-2.5 px-3">
481
+ <div className="grid grid-cols-[1.5rem_1fr] items-center gap-x-2">
482
+ <div className="flex items-center justify-center">
483
+ <button
484
+ type="button"
485
+ aria-label={label}
486
+ disabled={!hasEyeDropper || picking}
487
+ onClick={() => void pickScreenColor()}
488
+ className={cn(
489
+ "flex size-6 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
490
+ (!hasEyeDropper || picking) &&
491
+ "pointer-events-none opacity-40",
492
+ picking &&
493
+ "bg-primary/10 text-primary ring-1 ring-primary/50",
494
+ )}
495
+ >
496
+ <IconColorPicker className="size-4" />
497
+ </button>
498
+ </div>
499
+ <VisualColorTrack
500
+ label={label}
501
+ value={hsv.h}
502
+ min={0}
503
+ max={360}
504
+ backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
505
+ onChange={(nextHue) =>
506
+ emitHsv({ ...hsv, h: nextHue === 360 ? 0 : nextHue })
507
+ }
508
+ />
509
+ <span
510
+ className="mt-2 size-[18px] shrink-0 rounded-[3px] border border-border/60"
511
+ style={swatchBackground(rgbaToCss(color))}
512
+ />
513
+ <VisualColorTrack
514
+ label={transparentLabel}
515
+ value={Math.round(color.a * 100)}
516
+ min={0}
517
+ max={100}
518
+ backgroundImage={`${CHECKERBOARD_IMAGE}, linear-gradient(90deg, rgba(${color.r}, ${color.g}, ${color.b}, 0), rgba(${color.r}, ${color.g}, ${color.b}, 1))`}
519
+ backgroundSize="8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%"
520
+ backgroundPosition="0 0, 0 4px, 4px -4px, -4px 0, 0 0"
521
+ onChange={(nextOpacity) => {
522
+ const nextColor = normalizeRgba({
523
+ ...color,
524
+ a: nextOpacity / 100,
525
+ });
526
+ const nextValue = rgbaToCss(nextColor);
527
+ setDraft(rgbaToHex(nextColor).replace(/^#/, ""));
528
+ onChange(nextValue);
529
+ }}
530
+ />
531
+ </div>
532
+ </div>
533
+
534
+ <div className="mt-2.5 grid grid-cols-[4.5rem_1fr] items-center gap-1 border-b border-border/70 px-3 pb-3">
535
+ <span className="text-[11px] font-medium text-muted-foreground">
536
+ {hexLabel}
537
+ </span>
538
+ <input
539
+ value={draft}
540
+ spellCheck={false}
541
+ onChange={(event) => setDraft(event.currentTarget.value)}
542
+ onFocus={(event) => event.currentTarget.select()}
543
+ onBlur={() => commitDraft()}
544
+ onKeyDown={(event) => {
545
+ if (event.key === "Enter") {
546
+ event.preventDefault();
547
+ commitDraft();
548
+ event.currentTarget.blur();
549
+ }
550
+ if (event.key === "Escape") {
551
+ event.preventDefault();
552
+ setDraft(colorHex.replace(/^#/, ""));
553
+ event.currentTarget.blur();
554
+ }
555
+ }}
556
+ className="h-6 min-w-0 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] uppercase tabular-nums outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring"
557
+ />
558
+ </div>
559
+ {(allowTransparent || uniqueDocumentColors.length > 0) && (
560
+ <div className="px-3 py-2.5">
561
+ <div className="mb-2 flex h-5 items-center justify-between text-[11px] text-muted-foreground">
562
+ <span>{documentColorsLabel}</span>
563
+ </div>
564
+ <div className="grid grid-cols-8 gap-1">
565
+ {allowTransparent && (
566
+ <button
567
+ type="button"
568
+ aria-label={transparentLabel}
569
+ aria-pressed={value === "transparent"}
570
+ className={cn(
571
+ "size-5 cursor-pointer rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
572
+ value === "transparent" || color.a === 0
573
+ ? "border-primary ring-1 ring-primary"
574
+ : "border-border/60",
575
+ )}
576
+ style={swatchBackground("transparent")}
577
+ onClick={() => {
578
+ setDraft(colorHex.replace(/^#/, ""));
579
+ onChange("transparent");
580
+ }}
581
+ />
582
+ )}
583
+ {uniqueDocumentColors.map((color) => (
584
+ <button
585
+ key={color}
586
+ type="button"
587
+ title={color}
588
+ aria-label={color}
589
+ aria-pressed={colorHex === color}
590
+ className={cn(
591
+ "size-5 cursor-pointer rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
592
+ colorHex === color
593
+ ? "border-primary ring-1 ring-primary"
594
+ : "border-border/60",
595
+ )}
596
+ style={swatchBackground(color)}
597
+ onClick={() => {
598
+ setDraft(color);
599
+ onChange(color);
600
+ }}
601
+ />
602
+ ))}
603
+ </div>
604
+ </div>
605
+ )}
606
+ </div>
607
+ </PopoverContent>
608
+ </Popover>
609
+ );
610
+ }
611
+
612
+ function VisualSaturationBrightnessField({
613
+ hsv,
614
+ label,
615
+ onChange,
616
+ }: {
617
+ hsv: HsvaColor;
618
+ label: string;
619
+ onChange: (value: HsvaColor) => void;
620
+ }) {
621
+ const fieldRef = useRef<HTMLDivElement>(null);
622
+ const draggingRef = useRef(false);
623
+ const hueColor = rgbaToCss(hsvToRgba({ h: hsv.h, s: 100, v: 100, a: 1 }));
624
+
625
+ const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
626
+ const rect = fieldRef.current?.getBoundingClientRect();
627
+ if (!rect) return;
628
+ onChange({
629
+ ...hsv,
630
+ s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100),
631
+ v: clamp(100 - ((event.clientY - rect.top) / rect.height) * 100, 0, 100),
632
+ });
633
+ };
634
+
635
+ const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
636
+ const step = event.shiftKey ? 10 : 1;
637
+ if (event.key === "ArrowRight") {
638
+ event.preventDefault();
639
+ onChange({ ...hsv, s: clamp(hsv.s + step, 0, 100) });
640
+ }
641
+ if (event.key === "ArrowLeft") {
642
+ event.preventDefault();
643
+ onChange({ ...hsv, s: clamp(hsv.s - step, 0, 100) });
644
+ }
645
+ if (event.key === "ArrowUp") {
646
+ event.preventDefault();
647
+ onChange({ ...hsv, v: clamp(hsv.v + step, 0, 100) });
648
+ }
649
+ if (event.key === "ArrowDown") {
650
+ event.preventDefault();
651
+ onChange({ ...hsv, v: clamp(hsv.v - step, 0, 100) });
652
+ }
653
+ };
654
+
655
+ return (
656
+ <div
657
+ ref={fieldRef}
658
+ tabIndex={0}
659
+ aria-label={label}
660
+ onPointerDown={(event) => {
661
+ draggingRef.current = true;
662
+ event.currentTarget.setPointerCapture(event.pointerId);
663
+ updateFromPointer(event);
664
+ }}
665
+ onPointerMove={(event) => {
666
+ if (!draggingRef.current) return;
667
+ updateFromPointer(event);
668
+ }}
669
+ onPointerUp={(event) => {
670
+ draggingRef.current = false;
671
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
672
+ event.currentTarget.releasePointerCapture(event.pointerId);
673
+ }
674
+ }}
675
+ onPointerCancel={() => {
676
+ draggingRef.current = false;
677
+ }}
678
+ onKeyDown={stepWithKeyboard}
679
+ className="relative h-40 w-full cursor-crosshair overflow-hidden rounded-t-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset active:cursor-grabbing"
680
+ style={{
681
+ backgroundImage: `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueColor} 100%)`,
682
+ }}
683
+ >
684
+ <span
685
+ className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
686
+ style={{
687
+ left: `${hsv.s}%`,
688
+ top: `${100 - hsv.v}%`,
689
+ }}
690
+ />
691
+ </div>
692
+ );
693
+ }
694
+
695
+ function VisualColorTrack({
696
+ label,
697
+ value,
698
+ min,
699
+ max,
700
+ backgroundImage,
701
+ backgroundSize,
702
+ backgroundPosition,
703
+ onChange,
704
+ }: {
705
+ label: string;
706
+ value: number;
707
+ min: number;
708
+ max: number;
709
+ backgroundImage: string;
710
+ backgroundSize?: string;
711
+ backgroundPosition?: string;
712
+ onChange: (value: number) => void;
713
+ }) {
714
+ const trackRef = useRef<HTMLDivElement>(null);
715
+ const draggingRef = useRef(false);
716
+ const percent = ((value - min) / (max - min)) * 100;
717
+
718
+ const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
719
+ const rect = trackRef.current?.getBoundingClientRect();
720
+ if (!rect) return;
721
+ const next = min + ((event.clientX - rect.left) / rect.width) * (max - min);
722
+ onChange(clamp(next, min, max));
723
+ };
724
+
725
+ const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
726
+ const step = event.shiftKey ? 10 : 1;
727
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
728
+ event.preventDefault();
729
+ onChange(clamp(value + step, min, max));
730
+ }
731
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
732
+ event.preventDefault();
733
+ onChange(clamp(value - step, min, max));
734
+ }
735
+ if (event.key === "Home") {
736
+ event.preventDefault();
737
+ onChange(min);
738
+ }
739
+ if (event.key === "End") {
740
+ event.preventDefault();
741
+ onChange(max);
742
+ }
743
+ };
744
+
745
+ return (
746
+ <div
747
+ ref={trackRef}
748
+ role="slider"
749
+ tabIndex={0}
750
+ aria-label={label}
751
+ aria-valuemin={min}
752
+ aria-valuemax={max}
753
+ aria-valuenow={Math.round(value)}
754
+ onKeyDown={stepWithKeyboard}
755
+ onPointerDown={(event) => {
756
+ draggingRef.current = true;
757
+ event.currentTarget.setPointerCapture(event.pointerId);
758
+ updateFromPointer(event);
759
+ }}
760
+ onPointerMove={(event) => {
761
+ if (!draggingRef.current) return;
762
+ updateFromPointer(event);
763
+ }}
764
+ onPointerUp={(event) => {
765
+ draggingRef.current = false;
766
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
767
+ event.currentTarget.releasePointerCapture(event.pointerId);
768
+ }
769
+ }}
770
+ onPointerCancel={() => {
771
+ draggingRef.current = false;
772
+ }}
773
+ className="relative h-3.5 cursor-pointer rounded-full border border-border/60 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing"
774
+ style={{ backgroundImage, backgroundSize, backgroundPosition }}
775
+ >
776
+ <span
777
+ className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
778
+ style={{ left: `${clamp(percent, 0, 100)}%` }}
779
+ />
780
+ </div>
781
+ );
782
+ }
783
+
784
+ export function VisualSegmentedControl({
785
+ options,
786
+ value,
787
+ onChange,
788
+ className,
789
+ }: {
790
+ options: VisualControlOption[];
791
+ value: string;
792
+ onChange: (value: string) => void;
793
+ className?: string;
794
+ }) {
795
+ return (
796
+ <div
797
+ className={cn(
798
+ "flex min-h-7 overflow-hidden rounded-md border border-border bg-background/60",
799
+ className,
800
+ )}
801
+ >
802
+ {options.map((option) => (
803
+ <button
804
+ key={option.value}
805
+ type="button"
806
+ onClick={() => onChange(option.value)}
807
+ className={cn(
808
+ "min-w-0 flex-1 cursor-pointer px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground",
809
+ value === option.value && "bg-accent text-foreground",
810
+ )}
811
+ >
812
+ <span className="truncate">{option.label}</span>
813
+ </button>
814
+ ))}
815
+ </div>
816
+ );
817
+ }
818
+
819
+ export function VisualToggleControl({
820
+ checked,
821
+ onChange,
822
+ label,
823
+ }: {
824
+ checked: boolean;
825
+ onChange: (checked: boolean) => void;
826
+ label?: string;
827
+ }) {
828
+ return (
829
+ <button
830
+ type="button"
831
+ aria-pressed={checked}
832
+ aria-label={label}
833
+ onClick={() => onChange(!checked)}
834
+ className={cn(
835
+ "relative h-5 w-9 cursor-pointer rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
836
+ checked ? "bg-primary/35" : "bg-muted",
837
+ )}
838
+ >
839
+ <span
840
+ className={cn(
841
+ "absolute top-1/2 size-3.5 -translate-y-1/2 rounded-full bg-foreground shadow transition-transform",
842
+ checked ? "translate-x-[18px]" : "translate-x-[3px]",
843
+ )}
844
+ />
845
+ </button>
846
+ );
847
+ }
848
+
849
+ export function VisualSliderControl({
850
+ value,
851
+ onChange,
852
+ min = 0,
853
+ max = 100,
854
+ step = 1,
855
+ unit,
856
+ }: {
857
+ value: number;
858
+ onChange: (value: number) => void;
859
+ min?: number;
860
+ max?: number;
861
+ step?: number;
862
+ unit?: string;
863
+ }) {
864
+ const safeValue = clampNumber(Number.isFinite(value) ? value : min, min, max);
865
+ return (
866
+ <div className="flex h-7 items-center gap-2">
867
+ <input
868
+ type="range"
869
+ min={min}
870
+ max={max}
871
+ step={step}
872
+ value={safeValue}
873
+ onChange={(event) => onChange(Number(event.currentTarget.value))}
874
+ className="min-w-0 flex-1 cursor-pointer accent-foreground"
875
+ />
876
+ <span className="w-9 text-right text-[11px] tabular-nums text-muted-foreground">
877
+ {formatNumber(safeValue, unit)}
878
+ </span>
879
+ </div>
880
+ );
881
+ }
882
+
883
+ export function VisualScrubInput({
884
+ label,
885
+ value,
886
+ onChange,
887
+ min,
888
+ max,
889
+ step = 1,
890
+ unit,
891
+ disabled = false,
892
+ }: {
893
+ label: string;
894
+ value: number;
895
+ onChange: (value: number) => void;
896
+ min?: number;
897
+ max?: number;
898
+ step?: number;
899
+ unit?: string;
900
+ disabled?: boolean;
901
+ }) {
902
+ const id = useId();
903
+ const [draft, setDraft] = useState(() => formatNumber(value, unit));
904
+ const [focused, setFocused] = useState(false);
905
+ const dragRef = useRef<{
906
+ pointerId: number;
907
+ prevX: number;
908
+ dragged: boolean;
909
+ } | null>(null);
910
+
911
+ useEffect(() => {
912
+ if (!focused) setDraft(formatNumber(value, unit));
913
+ }, [focused, unit, value]);
914
+
915
+ const commit = (nextDraft = draft) => {
916
+ const parsed = parseDraftNumber(nextDraft, value);
917
+ const next = clampNumber(parsed, min, max);
918
+ onChange(next);
919
+ setDraft(formatNumber(next, unit));
920
+ };
921
+
922
+ const setNext = (next: number) => {
923
+ const clamped = clampNumber(next, min, max);
924
+ onChange(clamped);
925
+ setDraft(formatNumber(clamped, unit));
926
+ };
927
+
928
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
929
+ if (event.key === "Enter") {
930
+ event.preventDefault();
931
+ commit();
932
+ event.currentTarget.blur();
933
+ return;
934
+ }
935
+ if (event.key === "Escape") {
936
+ event.preventDefault();
937
+ setDraft(formatNumber(value, unit));
938
+ event.currentTarget.blur();
939
+ return;
940
+ }
941
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
942
+ event.preventDefault();
943
+ const mult =
944
+ event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1;
945
+ const direction = event.key === "ArrowUp" ? 1 : -1;
946
+ const base = parseDraftNumber(draft, value);
947
+ setNext(base + direction * step * mult);
948
+ }
949
+ };
950
+
951
+ const onPointerDown = (event: PointerEvent<HTMLLabelElement>) => {
952
+ if (disabled || event.button !== 0) return;
953
+ event.preventDefault();
954
+ dragRef.current = {
955
+ pointerId: event.pointerId,
956
+ prevX: event.clientX,
957
+ dragged: false,
958
+ };
959
+ event.currentTarget.setPointerCapture(event.pointerId);
960
+ };
961
+
962
+ const onPointerMove = (event: PointerEvent<HTMLLabelElement>) => {
963
+ const drag = dragRef.current;
964
+ if (!drag || drag.pointerId !== event.pointerId) return;
965
+ const delta = event.clientX - drag.prevX;
966
+ if (delta === 0) return;
967
+ drag.prevX = event.clientX;
968
+ drag.dragged = true;
969
+ const mult = event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1;
970
+ setNext(value + delta * step * mult);
971
+ };
972
+
973
+ const onPointerUp = (event: PointerEvent<HTMLLabelElement>) => {
974
+ const drag = dragRef.current;
975
+ if (!drag || drag.pointerId !== event.pointerId) return;
976
+ event.currentTarget.releasePointerCapture(event.pointerId);
977
+ dragRef.current = null;
978
+ if (!drag.dragged) {
979
+ document.getElementById(id)?.focus();
980
+ }
981
+ };
982
+
983
+ return (
984
+ <div className="flex items-center gap-1.5">
985
+ <label
986
+ htmlFor={id}
987
+ onPointerDown={onPointerDown}
988
+ onPointerMove={onPointerMove}
989
+ onPointerUp={onPointerUp}
990
+ className={cn(
991
+ "flex w-8 shrink-0 cursor-ew-resize select-none items-center justify-center rounded border border-transparent px-1 text-[10px] font-semibold text-muted-foreground hover:border-border hover:bg-accent/60",
992
+ disabled && "cursor-not-allowed opacity-50",
993
+ )}
994
+ >
995
+ {label}
996
+ </label>
997
+ <input
998
+ id={id}
999
+ value={draft}
1000
+ disabled={disabled}
1001
+ onFocus={() => setFocused(true)}
1002
+ onBlur={() => {
1003
+ setFocused(false);
1004
+ commit();
1005
+ }}
1006
+ onChange={(event) => setDraft(event.currentTarget.value)}
1007
+ onKeyDown={onKeyDown}
1008
+ className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] tabular-nums text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-50"
1009
+ />
1010
+ </div>
1011
+ );
1012
+ }
1013
+
1014
+ export function VisualTweakControl({
1015
+ tweak,
1016
+ value,
1017
+ onChange,
1018
+ className,
1019
+ }: {
1020
+ tweak: VisualTweakDefinition;
1021
+ value: VisualControlValue;
1022
+ onChange: (value: VisualControlValue) => void;
1023
+ className?: string;
1024
+ }) {
1025
+ if (tweak.type === "toggle") {
1026
+ return (
1027
+ <div
1028
+ className={cn("flex h-7 items-center justify-between gap-2", className)}
1029
+ >
1030
+ <span className="truncate text-[11px] text-muted-foreground">
1031
+ {tweak.label}
1032
+ </span>
1033
+ <VisualToggleControl
1034
+ checked={Boolean(value)}
1035
+ onChange={onChange}
1036
+ label={tweak.label}
1037
+ />
1038
+ </div>
1039
+ );
1040
+ }
1041
+
1042
+ const numericValue =
1043
+ typeof value === "number"
1044
+ ? value
1045
+ : typeof value === "string"
1046
+ ? Number(value)
1047
+ : Number(tweak.defaultValue);
1048
+
1049
+ return (
1050
+ <VisualControlRow label={tweak.label} className={className}>
1051
+ {((tweak.type as string) === "color-swatch" ||
1052
+ (tweak.type as string) === "color-swatches") && (
1053
+ <VisualColorPicker
1054
+ label={tweak.label}
1055
+ value={String(value)}
1056
+ documentColors={(tweak.options ?? []).map(
1057
+ (option) => option.color ?? option.value,
1058
+ )}
1059
+ onChange={onChange}
1060
+ />
1061
+ )}
1062
+ {tweak.type === "segment" && (
1063
+ <VisualSegmentedControl
1064
+ options={tweak.options ?? []}
1065
+ value={String(value)}
1066
+ onChange={onChange}
1067
+ />
1068
+ )}
1069
+ {tweak.type === "slider" && (
1070
+ <VisualSliderControl
1071
+ min={tweak.min ?? 0}
1072
+ max={tweak.max ?? 100}
1073
+ step={tweak.step ?? 1}
1074
+ unit={
1075
+ tweak.unit ??
1076
+ (tweak.cssVar?.toLowerCase().includes("radius") ? "px" : undefined)
1077
+ }
1078
+ value={Number.isFinite(numericValue) ? numericValue : 0}
1079
+ onChange={onChange}
1080
+ />
1081
+ )}
1082
+ </VisualControlRow>
1083
+ );
1084
+ }