@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,920 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
4
+ import { sendViaIframe } from '@/hooks/usePostMessage'
5
+ import { useEditorStore } from '@/store'
6
+ import {
7
+ filterColorVariables,
8
+ toDisplayableColor,
9
+ } from '@/lib/cssVariableUtils'
10
+
11
+ // ─── Color Conversion Utilities ─────────────────────────────────
12
+
13
+ interface HSV {
14
+ h: number
15
+ s: number
16
+ v: number
17
+ }
18
+ interface RGB {
19
+ r: number
20
+ g: number
21
+ b: number
22
+ }
23
+
24
+ function hsvToRgb({ h, s, v }: HSV): RGB {
25
+ const s1 = s / 100,
26
+ v1 = v / 100
27
+ const c = v1 * s1,
28
+ x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
29
+ m = v1 - c
30
+ let r = 0,
31
+ g = 0,
32
+ b = 0
33
+ if (h < 60) {
34
+ r = c
35
+ g = x
36
+ } else if (h < 120) {
37
+ r = x
38
+ g = c
39
+ } else if (h < 180) {
40
+ g = c
41
+ b = x
42
+ } else if (h < 240) {
43
+ g = x
44
+ b = c
45
+ } else if (h < 300) {
46
+ r = x
47
+ b = c
48
+ } else {
49
+ r = c
50
+ b = x
51
+ }
52
+ return {
53
+ r: Math.round((r + m) * 255),
54
+ g: Math.round((g + m) * 255),
55
+ b: Math.round((b + m) * 255),
56
+ }
57
+ }
58
+
59
+ function rgbToHsv({ r, g, b }: RGB): HSV {
60
+ const r1 = r / 255,
61
+ g1 = g / 255,
62
+ b1 = b / 255
63
+ const max = Math.max(r1, g1, b1),
64
+ min = Math.min(r1, g1, b1)
65
+ const d = max - min
66
+ let h = 0
67
+ if (d !== 0) {
68
+ if (max === r1) h = 60 * (((g1 - b1) / d) % 6)
69
+ else if (max === g1) h = 60 * ((b1 - r1) / d + 2)
70
+ else h = 60 * ((r1 - g1) / d + 4)
71
+ }
72
+ if (h < 0) h += 360
73
+ const s = max === 0 ? 0 : (d / max) * 100
74
+ const v = max * 100
75
+ return { h: Math.round(h), s: Math.round(s), v: Math.round(v) }
76
+ }
77
+
78
+ function rgbToHex({ r, g, b }: RGB): string {
79
+ return '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')
80
+ }
81
+
82
+ function hexToRgb(hex: string): RGB | null {
83
+ const clean = hex.replace('#', '')
84
+ let r: number, g: number, b: number
85
+ if (clean.length === 3) {
86
+ r = parseInt(clean[0] + clean[0], 16)
87
+ g = parseInt(clean[1] + clean[1], 16)
88
+ b = parseInt(clean[2] + clean[2], 16)
89
+ } else if (clean.length >= 6) {
90
+ r = parseInt(clean.slice(0, 2), 16)
91
+ g = parseInt(clean.slice(2, 4), 16)
92
+ b = parseInt(clean.slice(4, 6), 16)
93
+ } else {
94
+ return null
95
+ }
96
+ if (isNaN(r) || isNaN(g) || isNaN(b)) return null
97
+ return { r, g, b }
98
+ }
99
+
100
+ function parseColor(value: string): { rgb: RGB; alpha: number } {
101
+ // Handle transparent keyword
102
+ if (!value || value === 'transparent') {
103
+ return { rgb: { r: 0, g: 0, b: 0 }, alpha: 0 }
104
+ }
105
+ // Handle rgba/rgb
106
+ const rgbaMatch = value.match(
107
+ /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/,
108
+ )
109
+ if (rgbaMatch) {
110
+ return {
111
+ rgb: { r: +rgbaMatch[1], g: +rgbaMatch[2], b: +rgbaMatch[3] },
112
+ alpha:
113
+ rgbaMatch[4] !== undefined
114
+ ? Math.round(parseFloat(rgbaMatch[4]) * 100)
115
+ : 100,
116
+ }
117
+ }
118
+ // Handle hex
119
+ const rgb = hexToRgb(value)
120
+ if (rgb) {
121
+ // Check for 8-digit hex alpha
122
+ const clean = value.replace('#', '')
123
+ let alpha = 100
124
+ if (clean.length === 8) {
125
+ alpha = Math.round((parseInt(clean.slice(6, 8), 16) / 255) * 100)
126
+ }
127
+ return { rgb, alpha }
128
+ }
129
+ return { rgb: { r: 0, g: 0, b: 0 }, alpha: 100 }
130
+ }
131
+
132
+ function formatOutput(rgb: RGB, alpha: number): string {
133
+ if (alpha < 100) {
134
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${(alpha / 100).toFixed(2)})`
135
+ }
136
+ return rgbToHex(rgb)
137
+ }
138
+
139
+ // ─── Canvas Drawing ─────────────────────────────────────────────
140
+
141
+ function drawSaturationBrightness(canvas: HTMLCanvasElement, hue: number) {
142
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
143
+ if (!ctx) return
144
+ const w = canvas.width,
145
+ h = canvas.height
146
+
147
+ // Base hue color
148
+ const hueRgb = hsvToRgb({ h: hue, s: 100, v: 100 })
149
+
150
+ // White → hue horizontal gradient
151
+ const gradH = ctx.createLinearGradient(0, 0, w, 0)
152
+ gradH.addColorStop(0, '#ffffff')
153
+ gradH.addColorStop(1, `rgb(${hueRgb.r},${hueRgb.g},${hueRgb.b})`)
154
+ ctx.fillStyle = gradH
155
+ ctx.fillRect(0, 0, w, h)
156
+
157
+ // Transparent → black vertical gradient
158
+ const gradV = ctx.createLinearGradient(0, 0, 0, h)
159
+ gradV.addColorStop(0, 'rgba(0,0,0,0)')
160
+ gradV.addColorStop(1, 'rgba(0,0,0,1)')
161
+ ctx.fillStyle = gradV
162
+ ctx.fillRect(0, 0, w, h)
163
+ }
164
+
165
+ function drawHueSlider(canvas: HTMLCanvasElement) {
166
+ const ctx = canvas.getContext('2d')
167
+ if (!ctx) return
168
+ const w = canvas.width
169
+ const grad = ctx.createLinearGradient(0, 0, w, 0)
170
+ for (let i = 0; i <= 6; i++) {
171
+ const hue = i * 60
172
+ const rgb = hsvToRgb({ h: hue >= 360 ? 0 : hue, s: 100, v: 100 })
173
+ grad.addColorStop(i / 6, `rgb(${rgb.r},${rgb.g},${rgb.b})`)
174
+ }
175
+ ctx.fillStyle = grad
176
+ ctx.fillRect(0, 0, w, canvas.height)
177
+ }
178
+
179
+ function drawAlphaSlider(canvas: HTMLCanvasElement, rgb: RGB) {
180
+ const ctx = canvas.getContext('2d')
181
+ if (!ctx) return
182
+ const w = canvas.width,
183
+ h = canvas.height
184
+
185
+ // Checkerboard background
186
+ const size = 4
187
+ for (let x = 0; x < w; x += size) {
188
+ for (let y = 0; y < h; y += size) {
189
+ ctx.fillStyle = (x / size + y / size) % 2 === 0 ? '#ccc' : '#fff'
190
+ ctx.fillRect(x, y, size, size)
191
+ }
192
+ }
193
+
194
+ // Alpha gradient
195
+ const grad = ctx.createLinearGradient(0, 0, w, 0)
196
+ grad.addColorStop(0, `rgba(${rgb.r},${rgb.g},${rgb.b},0)`)
197
+ grad.addColorStop(1, `rgba(${rgb.r},${rgb.g},${rgb.b},1)`)
198
+ ctx.fillStyle = grad
199
+ ctx.fillRect(0, 0, w, h)
200
+ }
201
+
202
+ // ─── Pointer drag helper ────────────────────────────────────────
203
+
204
+ function usePointerDrag(onDrag: (x: number, y: number, rect: DOMRect) => void) {
205
+ const ref = useRef<HTMLCanvasElement>(null)
206
+
207
+ const handlePointerDown = useCallback(
208
+ (e: React.PointerEvent) => {
209
+ e.preventDefault()
210
+ const el = ref.current
211
+ if (!el) return
212
+ el.setPointerCapture(e.pointerId)
213
+ const rect = el.getBoundingClientRect()
214
+ onDrag(e.clientX - rect.left, e.clientY - rect.top, rect)
215
+ },
216
+ [onDrag],
217
+ )
218
+
219
+ const handlePointerMove = useCallback(
220
+ (e: React.PointerEvent) => {
221
+ const el = ref.current
222
+ if (!el || !el.hasPointerCapture(e.pointerId)) return
223
+ const rect = el.getBoundingClientRect()
224
+ onDrag(e.clientX - rect.left, e.clientY - rect.top, rect)
225
+ },
226
+ [onDrag],
227
+ )
228
+
229
+ return { ref, handlePointerDown, handlePointerMove }
230
+ }
231
+
232
+ // ─── Numeric scrub input ────────────────────────────────────────
233
+
234
+ function ScrubInput({
235
+ label,
236
+ value,
237
+ min,
238
+ max,
239
+ onChange,
240
+ }: {
241
+ label: string
242
+ value: number
243
+ min: number
244
+ max: number
245
+ onChange: (v: number) => void
246
+ }) {
247
+ const [editing, setEditing] = useState(false)
248
+ const [editVal, setEditVal] = useState('')
249
+ const ref = useRef<HTMLSpanElement>(null)
250
+ const isDragging = useRef(false)
251
+ const startX = useRef(0)
252
+ const startVal = useRef(0)
253
+
254
+ const commit = useCallback(
255
+ (raw: string) => {
256
+ const n = parseInt(raw, 10)
257
+ if (!isNaN(n)) onChange(Math.max(min, Math.min(max, n)))
258
+ setEditing(false)
259
+ },
260
+ [onChange, min, max],
261
+ )
262
+
263
+ return (
264
+ <div className="flex flex-col items-center gap-0.5">
265
+ {editing ? (
266
+ <input
267
+ autoFocus
268
+ type="text"
269
+ inputMode="numeric"
270
+ value={editVal}
271
+ onChange={(e) => setEditVal(e.target.value)}
272
+ onBlur={() => commit(editVal)}
273
+ onKeyDown={(e) => {
274
+ if (e.key === 'Enter') commit(editVal)
275
+ if (e.key === 'Escape') setEditing(false)
276
+ }}
277
+ className="w-8 text-center text-[10px] bg-transparent border-none outline-none rounded"
278
+ style={{
279
+ color: 'var(--text-primary)',
280
+ background: 'rgba(255,255,255,0.06)',
281
+ }}
282
+ />
283
+ ) : (
284
+ <span
285
+ ref={ref}
286
+ className="w-8 text-center text-[10px] select-none tabular-nums"
287
+ style={{ color: 'var(--text-primary)', cursor: 'ew-resize' }}
288
+ onDoubleClick={() => {
289
+ setEditVal(String(value))
290
+ setEditing(true)
291
+ }}
292
+ onPointerDown={(e) => {
293
+ e.preventDefault()
294
+ isDragging.current = true
295
+ startX.current = e.clientX
296
+ startVal.current = value
297
+ ref.current?.setPointerCapture(e.pointerId)
298
+ document.body.style.cursor = 'ew-resize'
299
+ document.body.style.userSelect = 'none'
300
+ }}
301
+ onPointerMove={(e) => {
302
+ if (!isDragging.current) return
303
+ const delta = e.clientX - startX.current
304
+ const mult = e.shiftKey ? 10 : 1
305
+ const next = Math.max(
306
+ min,
307
+ Math.min(max, startVal.current + delta * mult),
308
+ )
309
+ onChange(Math.round(next))
310
+ }}
311
+ onPointerUp={(e) => {
312
+ isDragging.current = false
313
+ ref.current?.releasePointerCapture(e.pointerId)
314
+ document.body.style.cursor = ''
315
+ document.body.style.userSelect = ''
316
+ }}
317
+ >
318
+ {value}
319
+ </span>
320
+ )}
321
+ <span
322
+ className="text-[8px] uppercase"
323
+ style={{ color: 'var(--text-muted)' }}
324
+ >
325
+ {label}
326
+ </span>
327
+ </div>
328
+ )
329
+ }
330
+
331
+ // ─── Main ColorPicker ───────────────────────────────────────────
332
+
333
+ interface ColorPickerProps {
334
+ value: string
335
+ onChange: (value: string) => void
336
+ onSelectVariable?: (varExpr: string) => void
337
+ label?: string
338
+ }
339
+
340
+ export function ColorPicker({
341
+ value,
342
+ onChange,
343
+ onSelectVariable,
344
+ label,
345
+ }: ColorPickerProps) {
346
+ const [isOpen, setIsOpen] = useState(false)
347
+ const [varsOpen, setVarsOpen] = useState(false)
348
+ const [varSearch, setVarSearch] = useState('')
349
+ const containerRef = useRef<HTMLDivElement>(null)
350
+ const popoverRef = useRef<HTMLDivElement>(null)
351
+
352
+ // CSS variable definitions from store
353
+ const definitions = useEditorStore((s) => s.cssVariableDefinitions)
354
+ const colorVars = useMemo(
355
+ () => filterColorVariables(definitions),
356
+ [definitions],
357
+ )
358
+ const filteredColorVars = useMemo(() => {
359
+ if (!varSearch) return colorVars
360
+ const lower = varSearch.toLowerCase()
361
+ const result: typeof colorVars = {}
362
+ for (const [name, def] of Object.entries(colorVars)) {
363
+ if (name.toLowerCase().includes(lower)) {
364
+ result[name] = def
365
+ }
366
+ }
367
+ return result
368
+ }, [colorVars, varSearch])
369
+ const hasColorVars = Object.keys(colorVars).length > 0
370
+
371
+ // Parse incoming color
372
+ const { rgb: initRgb, alpha: initAlpha } = parseColor(value)
373
+ const initHsv = rgbToHsv(initRgb)
374
+
375
+ const [hsv, setHsv] = useState<HSV>(initHsv)
376
+ const [alpha, setAlpha] = useState(initAlpha)
377
+ const [hexInput, setHexInput] = useState(rgbToHex(initRgb).slice(1))
378
+
379
+ // Canvas refs
380
+ const satCanvasRef = useRef<HTMLCanvasElement>(null)
381
+ const hueCanvasRef = useRef<HTMLCanvasElement>(null)
382
+ const alphaCanvasRef = useRef<HTMLCanvasElement>(null)
383
+
384
+ // Sync incoming value
385
+ useEffect(() => {
386
+ const { rgb, alpha: a } = parseColor(value)
387
+ const h = rgbToHsv(rgb)
388
+ setHsv(h)
389
+ setAlpha(a)
390
+ setHexInput(rgbToHex(rgb).slice(1))
391
+ }, [value])
392
+
393
+ // Emit color
394
+ const emit = useCallback(
395
+ (h: HSV, a: number) => {
396
+ const rgb = hsvToRgb(h)
397
+ onChange(formatOutput(rgb, a))
398
+ },
399
+ [onChange],
400
+ )
401
+
402
+ // Draw canvases
403
+ useEffect(() => {
404
+ if (!isOpen) return
405
+ if (satCanvasRef.current)
406
+ drawSaturationBrightness(satCanvasRef.current, hsv.h)
407
+ }, [isOpen, hsv.h])
408
+
409
+ useEffect(() => {
410
+ if (!isOpen) return
411
+ if (hueCanvasRef.current) drawHueSlider(hueCanvasRef.current)
412
+ }, [isOpen])
413
+
414
+ useEffect(() => {
415
+ if (!isOpen) return
416
+ if (alphaCanvasRef.current)
417
+ drawAlphaSlider(alphaCanvasRef.current, hsvToRgb(hsv))
418
+ }, [isOpen, hsv])
419
+
420
+ // When the popover closes, commit any pending hex input that hasn't
421
+ // been emitted yet (e.g., user typed a hex value and clicked outside
422
+ // before blur fired). Also hide/show the selection overlay.
423
+ const prevOpenRef = useRef(isOpen)
424
+ useEffect(() => {
425
+ if (isOpen) {
426
+ sendViaIframe({ type: 'HIDE_SELECTION_OVERLAY' })
427
+ } else {
428
+ sendViaIframe({ type: 'SHOW_SELECTION_OVERLAY' })
429
+ // Commit pending hex input on close
430
+ if (prevOpenRef.current) {
431
+ const currentHex = rgbToHex(hsvToRgb(hsv))
432
+ const pendingHex = '#' + hexInput
433
+ if (pendingHex !== currentHex) {
434
+ const rgb = hexToRgb(pendingHex)
435
+ if (rgb) {
436
+ const next = rgbToHsv(rgb)
437
+ setHsv(next)
438
+ emit(next, alpha)
439
+ }
440
+ }
441
+ }
442
+ }
443
+ prevOpenRef.current = isOpen
444
+ }, [isOpen, hsv, hexInput, alpha, emit])
445
+
446
+ // Click outside
447
+ useEffect(() => {
448
+ if (!isOpen) return
449
+ const handle = (e: MouseEvent) => {
450
+ if (
451
+ containerRef.current &&
452
+ !containerRef.current.contains(e.target as Node)
453
+ ) {
454
+ setIsOpen(false)
455
+ }
456
+ }
457
+ document.addEventListener('mousedown', handle)
458
+ return () => document.removeEventListener('mousedown', handle)
459
+ }, [isOpen])
460
+
461
+ // Saturation/brightness drag
462
+ const satDrag = usePointerDrag(
463
+ useCallback(
464
+ (x: number, y: number, rect: DOMRect) => {
465
+ const s = Math.round(Math.max(0, Math.min(100, (x / rect.width) * 100)))
466
+ const v = Math.round(
467
+ Math.max(0, Math.min(100, 100 - (y / rect.height) * 100)),
468
+ )
469
+ const next = { ...hsv, s, v }
470
+ setHsv(next)
471
+ setHexInput(rgbToHex(hsvToRgb(next)).slice(1))
472
+ emit(next, alpha)
473
+ },
474
+ [hsv, alpha, emit],
475
+ ),
476
+ )
477
+
478
+ // Hue drag
479
+ const hueDrag = usePointerDrag(
480
+ useCallback(
481
+ (x: number, _y: number, rect: DOMRect) => {
482
+ const h = Math.round(Math.max(0, Math.min(359, (x / rect.width) * 360)))
483
+ const next = { ...hsv, h }
484
+ setHsv(next)
485
+ setHexInput(rgbToHex(hsvToRgb(next)).slice(1))
486
+ emit(next, alpha)
487
+ },
488
+ [hsv, alpha, emit],
489
+ ),
490
+ )
491
+
492
+ // Alpha drag
493
+ const alphaDrag = usePointerDrag(
494
+ useCallback(
495
+ (x: number, _y: number, rect: DOMRect) => {
496
+ const a = Math.round(Math.max(0, Math.min(100, (x / rect.width) * 100)))
497
+ setAlpha(a)
498
+ emit(hsv, a)
499
+ },
500
+ [hsv, emit],
501
+ ),
502
+ )
503
+
504
+ // Hex commit
505
+ const commitHex = useCallback(() => {
506
+ const rgb = hexToRgb('#' + hexInput)
507
+ if (rgb) {
508
+ const next = rgbToHsv(rgb)
509
+ setHsv(next)
510
+ emit(next, alpha)
511
+ } else {
512
+ setHexInput(rgbToHex(hsvToRgb(hsv)).slice(1))
513
+ }
514
+ }, [hexInput, alpha, hsv, emit])
515
+
516
+ // HSV numeric changes
517
+ const updateH = useCallback(
518
+ (h: number) => {
519
+ const next = { ...hsv, h }
520
+ setHsv(next)
521
+ setHexInput(rgbToHex(hsvToRgb(next)).slice(1))
522
+ emit(next, alpha)
523
+ },
524
+ [hsv, alpha, emit],
525
+ )
526
+
527
+ const updateS = useCallback(
528
+ (s: number) => {
529
+ const next = { ...hsv, s }
530
+ setHsv(next)
531
+ setHexInput(rgbToHex(hsvToRgb(next)).slice(1))
532
+ emit(next, alpha)
533
+ },
534
+ [hsv, alpha, emit],
535
+ )
536
+
537
+ const updateV = useCallback(
538
+ (v: number) => {
539
+ const next = { ...hsv, v }
540
+ setHsv(next)
541
+ setHexInput(rgbToHex(hsvToRgb(next)).slice(1))
542
+ emit(next, alpha)
543
+ },
544
+ [hsv, alpha, emit],
545
+ )
546
+
547
+ const updateA = useCallback(
548
+ (a: number) => {
549
+ setAlpha(a)
550
+ emit(hsv, a)
551
+ },
552
+ [hsv, emit],
553
+ )
554
+
555
+ // Popover position
556
+ useEffect(() => {
557
+ if (!isOpen || !popoverRef.current || !containerRef.current) return
558
+ const popover = popoverRef.current
559
+ const trigger = containerRef.current
560
+ const triggerRect = trigger.getBoundingClientRect()
561
+ const popoverHeight = popover.offsetHeight
562
+
563
+ // Check if there's enough space below
564
+ const spaceBelow = window.innerHeight - triggerRect.bottom - 8
565
+ if (spaceBelow < popoverHeight) {
566
+ popover.style.bottom = '100%'
567
+ popover.style.top = 'auto'
568
+ popover.style.marginBottom = '4px'
569
+ popover.style.marginTop = '0'
570
+ } else {
571
+ popover.style.top = '100%'
572
+ popover.style.bottom = 'auto'
573
+ popover.style.marginTop = '4px'
574
+ popover.style.marginBottom = '0'
575
+ }
576
+ }, [isOpen])
577
+
578
+ const currentRgb = hsvToRgb(hsv)
579
+ const displayHex = alpha === 0 ? 'transparent' : rgbToHex(currentRgb)
580
+
581
+ // Saturation/brightness cursor position
582
+ const satCursorX = `${hsv.s}%`
583
+ const satCursorY = `${100 - hsv.v}%`
584
+
585
+ return (
586
+ <div className="flex items-center gap-2" ref={containerRef}>
587
+ {label && (
588
+ <label
589
+ className="text-[11px] w-16 flex-shrink-0 truncate"
590
+ style={{ color: 'var(--text-muted)' }}
591
+ >
592
+ {label}
593
+ </label>
594
+ )}
595
+ <div className="flex items-center gap-1 flex-1 relative">
596
+ {/* Color swatch trigger */}
597
+ <button
598
+ onClick={() => setIsOpen(!isOpen)}
599
+ className="w-6 h-6 rounded border flex-shrink-0"
600
+ style={{
601
+ background:
602
+ alpha === 0
603
+ ? `repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0 / 8px 8px`
604
+ : alpha < 100
605
+ ? `linear-gradient(${formatOutput(currentRgb, alpha)}, ${formatOutput(currentRgb, alpha)}), repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0 / 8px 8px`
606
+ : displayHex,
607
+ borderColor: 'var(--border)',
608
+ }}
609
+ />
610
+ {/* Hex text input */}
611
+ <input
612
+ type="text"
613
+ value={displayHex}
614
+ onChange={(e) => {
615
+ const v = e.target.value.replace('#', '')
616
+ setHexInput(v)
617
+ // Emit immediately when a valid 6-char hex is entered
618
+ const clean = v.replace(/[^0-9a-fA-F]/g, '')
619
+ if (clean.length === 6) {
620
+ const rgb = hexToRgb('#' + clean)
621
+ if (rgb) {
622
+ const next = rgbToHsv(rgb)
623
+ setHsv(next)
624
+ emit(next, alpha)
625
+ }
626
+ }
627
+ }}
628
+ onBlur={() => {
629
+ const rgb = hexToRgb('#' + hexInput)
630
+ if (rgb) {
631
+ const next = rgbToHsv(rgb)
632
+ setHsv(next)
633
+ emit(next, alpha)
634
+ }
635
+ }}
636
+ onKeyDown={(e) => {
637
+ if (e.key === 'Enter') commitHex()
638
+ }}
639
+ className="flex-1 min-w-0 text-xs py-1 px-2"
640
+ style={{
641
+ color: 'var(--text-primary)',
642
+ background: 'var(--bg-tertiary)',
643
+ border: '1px solid var(--border)',
644
+ borderRadius: '4px',
645
+ outline: 'none',
646
+ }}
647
+ />
648
+
649
+ {/* ─── Popover ─────────────────────────────── */}
650
+ {isOpen && (
651
+ <div
652
+ ref={popoverRef}
653
+ className="absolute left-0 rounded-lg shadow-xl z-50"
654
+ onMouseDown={(e) => e.stopPropagation()}
655
+ style={{
656
+ background: 'var(--bg-secondary, #252525)',
657
+ border: '1px solid var(--border)',
658
+ width: '232px',
659
+ padding: '8px',
660
+ }}
661
+ >
662
+ {/* Saturation / Brightness area */}
663
+ <div
664
+ className="relative mb-2 rounded overflow-hidden"
665
+ style={{ height: '140px' }}
666
+ >
667
+ <canvas
668
+ ref={(el) => {
669
+ ;(
670
+ satDrag.ref as React.MutableRefObject<HTMLCanvasElement | null>
671
+ ).current = el
672
+ ;(
673
+ satCanvasRef as React.MutableRefObject<HTMLCanvasElement | null>
674
+ ).current = el
675
+ }}
676
+ width={216}
677
+ height={140}
678
+ className="w-full h-full cursor-crosshair block"
679
+ onPointerDown={satDrag.handlePointerDown}
680
+ onPointerMove={satDrag.handlePointerMove}
681
+ />
682
+ {/* Circle cursor */}
683
+ <div
684
+ className="absolute w-3.5 h-3.5 rounded-full border-2 border-white pointer-events-none"
685
+ style={{
686
+ left: satCursorX,
687
+ top: satCursorY,
688
+ transform: 'translate(-50%, -50%)',
689
+ boxShadow: '0 0 2px rgba(0,0,0,0.6)',
690
+ }}
691
+ />
692
+ </div>
693
+
694
+ {/* Hue slider */}
695
+ <div
696
+ className="relative mb-1.5 rounded-full overflow-hidden"
697
+ style={{ height: '10px' }}
698
+ >
699
+ <canvas
700
+ ref={(el) => {
701
+ ;(
702
+ hueDrag.ref as React.MutableRefObject<HTMLCanvasElement | null>
703
+ ).current = el
704
+ ;(
705
+ hueCanvasRef as React.MutableRefObject<HTMLCanvasElement | null>
706
+ ).current = el
707
+ }}
708
+ width={216}
709
+ height={10}
710
+ className="w-full h-full cursor-pointer block"
711
+ onPointerDown={hueDrag.handlePointerDown}
712
+ onPointerMove={hueDrag.handlePointerMove}
713
+ />
714
+ <div
715
+ className="absolute top-1/2 w-2.5 h-2.5 rounded-full border-2 border-white pointer-events-none"
716
+ style={{
717
+ left: `${(hsv.h / 360) * 100}%`,
718
+ transform: 'translate(-50%, -50%)',
719
+ boxShadow: '0 0 2px rgba(0,0,0,0.4)',
720
+ }}
721
+ />
722
+ </div>
723
+
724
+ {/* Alpha slider */}
725
+ <div
726
+ className="relative mb-2 rounded-full overflow-hidden"
727
+ style={{ height: '10px' }}
728
+ >
729
+ <canvas
730
+ ref={(el) => {
731
+ ;(
732
+ alphaDrag.ref as React.MutableRefObject<HTMLCanvasElement | null>
733
+ ).current = el
734
+ ;(
735
+ alphaCanvasRef as React.MutableRefObject<HTMLCanvasElement | null>
736
+ ).current = el
737
+ }}
738
+ width={216}
739
+ height={10}
740
+ className="w-full h-full cursor-pointer block"
741
+ onPointerDown={alphaDrag.handlePointerDown}
742
+ onPointerMove={alphaDrag.handlePointerMove}
743
+ />
744
+ <div
745
+ className="absolute top-1/2 w-2.5 h-2.5 rounded-full border-2 border-white pointer-events-none"
746
+ style={{
747
+ left: `${alpha}%`,
748
+ transform: 'translate(-50%, -50%)',
749
+ boxShadow: '0 0 2px rgba(0,0,0,0.4)',
750
+ }}
751
+ />
752
+ </div>
753
+
754
+ {/* Hex input row */}
755
+ <div className="flex items-center gap-1 mb-1.5">
756
+ <div
757
+ className="flex items-center flex-1 h-6 rounded overflow-hidden"
758
+ style={{
759
+ background: 'var(--bg-tertiary)',
760
+ border: '1px solid var(--border)',
761
+ }}
762
+ >
763
+ <span
764
+ className="flex-shrink-0 px-1.5 text-[10px] h-full flex items-center"
765
+ style={{
766
+ color: 'var(--text-muted)',
767
+ borderRight: '1px solid var(--border)',
768
+ }}
769
+ >
770
+ #
771
+ </span>
772
+ <input
773
+ type="text"
774
+ value={hexInput}
775
+ onChange={(e) =>
776
+ setHexInput(
777
+ e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6),
778
+ )
779
+ }
780
+ onBlur={commitHex}
781
+ onKeyDown={(e) => {
782
+ if (e.key === 'Enter') commitHex()
783
+ }}
784
+ className="flex-1 min-w-0 h-full px-1 text-[10px] bg-transparent border-none outline-none"
785
+ style={{ color: 'var(--text-primary)' }}
786
+ />
787
+ </div>
788
+ </div>
789
+
790
+ {/* HSB + A numeric inputs */}
791
+ <div className="flex items-center justify-between px-1">
792
+ <ScrubInput
793
+ label="H"
794
+ value={hsv.h}
795
+ min={0}
796
+ max={359}
797
+ onChange={updateH}
798
+ />
799
+ <ScrubInput
800
+ label="S"
801
+ value={hsv.s}
802
+ min={0}
803
+ max={100}
804
+ onChange={updateS}
805
+ />
806
+ <ScrubInput
807
+ label="B"
808
+ value={hsv.v}
809
+ min={0}
810
+ max={100}
811
+ onChange={updateV}
812
+ />
813
+ <ScrubInput
814
+ label="A"
815
+ value={alpha}
816
+ min={0}
817
+ max={100}
818
+ onChange={updateA}
819
+ />
820
+ </div>
821
+
822
+ {/* ─── Variables section ─────────────────── */}
823
+ {hasColorVars && (
824
+ <div
825
+ className="mt-2"
826
+ style={{ borderTop: '1px solid var(--border)' }}
827
+ >
828
+ <button
829
+ type="button"
830
+ onClick={() => setVarsOpen(!varsOpen)}
831
+ className="w-full flex items-center justify-between py-1.5 px-0.5"
832
+ style={{ color: 'var(--text-secondary)' }}
833
+ >
834
+ <span className="text-[10px] font-medium">Variables</span>
835
+ <svg
836
+ width="10"
837
+ height="10"
838
+ viewBox="0 0 10 10"
839
+ className={`transition-transform ${varsOpen ? 'rotate-180' : ''}`}
840
+ style={{ fill: 'var(--text-muted)' }}
841
+ >
842
+ <path
843
+ d="M2 3.5L5 6.5L8 3.5"
844
+ fill="none"
845
+ stroke="currentColor"
846
+ strokeWidth="1.5"
847
+ />
848
+ </svg>
849
+ </button>
850
+
851
+ {varsOpen && (
852
+ <div>
853
+ {/* Search */}
854
+ <input
855
+ type="text"
856
+ placeholder="Filter variables..."
857
+ value={varSearch}
858
+ onChange={(e) => setVarSearch(e.target.value)}
859
+ className="w-full text-[10px] py-1 px-1.5 rounded mb-1"
860
+ style={{
861
+ background: 'var(--bg-primary, #1e1e1e)',
862
+ border: '1px solid var(--border)',
863
+ color: 'var(--text-primary)',
864
+ outline: 'none',
865
+ }}
866
+ autoFocus
867
+ />
868
+
869
+ {/* Scrollable list */}
870
+ <div
871
+ className="overflow-y-auto"
872
+ style={{ maxHeight: '140px' }}
873
+ >
874
+ {Object.entries(filteredColorVars).length === 0 && (
875
+ <div
876
+ className="text-[10px] px-1 py-2 text-center"
877
+ style={{ color: 'var(--text-muted)' }}
878
+ >
879
+ No matching variables
880
+ </div>
881
+ )}
882
+ {Object.entries(filteredColorVars).map(([name, def]) => (
883
+ <button
884
+ key={name}
885
+ type="button"
886
+ onClick={() => {
887
+ const expr = `var(${name})`
888
+ if (onSelectVariable) {
889
+ onSelectVariable(expr)
890
+ } else {
891
+ onChange(expr)
892
+ }
893
+ setIsOpen(false)
894
+ setVarsOpen(false)
895
+ setVarSearch('')
896
+ }}
897
+ className="w-full flex items-center gap-1.5 px-1 py-1 rounded text-[10px] hover:opacity-80"
898
+ style={{ color: 'var(--text-primary)' }}
899
+ >
900
+ <div
901
+ className="w-3.5 h-3.5 rounded border flex-shrink-0"
902
+ style={{
903
+ background: toDisplayableColor(def.resolvedValue),
904
+ borderColor: 'var(--border)',
905
+ }}
906
+ />
907
+ <span className="truncate">{name}</span>
908
+ </button>
909
+ ))}
910
+ </div>
911
+ </div>
912
+ )}
913
+ </div>
914
+ )}
915
+ </div>
916
+ )}
917
+ </div>
918
+ </div>
919
+ )
920
+ }