@antigenic-oss/paint 0.1.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 (158) hide show
  1. package/LICENSE +178 -0
  2. package/NOTICE +4 -0
  3. package/README.md +180 -0
  4. package/bin/paint.js +266 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +19 -0
  7. package/package.json +81 -0
  8. package/postcss.config.mjs +8 -0
  9. package/public/dev-editor-inspector.js +1872 -0
  10. package/src/app/api/claude/analyze/route.ts +319 -0
  11. package/src/app/api/claude/apply/route.ts +185 -0
  12. package/src/app/api/claude/pick-folder/route.ts +64 -0
  13. package/src/app/api/claude/scan/route.ts +221 -0
  14. package/src/app/api/claude/status/route.ts +55 -0
  15. package/src/app/api/project/scan/route.ts +634 -0
  16. package/src/app/api/project-scan/css-variables/route.ts +238 -0
  17. package/src/app/api/project-scan/route.ts +40 -0
  18. package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
  19. package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
  20. package/src/app/docs/DocsClient.tsx +322 -0
  21. package/src/app/docs/layout.tsx +7 -0
  22. package/src/app/docs/page.tsx +855 -0
  23. package/src/app/globals.css +176 -0
  24. package/src/app/layout.tsx +19 -0
  25. package/src/app/page.tsx +46 -0
  26. package/src/bridge/api-handlers.ts +885 -0
  27. package/src/bridge/proxy-handler.ts +329 -0
  28. package/src/bridge/server.ts +113 -0
  29. package/src/components/BreakpointTabs.tsx +72 -0
  30. package/src/components/ChangeSummaryModal.tsx +267 -0
  31. package/src/components/ConnectModal.tsx +994 -0
  32. package/src/components/Editor.tsx +90 -0
  33. package/src/components/PageSelector.tsx +208 -0
  34. package/src/components/PreviewFrame.tsx +299 -0
  35. package/src/components/ProjectFolderBanner.tsx +91 -0
  36. package/src/components/ResponsiveToolbar.tsx +222 -0
  37. package/src/components/TargetSelector.tsx +243 -0
  38. package/src/components/TopBar.tsx +315 -0
  39. package/src/components/common/CollapsibleSection.tsx +36 -0
  40. package/src/components/common/ColorPicker.tsx +920 -0
  41. package/src/components/common/EditablePre.tsx +136 -0
  42. package/src/components/common/ErrorBoundary.tsx +65 -0
  43. package/src/components/common/ResizablePanel.tsx +83 -0
  44. package/src/components/common/ScanAnimation.tsx +76 -0
  45. package/src/components/common/ToastContainer.tsx +97 -0
  46. package/src/components/common/UnitInput.tsx +77 -0
  47. package/src/components/common/VariableColorPicker.tsx +622 -0
  48. package/src/components/left-panel/AddElementPanel.tsx +237 -0
  49. package/src/components/left-panel/ComponentsPanel.tsx +609 -0
  50. package/src/components/left-panel/IconSidebar.tsx +99 -0
  51. package/src/components/left-panel/LayerNode.tsx +874 -0
  52. package/src/components/left-panel/LayerSearch.tsx +23 -0
  53. package/src/components/left-panel/LayersPanel.tsx +52 -0
  54. package/src/components/left-panel/LeftPanel.tsx +122 -0
  55. package/src/components/left-panel/PagesPanel.tsx +114 -0
  56. package/src/components/left-panel/icons.tsx +162 -0
  57. package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
  58. package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
  59. package/src/components/right-panel/ElementLogBox.tsx +248 -0
  60. package/src/components/right-panel/PanelTabs.tsx +83 -0
  61. package/src/components/right-panel/RightPanel.tsx +41 -0
  62. package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
  63. package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
  64. package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
  65. package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
  66. package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
  67. package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
  68. package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
  69. package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
  70. package/src/components/right-panel/claude/DiffCard.tsx +130 -0
  71. package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
  72. package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
  73. package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
  74. package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
  75. package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
  76. package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
  77. package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
  78. package/src/components/right-panel/design/BorderSection.tsx +161 -0
  79. package/src/components/right-panel/design/CSSRawView.tsx +412 -0
  80. package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
  81. package/src/components/right-panel/design/DesignPanel.tsx +275 -0
  82. package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
  83. package/src/components/right-panel/design/GradientEditor.tsx +726 -0
  84. package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
  85. package/src/components/right-panel/design/PositionSection.tsx +865 -0
  86. package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
  87. package/src/components/right-panel/design/SVGSection.tsx +361 -0
  88. package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
  89. package/src/components/right-panel/design/SizeSection.tsx +183 -0
  90. package/src/components/right-panel/design/TextSection.tsx +719 -0
  91. package/src/components/right-panel/design/icons.tsx +948 -0
  92. package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
  93. package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
  94. package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
  95. package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
  96. package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
  97. package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
  98. package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
  99. package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
  100. package/src/hooks/useBridge.ts +95 -0
  101. package/src/hooks/useChangeTracker.ts +563 -0
  102. package/src/hooks/useClaudeAPI.ts +118 -0
  103. package/src/hooks/useDOMTree.ts +25 -0
  104. package/src/hooks/useKeyboardShortcuts.ts +76 -0
  105. package/src/hooks/usePostMessage.ts +589 -0
  106. package/src/hooks/useProjectScan.ts +204 -0
  107. package/src/hooks/useResizable.ts +20 -0
  108. package/src/hooks/useSelectedElement.ts +51 -0
  109. package/src/hooks/useTargetUrl.ts +81 -0
  110. package/src/inspector/DOMTraverser.ts +71 -0
  111. package/src/inspector/ElementSelector.ts +23 -0
  112. package/src/inspector/HoverHighlighter.ts +54 -0
  113. package/src/inspector/SelectionHighlighter.ts +27 -0
  114. package/src/inspector/StyleExtractor.ts +19 -0
  115. package/src/inspector/inspector.ts +17 -0
  116. package/src/inspector/messaging.ts +30 -0
  117. package/src/lib/apiBase.ts +15 -0
  118. package/src/lib/classifyElement.ts +430 -0
  119. package/src/lib/claude-bin.ts +197 -0
  120. package/src/lib/claude-stream.ts +158 -0
  121. package/src/lib/clientProjectScanner.ts +344 -0
  122. package/src/lib/componentMatcher.ts +156 -0
  123. package/src/lib/constants.ts +573 -0
  124. package/src/lib/cssVariableUtils.ts +409 -0
  125. package/src/lib/diffParser.ts +206 -0
  126. package/src/lib/folderPicker.ts +84 -0
  127. package/src/lib/gradientParser.ts +160 -0
  128. package/src/lib/projectScanner.ts +355 -0
  129. package/src/lib/promptBuilder.ts +402 -0
  130. package/src/lib/shadowParser.ts +124 -0
  131. package/src/lib/tailwindClassParser.ts +248 -0
  132. package/src/lib/textShadowUtils.ts +106 -0
  133. package/src/lib/utils.ts +299 -0
  134. package/src/lib/validatePath.ts +40 -0
  135. package/src/proxy.ts +92 -0
  136. package/src/server/terminal-server.ts +104 -0
  137. package/src/store/changeSlice.ts +288 -0
  138. package/src/store/claudeSlice.ts +222 -0
  139. package/src/store/componentSlice.ts +90 -0
  140. package/src/store/consoleSlice.ts +51 -0
  141. package/src/store/cssVariableSlice.ts +94 -0
  142. package/src/store/elementSlice.ts +78 -0
  143. package/src/store/index.ts +35 -0
  144. package/src/store/terminalSlice.ts +30 -0
  145. package/src/store/treeSlice.ts +69 -0
  146. package/src/store/uiSlice.ts +327 -0
  147. package/src/types/changelog.ts +49 -0
  148. package/src/types/claude.ts +131 -0
  149. package/src/types/component.ts +49 -0
  150. package/src/types/cssVariables.ts +18 -0
  151. package/src/types/element.ts +21 -0
  152. package/src/types/file-system-access.d.ts +27 -0
  153. package/src/types/gradient.ts +12 -0
  154. package/src/types/messages.ts +392 -0
  155. package/src/types/shadow.ts +8 -0
  156. package/src/types/tree.ts +9 -0
  157. package/tsconfig.json +42 -0
  158. package/tsconfig.server.json +12 -0
@@ -0,0 +1,726 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
4
+ import type { GradientData, GradientStop } from '@/types/gradient'
5
+ import { serializeGradient } from '@/lib/gradientParser'
6
+ import { ColorPicker } from '@/components/common/ColorPicker'
7
+
8
+ interface GradientEditorProps {
9
+ value: GradientData
10
+ onChange: (data: GradientData) => void
11
+ showTypeSelector?: boolean
12
+ }
13
+
14
+ // ─── Color Helpers ───────────────────────────────────────────────
15
+
16
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
17
+ const clean = hex.replace('#', '')
18
+ if (clean.length === 3) {
19
+ return {
20
+ r: parseInt(clean[0] + clean[0], 16),
21
+ g: parseInt(clean[1] + clean[1], 16),
22
+ b: parseInt(clean[2] + clean[2], 16),
23
+ }
24
+ }
25
+ if (clean.length >= 6) {
26
+ return {
27
+ r: parseInt(clean.slice(0, 2), 16),
28
+ g: parseInt(clean.slice(2, 4), 16),
29
+ b: parseInt(clean.slice(4, 6), 16),
30
+ }
31
+ }
32
+ return null
33
+ }
34
+
35
+ function parseStopColor(color: string): { hex: string; opacity: number } {
36
+ const rgbaMatch = color.match(
37
+ /rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/,
38
+ )
39
+ if (rgbaMatch) {
40
+ const r = +rgbaMatch[1],
41
+ g = +rgbaMatch[2],
42
+ b = +rgbaMatch[3]
43
+ const a = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1
44
+ const hex =
45
+ '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')
46
+ return { hex, opacity: Math.round(a * 100) }
47
+ }
48
+ if (color.startsWith('#')) {
49
+ return { hex: color.slice(0, 7), opacity: 100 }
50
+ }
51
+ return { hex: '#000000', opacity: 100 }
52
+ }
53
+
54
+ function buildStopColor(hex: string, opacity: number): string {
55
+ if (opacity >= 100) return hex
56
+ const rgb = hexToRgb(hex)
57
+ if (!rgb) return hex
58
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(opacity / 100).toFixed(2)})`
59
+ }
60
+
61
+ // ─── Gradient Type Icons ─────────────────────────────────────────
62
+
63
+ function LinearIcon(props: React.SVGProps<SVGSVGElement>) {
64
+ return (
65
+ <svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
66
+ <rect
67
+ x={2}
68
+ y={2}
69
+ width={12}
70
+ height={12}
71
+ rx={2}
72
+ stroke="currentColor"
73
+ strokeWidth={1.2}
74
+ />
75
+ <line
76
+ x1={4}
77
+ y1={12}
78
+ x2={12}
79
+ y2={4}
80
+ stroke="currentColor"
81
+ strokeWidth={1.2}
82
+ strokeLinecap="round"
83
+ />
84
+ </svg>
85
+ )
86
+ }
87
+
88
+ function RadialIcon(props: React.SVGProps<SVGSVGElement>) {
89
+ return (
90
+ <svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
91
+ <circle cx={8} cy={8} r={3} stroke="currentColor" strokeWidth={1.2} />
92
+ <circle
93
+ cx={8}
94
+ cy={8}
95
+ r={6}
96
+ stroke="currentColor"
97
+ strokeWidth={1.2}
98
+ opacity={0.4}
99
+ />
100
+ </svg>
101
+ )
102
+ }
103
+
104
+ function ConicIcon(props: React.SVGProps<SVGSVGElement>) {
105
+ return (
106
+ <svg width={16} height={16} viewBox="0 0 16 16" fill="none" {...props}>
107
+ <circle cx={8} cy={8} r={6} stroke="currentColor" strokeWidth={1.2} />
108
+ <path
109
+ d="M8 2V8L12.5 4"
110
+ stroke="currentColor"
111
+ strokeWidth={1.2}
112
+ strokeLinecap="round"
113
+ strokeLinejoin="round"
114
+ />
115
+ </svg>
116
+ )
117
+ }
118
+
119
+ // ─── Rotate Icons ────────────────────────────────────────────────
120
+
121
+ function RotateCCWIcon() {
122
+ return (
123
+ <svg width={12} height={12} viewBox="0 0 12 12" fill="none">
124
+ <path
125
+ d="M2.5 5.5A3.5 3.5 0 0 1 9.3 4"
126
+ stroke="currentColor"
127
+ strokeWidth={1.2}
128
+ strokeLinecap="round"
129
+ />
130
+ <path
131
+ d="M2 3.5L2.5 5.5 4.5 5"
132
+ stroke="currentColor"
133
+ strokeWidth={1.2}
134
+ strokeLinecap="round"
135
+ strokeLinejoin="round"
136
+ />
137
+ </svg>
138
+ )
139
+ }
140
+
141
+ function RotateCWIcon() {
142
+ return (
143
+ <svg width={12} height={12} viewBox="0 0 12 12" fill="none">
144
+ <path
145
+ d="M9.5 5.5A3.5 3.5 0 0 0 2.7 4"
146
+ stroke="currentColor"
147
+ strokeWidth={1.2}
148
+ strokeLinecap="round"
149
+ />
150
+ <path
151
+ d="M10 3.5L9.5 5.5 7.5 5"
152
+ stroke="currentColor"
153
+ strokeWidth={1.2}
154
+ strokeLinecap="round"
155
+ strokeLinejoin="round"
156
+ />
157
+ </svg>
158
+ )
159
+ }
160
+
161
+ // ─── Angle Dial ──────────────────────────────────────────────────
162
+
163
+ function AngleDial({
164
+ angle,
165
+ onChange,
166
+ }: {
167
+ angle: number
168
+ onChange: (a: number) => void
169
+ }) {
170
+ const svgRef = useRef<SVGSVGElement>(null)
171
+
172
+ const calcAngle = useCallback(
173
+ (e: React.PointerEvent) => {
174
+ const svg = svgRef.current
175
+ if (!svg) return
176
+ const rect = svg.getBoundingClientRect()
177
+ const x = e.clientX - rect.left - rect.width / 2
178
+ const y = e.clientY - rect.top - rect.height / 2
179
+ let deg = Math.atan2(y, x) * (180 / Math.PI) + 90
180
+ if (deg < 0) deg += 360
181
+ onChange(Math.round(deg) % 360)
182
+ },
183
+ [onChange],
184
+ )
185
+
186
+ const handlePointerDown = useCallback(
187
+ (e: React.PointerEvent) => {
188
+ e.preventDefault()
189
+ svgRef.current?.setPointerCapture(e.pointerId)
190
+ calcAngle(e)
191
+ },
192
+ [calcAngle],
193
+ )
194
+
195
+ const handlePointerMove = useCallback(
196
+ (e: React.PointerEvent) => {
197
+ if (!svgRef.current?.hasPointerCapture(e.pointerId)) return
198
+ calcAngle(e)
199
+ },
200
+ [calcAngle],
201
+ )
202
+
203
+ const rad = (angle - 90) * (Math.PI / 180)
204
+ const cx = 13,
205
+ cy = 13,
206
+ r = 9
207
+ const dotX = cx + r * Math.cos(rad)
208
+ const dotY = cy + r * Math.sin(rad)
209
+
210
+ return (
211
+ <svg
212
+ ref={svgRef}
213
+ width={26}
214
+ height={26}
215
+ viewBox="0 0 26 26"
216
+ style={{ cursor: 'pointer', flexShrink: 0 }}
217
+ onPointerDown={handlePointerDown}
218
+ onPointerMove={handlePointerMove}
219
+ >
220
+ <circle
221
+ cx={cx}
222
+ cy={cy}
223
+ r={r}
224
+ fill="none"
225
+ stroke="var(--text-muted)"
226
+ strokeWidth={1.5}
227
+ opacity={0.3}
228
+ />
229
+ <circle cx={cx} cy={cy} r={1.5} fill="var(--text-muted)" opacity={0.4} />
230
+ <line
231
+ x1={cx}
232
+ y1={cy}
233
+ x2={dotX}
234
+ y2={dotY}
235
+ stroke="var(--text-secondary)"
236
+ strokeWidth={1.5}
237
+ strokeLinecap="round"
238
+ />
239
+ <circle cx={dotX} cy={dotY} r={2.5} fill="var(--accent)" />
240
+ </svg>
241
+ )
242
+ }
243
+
244
+ // ─── Gradient Bar with Draggable Stops ───────────────────────────
245
+
246
+ function GradientBar({
247
+ stops,
248
+ selectedIndex,
249
+ gradient,
250
+ onSelectStop,
251
+ onMoveStop,
252
+ onAddStop,
253
+ onRemoveStop,
254
+ }: {
255
+ stops: GradientStop[]
256
+ selectedIndex: number
257
+ gradient: string
258
+ onSelectStop: (index: number) => void
259
+ onMoveStop: (index: number, position: number) => void
260
+ onAddStop: (position: number) => void
261
+ onRemoveStop: (index: number) => void
262
+ }) {
263
+ const barRef = useRef<HTMLDivElement>(null)
264
+ const dragIndexRef = useRef<number | null>(null)
265
+ const wasDragging = useRef(false)
266
+
267
+ const handleBarPointerDown = useCallback(
268
+ (e: React.PointerEvent) => {
269
+ // Only handle direct clicks on the bar, not on stop handles
270
+ if ((e.target as HTMLElement).dataset.stopHandle) return
271
+ const rect = barRef.current?.getBoundingClientRect()
272
+ if (!rect) return
273
+ const pos = Math.max(
274
+ 0,
275
+ Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100)),
276
+ )
277
+ onAddStop(pos)
278
+ },
279
+ [onAddStop],
280
+ )
281
+
282
+ const handleStopPointerDown = useCallback(
283
+ (e: React.PointerEvent, index: number) => {
284
+ e.preventDefault()
285
+ e.stopPropagation()
286
+ dragIndexRef.current = index
287
+ wasDragging.current = false
288
+ onSelectStop(index)
289
+ barRef.current?.setPointerCapture(e.pointerId)
290
+ },
291
+ [onSelectStop],
292
+ )
293
+
294
+ const handlePointerMove = useCallback(
295
+ (e: React.PointerEvent) => {
296
+ if (dragIndexRef.current === null) return
297
+ if (!barRef.current?.hasPointerCapture(e.pointerId)) return
298
+ wasDragging.current = true
299
+ const rect = barRef.current.getBoundingClientRect()
300
+ const pos = Math.max(
301
+ 0,
302
+ Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100)),
303
+ )
304
+ onMoveStop(dragIndexRef.current, pos)
305
+ },
306
+ [onMoveStop],
307
+ )
308
+
309
+ const handlePointerUp = useCallback(() => {
310
+ dragIndexRef.current = null
311
+ }, [])
312
+
313
+ return (
314
+ <div
315
+ ref={barRef}
316
+ className="relative w-full"
317
+ style={{ paddingBottom: 14, cursor: 'copy' }}
318
+ onPointerMove={handlePointerMove}
319
+ onPointerUp={handlePointerUp}
320
+ >
321
+ {/* Checkerboard + gradient */}
322
+ <div
323
+ className="w-full rounded"
324
+ style={{
325
+ height: 24,
326
+ background: `${gradient}, repeating-conic-gradient(#333 0% 25%, #444 0% 50%) 0 0 / 8px 8px`,
327
+ border: '1px solid var(--border)',
328
+ cursor: 'copy',
329
+ }}
330
+ onPointerDown={handleBarPointerDown}
331
+ />
332
+
333
+ {/* Stop handles */}
334
+ {stops.map((stop, i) => {
335
+ const isSelected = i === selectedIndex
336
+ return (
337
+ <div
338
+ key={i}
339
+ data-stop-handle="true"
340
+ className="absolute"
341
+ style={{
342
+ left: `${stop.position}%`,
343
+ bottom: 0,
344
+ transform: 'translateX(-50%)',
345
+ width: 11,
346
+ height: 11,
347
+ background: stop.color,
348
+ border: isSelected
349
+ ? '2px solid #fff'
350
+ : '1.5px solid rgba(255,255,255,0.25)',
351
+ borderRadius: 2,
352
+ cursor: 'grab',
353
+ zIndex: isSelected ? 2 : 1,
354
+ boxShadow: isSelected
355
+ ? '0 0 0 1px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.4)'
356
+ : '0 1px 3px rgba(0,0,0,0.4)',
357
+ }}
358
+ onPointerDown={(e) => handleStopPointerDown(e, i)}
359
+ onDoubleClick={(e) => {
360
+ e.stopPropagation()
361
+ onRemoveStop(i)
362
+ }}
363
+ />
364
+ )
365
+ })}
366
+ </div>
367
+ )
368
+ }
369
+
370
+ // ─── Main GradientEditor ─────────────────────────────────────────
371
+
372
+ export function GradientEditor({
373
+ value,
374
+ onChange,
375
+ showTypeSelector = true,
376
+ }: GradientEditorProps) {
377
+ const [selectedStop, setSelectedStop] = useState(0)
378
+
379
+ const previewGradient = useMemo(() => serializeGradient(value), [value])
380
+
381
+ // Build a scaled-down repeat preview (show ~3 repetitions)
382
+ const repeatPreview = useMemo(() => {
383
+ const scaled = {
384
+ ...value,
385
+ repeat: true,
386
+ stops: value.stops.map((s) => ({
387
+ ...s,
388
+ position: Math.round(s.position * 0.33),
389
+ })),
390
+ }
391
+ return serializeGradient(scaled)
392
+ }, [value])
393
+
394
+ // Keep selected stop in bounds
395
+ useEffect(() => {
396
+ if (selectedStop >= value.stops.length) {
397
+ setSelectedStop(Math.max(0, value.stops.length - 1))
398
+ }
399
+ }, [value.stops.length, selectedStop])
400
+
401
+ const currentStop = value.stops[selectedStop] || value.stops[0]
402
+ const { hex: stopHex, opacity: stopOpacity } = useMemo(
403
+ () => parseStopColor(currentStop?.color || '#000000'),
404
+ [currentStop?.color],
405
+ )
406
+
407
+ // ── Type ──
408
+ const updateType = useCallback(
409
+ (type: GradientData['type']) => {
410
+ onChange({ ...value, type })
411
+ },
412
+ [value, onChange],
413
+ )
414
+
415
+ // ── Angle ──
416
+ const updateAngle = useCallback(
417
+ (angle: number) => {
418
+ onChange({ ...value, angle })
419
+ },
420
+ [value, onChange],
421
+ )
422
+
423
+ const rotateAngle = useCallback(
424
+ (delta: number) => {
425
+ onChange({
426
+ ...value,
427
+ angle: (((value.angle + delta) % 360) + 360) % 360,
428
+ })
429
+ },
430
+ [value, onChange],
431
+ )
432
+
433
+ // ── Stops ──
434
+ const updateStop = useCallback(
435
+ (index: number, updates: Partial<GradientStop>) => {
436
+ const newStops = value.stops.map((s, i) =>
437
+ i === index ? { ...s, ...updates } : s,
438
+ )
439
+ onChange({ ...value, stops: newStops })
440
+ },
441
+ [value, onChange],
442
+ )
443
+
444
+ const addStopAtPosition = useCallback(
445
+ (position: number) => {
446
+ const pos = Math.max(0, Math.min(100, position))
447
+ // Interpolate color from closest neighbor
448
+ const newStop: GradientStop = {
449
+ color: '#808080',
450
+ position: pos,
451
+ opacity: 1,
452
+ }
453
+ const newStops = [...value.stops, newStop].sort(
454
+ (a, b) => a.position - b.position,
455
+ )
456
+ const newIndex = newStops.findIndex((s) => s === newStop)
457
+ setSelectedStop(newIndex)
458
+ onChange({ ...value, stops: newStops })
459
+ },
460
+ [value, onChange],
461
+ )
462
+
463
+ const removeStop = useCallback(
464
+ (index: number) => {
465
+ if (value.stops.length <= 2) return
466
+ const newStops = value.stops.filter((_, i) => i !== index)
467
+ onChange({ ...value, stops: newStops })
468
+ if (selectedStop >= newStops.length) setSelectedStop(newStops.length - 1)
469
+ else if (selectedStop === index) setSelectedStop(Math.max(0, index - 1))
470
+ },
471
+ [value, onChange, selectedStop],
472
+ )
473
+
474
+ // ── Selected stop color ──
475
+ const updateStopColor = useCallback(
476
+ (color: string) => {
477
+ updateStop(selectedStop, { color })
478
+ },
479
+ [selectedStop, updateStop],
480
+ )
481
+
482
+ const updateStopHex = useCallback(
483
+ (newHex: string) => {
484
+ const clean = newHex.replace(/[^0-9a-fA-F]/g, '').slice(0, 6)
485
+ if (clean.length === 6 || clean.length === 3) {
486
+ const hex = '#' + clean
487
+ const newColor = buildStopColor(hex, stopOpacity)
488
+ updateStop(selectedStop, { color: newColor })
489
+ }
490
+ },
491
+ [selectedStop, stopOpacity, updateStop],
492
+ )
493
+
494
+ const updateStopOpacity = useCallback(
495
+ (opacity: number) => {
496
+ const clamped = Math.max(0, Math.min(100, opacity))
497
+ const newColor = buildStopColor(stopHex, clamped)
498
+ updateStop(selectedStop, { color: newColor, opacity: clamped / 100 })
499
+ },
500
+ [selectedStop, stopHex, updateStop],
501
+ )
502
+
503
+ // ── Repeat ──
504
+ const toggleRepeat = useCallback(() => {
505
+ onChange({ ...value, repeat: !value.repeat })
506
+ }, [value, onChange])
507
+
508
+ // ── Local inputs ──
509
+ const [angleInput, setAngleInput] = useState(String(value.angle))
510
+ useEffect(() => setAngleInput(String(value.angle)), [value.angle])
511
+
512
+ const commitAngle = useCallback(() => {
513
+ const n = parseInt(angleInput, 10)
514
+ if (!isNaN(n)) updateAngle(((n % 360) + 360) % 360)
515
+ else setAngleInput(String(value.angle))
516
+ }, [angleInput, value.angle, updateAngle])
517
+
518
+ const [hexInput, setHexInput] = useState(stopHex.replace('#', ''))
519
+ useEffect(() => setHexInput(stopHex.replace('#', '')), [stopHex])
520
+
521
+ const commitHex = useCallback(() => {
522
+ updateStopHex(hexInput)
523
+ }, [hexInput, updateStopHex])
524
+
525
+ const [opacityInput, setOpacityInput] = useState(String(stopOpacity))
526
+ useEffect(() => setOpacityInput(String(stopOpacity)), [stopOpacity])
527
+
528
+ const commitOpacity = useCallback(() => {
529
+ const n = parseInt(opacityInput, 10)
530
+ if (!isNaN(n)) updateStopOpacity(n)
531
+ else setOpacityInput(String(stopOpacity))
532
+ }, [opacityInput, stopOpacity, updateStopOpacity])
533
+
534
+ const showAngle = value.type === 'linear' || value.type === 'conic'
535
+
536
+ const TYPES: {
537
+ type: GradientData['type']
538
+ Icon: React.FC<React.SVGProps<SVGSVGElement>>
539
+ }[] = [
540
+ { type: 'linear', Icon: LinearIcon },
541
+ { type: 'radial', Icon: RadialIcon },
542
+ { type: 'conic', Icon: ConicIcon },
543
+ ]
544
+
545
+ return (
546
+ <div className="space-y-2.5">
547
+ {/* ── Type row ── */}
548
+ {showTypeSelector && (
549
+ <div className="flex items-center gap-2">
550
+ <span
551
+ className="text-[11px] w-10 shrink-0"
552
+ style={{ color: 'var(--text-muted)' }}
553
+ >
554
+ Type
555
+ </span>
556
+ <div
557
+ className="flex gap-px rounded overflow-hidden"
558
+ style={{ border: '1px solid var(--border)' }}
559
+ >
560
+ {TYPES.map(({ type, Icon }) => {
561
+ const active = value.type === type
562
+ return (
563
+ <button
564
+ key={type}
565
+ type="button"
566
+ className="flex items-center justify-center w-7 h-7"
567
+ style={{
568
+ background: active
569
+ ? 'rgba(74,158,255,0.15)'
570
+ : 'var(--bg-tertiary)',
571
+ color: active ? 'var(--accent)' : 'var(--text-secondary)',
572
+ border: 'none',
573
+ cursor: 'pointer',
574
+ }}
575
+ title={type.charAt(0).toUpperCase() + type.slice(1)}
576
+ onClick={() => updateType(type)}
577
+ >
578
+ <Icon />
579
+ </button>
580
+ )
581
+ })}
582
+ </div>
583
+ </div>
584
+ )}
585
+
586
+ {/* ── Angle row ── */}
587
+ {showAngle && (
588
+ <div className="flex items-center gap-2">
589
+ <span
590
+ className="text-[11px] w-10 shrink-0"
591
+ style={{ color: 'var(--text-muted)' }}
592
+ >
593
+ Angle
594
+ </span>
595
+ <AngleDial angle={value.angle} onChange={updateAngle} />
596
+ <div className="flex items-center gap-0.5">
597
+ <button
598
+ type="button"
599
+ className="flex items-center justify-center w-5 h-5 rounded hover:opacity-80"
600
+ style={{ color: 'var(--text-muted)' }}
601
+ onClick={() => rotateAngle(-45)}
602
+ title="Rotate -45\u00B0"
603
+ >
604
+ <RotateCCWIcon />
605
+ </button>
606
+ <button
607
+ type="button"
608
+ className="flex items-center justify-center w-5 h-5 rounded hover:opacity-80"
609
+ style={{ color: 'var(--text-muted)' }}
610
+ onClick={() => rotateAngle(45)}
611
+ title="Rotate +45\u00B0"
612
+ >
613
+ <RotateCWIcon />
614
+ </button>
615
+ </div>
616
+ <div className="flex items-center gap-1 ml-auto">
617
+ <input
618
+ type="text"
619
+ value={angleInput}
620
+ onChange={(e) => setAngleInput(e.target.value)}
621
+ onBlur={commitAngle}
622
+ onKeyDown={(e) => {
623
+ if (e.key === 'Enter') commitAngle()
624
+ }}
625
+ className="w-11 h-6 rounded text-[11px] px-1.5 outline-none text-right tabular-nums"
626
+ style={{
627
+ background: 'var(--bg-tertiary)',
628
+ border: '1px solid var(--border)',
629
+ color: 'var(--text-primary)',
630
+ }}
631
+ />
632
+ <span
633
+ className="text-[9px] uppercase tracking-wide"
634
+ style={{ color: 'var(--text-muted)' }}
635
+ >
636
+ DEG
637
+ </span>
638
+ </div>
639
+ </div>
640
+ )}
641
+
642
+ {/* ── Gradient bar + stops ── */}
643
+ <GradientBar
644
+ stops={value.stops}
645
+ selectedIndex={selectedStop}
646
+ gradient={previewGradient}
647
+ onSelectStop={setSelectedStop}
648
+ onMoveStop={(index, pos) => updateStop(index, { position: pos })}
649
+ onAddStop={addStopAtPosition}
650
+ onRemoveStop={removeStop}
651
+ />
652
+
653
+ {/* ── Repeat row ── */}
654
+ <div className="flex items-center gap-2">
655
+ <label className="flex items-center gap-1.5 cursor-pointer select-none">
656
+ <input
657
+ type="checkbox"
658
+ checked={!!value.repeat}
659
+ onChange={toggleRepeat}
660
+ className="w-3 h-3 rounded accent-[#4a9eff]"
661
+ style={{ accentColor: 'var(--accent)' }}
662
+ />
663
+ <span
664
+ className="text-[11px]"
665
+ style={{ color: 'var(--text-secondary)' }}
666
+ >
667
+ Repeat
668
+ </span>
669
+ </label>
670
+ <div
671
+ className="flex-1 h-3 rounded-sm"
672
+ style={{
673
+ background: `${repeatPreview}, repeating-conic-gradient(#333 0% 25%, #444 0% 50%) 0 0 / 6px 6px`,
674
+ border: '1px solid var(--border)',
675
+ opacity: value.repeat ? 1 : 0.35,
676
+ }}
677
+ />
678
+ </div>
679
+
680
+ {/* ── Color row (selected stop) ── */}
681
+ <div className="flex items-center gap-2">
682
+ <span
683
+ className="text-[11px] w-10 shrink-0"
684
+ style={{ color: 'var(--text-muted)' }}
685
+ >
686
+ Color
687
+ </span>
688
+ <ColorPicker
689
+ value={currentStop?.color || '#000000'}
690
+ onChange={updateStopColor}
691
+ onSelectVariable={(varExpr) => updateStopColor(varExpr)}
692
+ />
693
+ <div className="flex items-center gap-0.5 ml-auto shrink-0">
694
+ <input
695
+ type="text"
696
+ value={opacityInput}
697
+ onChange={(e) => setOpacityInput(e.target.value)}
698
+ onBlur={commitOpacity}
699
+ onKeyDown={(e) => {
700
+ if (e.key === 'Enter') commitOpacity()
701
+ }}
702
+ className="w-8 h-6 rounded text-[11px] px-1 outline-none text-right tabular-nums"
703
+ style={{
704
+ background: 'var(--bg-tertiary)',
705
+ border: '1px solid var(--border)',
706
+ color: 'var(--text-primary)',
707
+ }}
708
+ />
709
+ <span className="text-[10px]" style={{ color: 'var(--text-muted)' }}>
710
+ %
711
+ </span>
712
+ </div>
713
+ </div>
714
+
715
+ {/* ── Stop list (remove on double-click hint) ── */}
716
+ {value.stops.length > 2 && (
717
+ <div
718
+ className="text-[9px] text-center"
719
+ style={{ color: 'var(--text-muted)', opacity: 0.5 }}
720
+ >
721
+ Double-click a stop handle to remove it
722
+ </div>
723
+ )}
724
+ </div>
725
+ )
726
+ }