@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,622 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useMemo } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { ColorPicker } from './ColorPicker'
6
+ import {
7
+ extractVariableName,
8
+ filterColorVariables,
9
+ formatTokenDisplayName,
10
+ toDisplayableColor,
11
+ } from '@/lib/cssVariableUtils'
12
+ import type {
13
+ CSSVariableFamily,
14
+ CSSVariableFamilyMember,
15
+ } from '@/types/cssVariables'
16
+
17
+ // ─── Inner Sub-Components ────────────────────────────────────────
18
+
19
+ function TokenRow({
20
+ name,
21
+ resolvedValue,
22
+ isActive,
23
+ tailwindClass,
24
+ onSelect,
25
+ }: {
26
+ name: string
27
+ resolvedValue: string
28
+ isActive: boolean
29
+ tailwindClass?: string
30
+ onSelect: () => void
31
+ }) {
32
+ const displayName = formatTokenDisplayName(name)
33
+
34
+ const opacityPercent = useMemo(() => {
35
+ const match = resolvedValue.match(/rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/)
36
+ if (match) return Math.round(parseFloat(match[1]) * 100)
37
+ return 100
38
+ }, [resolvedValue])
39
+
40
+ return (
41
+ <button
42
+ onClick={onSelect}
43
+ className="token-row w-full flex items-center gap-2 px-3 py-1 text-left"
44
+ style={{
45
+ background: isActive ? 'var(--accent-bg)' : 'transparent',
46
+ color: 'var(--text-primary)',
47
+ }}
48
+ >
49
+ <div
50
+ className="w-4 h-4 rounded flex-shrink-0"
51
+ style={{
52
+ background: toDisplayableColor(resolvedValue) || 'transparent',
53
+ border: '1px solid rgba(255,255,255,0.1)',
54
+ boxShadow: isActive ? '0 0 0 1.5px var(--accent)' : 'none',
55
+ }}
56
+ />
57
+ <span className="flex-1 text-[11px] truncate">{displayName}</span>
58
+ {tailwindClass && (
59
+ <span
60
+ className="text-[9px] px-1 py-px rounded flex-shrink-0"
61
+ style={{ background: 'var(--bg-hover)', color: 'var(--text-muted)' }}
62
+ >
63
+ {tailwindClass}
64
+ </span>
65
+ )}
66
+ {opacityPercent < 100 && (
67
+ <span
68
+ className="text-[10px] flex-shrink-0 tabular-nums"
69
+ style={{ color: 'var(--text-muted)' }}
70
+ >
71
+ {opacityPercent}%
72
+ </span>
73
+ )}
74
+ {isActive && (
75
+ <svg
76
+ width="10"
77
+ height="10"
78
+ viewBox="0 0 10 10"
79
+ className="flex-shrink-0"
80
+ style={{ color: 'var(--accent)' }}
81
+ >
82
+ <path
83
+ d="M2 5L4.5 7.5L8 3"
84
+ fill="none"
85
+ stroke="currentColor"
86
+ strokeWidth="1.5"
87
+ strokeLinecap="round"
88
+ />
89
+ </svg>
90
+ )}
91
+ </button>
92
+ )
93
+ }
94
+
95
+ function FamilySection({
96
+ family,
97
+ activeVarName,
98
+ isCollapsed,
99
+ tailwindClassForVar,
100
+ onToggleCollapse,
101
+ onSelect,
102
+ }: {
103
+ family: CSSVariableFamily
104
+ activeVarName: string | null
105
+ isCollapsed: boolean
106
+ tailwindClassForVar?: Record<string, string>
107
+ onToggleCollapse: () => void
108
+ onSelect: (name: string) => void
109
+ }) {
110
+ const displayPrefix = formatTokenDisplayName(family.prefix)
111
+
112
+ return (
113
+ <div className="family-section">
114
+ <button
115
+ onClick={onToggleCollapse}
116
+ className="family-header w-full flex items-center gap-1.5 px-3 py-1.5 text-left"
117
+ style={{ color: 'var(--text-secondary)' }}
118
+ >
119
+ <svg
120
+ width="8"
121
+ height="8"
122
+ viewBox="0 0 8 8"
123
+ className={`flex-shrink-0 transition-transform ${isCollapsed ? '-rotate-90' : ''}`}
124
+ >
125
+ <path
126
+ d="M1.5 2L4 5.5L6.5 2"
127
+ fill="none"
128
+ stroke="currentColor"
129
+ strokeWidth="1.2"
130
+ />
131
+ </svg>
132
+ <span className="text-[10px] font-medium tracking-wide">
133
+ {displayPrefix}
134
+ </span>
135
+ <span
136
+ className="text-[9px] ml-auto"
137
+ style={{ color: 'var(--text-muted)' }}
138
+ >
139
+ {family.members.length}
140
+ </span>
141
+ </button>
142
+ {!isCollapsed &&
143
+ family.members.map((member: CSSVariableFamilyMember) => (
144
+ <TokenRow
145
+ key={member.name}
146
+ name={member.name}
147
+ resolvedValue={member.resolvedValue}
148
+ isActive={member.name === activeVarName}
149
+ tailwindClass={tailwindClassForVar?.[member.name]}
150
+ onSelect={() => onSelect(member.name)}
151
+ />
152
+ ))}
153
+ </div>
154
+ )
155
+ }
156
+
157
+ // ─── Main Component ──────────────────────────────────────────────
158
+
159
+ interface VariableColorPickerProps {
160
+ label: string
161
+ property: string
162
+ value: string
163
+ varExpression?: string
164
+ tailwindClassName?: string
165
+ onChange: (property: string, value: string) => void
166
+ onDetach: () => void
167
+ onReattach: (varExpression: string) => void
168
+ }
169
+
170
+ export function VariableColorPicker({
171
+ label,
172
+ property,
173
+ value,
174
+ varExpression,
175
+ tailwindClassName,
176
+ onChange,
177
+ onDetach,
178
+ onReattach,
179
+ }: VariableColorPickerProps) {
180
+ const [isOpen, setIsOpen] = useState(false)
181
+ const [activeTab, setActiveTab] = useState<'libraries' | 'custom'>(
182
+ 'libraries',
183
+ )
184
+ const [search, setSearch] = useState('')
185
+ const [collapsedFamilies, setCollapsedFamilies] = useState<Set<string>>(
186
+ new Set(),
187
+ )
188
+
189
+ const containerRef = useRef<HTMLDivElement>(null)
190
+ const panelRef = useRef<HTMLDivElement>(null)
191
+
192
+ const selectorPath = useEditorStore((s) => s.selectorPath)
193
+ const isDetached = useEditorStore((s) =>
194
+ selectorPath ? s.isPropertyDetached(selectorPath, property) : false,
195
+ )
196
+ const definitions = useEditorStore((s) => s.cssVariableDefinitions)
197
+ const families = useEditorStore((s) => s.cssVariableFamilies)
198
+
199
+ const tailwindClassMap = useEditorStore((s) => s.tailwindClassMap)
200
+
201
+ const varName = varExpression ? extractVariableName(varExpression) : null
202
+ const displayName = useMemo(
203
+ () => (varName ? formatTokenDisplayName(varName) : null),
204
+ [varName],
205
+ )
206
+ const colorVars = useMemo(
207
+ () => filterColorVariables(definitions),
208
+ [definitions],
209
+ )
210
+
211
+ // Build reverse map: variable name → tailwind class name (for token row badges)
212
+ const tailwindClassForVar = useMemo(() => {
213
+ const map: Record<string, string> = {}
214
+ for (const entry of Object.values(tailwindClassMap)) {
215
+ if (entry.variableName) {
216
+ map[entry.variableName] = entry.className
217
+ }
218
+ }
219
+ return map
220
+ }, [tailwindClassMap])
221
+
222
+ // Filter families to color-only members
223
+ const colorOnlyFamilies = useMemo(() => {
224
+ return families
225
+ .map((fam) => ({
226
+ ...fam,
227
+ members: fam.members.filter((m) => m.name in colorVars),
228
+ }))
229
+ .filter((fam) => fam.members.length >= 2)
230
+ }, [families, colorVars])
231
+
232
+ // Track which vars are already in a family
233
+ const familyMemberNames = useMemo(() => {
234
+ const set = new Set<string>()
235
+ colorOnlyFamilies.forEach((fam) =>
236
+ fam.members.forEach((m) => set.add(m.name)),
237
+ )
238
+ return set
239
+ }, [colorOnlyFamilies])
240
+
241
+ // Ungrouped = color vars not in any family
242
+ const ungroupedVars = useMemo(() => {
243
+ return Object.entries(colorVars).filter(
244
+ ([name]) => !familyMemberNames.has(name),
245
+ )
246
+ }, [colorVars, familyMemberNames])
247
+
248
+ // Search filtering
249
+ const filteredFamilies = useMemo(() => {
250
+ if (!search) return colorOnlyFamilies
251
+ const lower = search.toLowerCase()
252
+ return colorOnlyFamilies
253
+ .map((fam) => ({
254
+ ...fam,
255
+ members: fam.members.filter((m) =>
256
+ m.name.toLowerCase().includes(lower),
257
+ ),
258
+ }))
259
+ .filter((fam) => fam.members.length > 0)
260
+ }, [colorOnlyFamilies, search])
261
+
262
+ const filteredUngrouped = useMemo(() => {
263
+ if (!search) return ungroupedVars
264
+ const lower = search.toLowerCase()
265
+ return ungroupedVars.filter(([name]) => name.toLowerCase().includes(lower))
266
+ }, [ungroupedVars, search])
267
+
268
+ // Click outside
269
+ useEffect(() => {
270
+ const handleClickOutside = (e: MouseEvent) => {
271
+ if (
272
+ containerRef.current &&
273
+ !containerRef.current.contains(e.target as Node)
274
+ ) {
275
+ setIsOpen(false)
276
+ setSearch('')
277
+ }
278
+ }
279
+ document.addEventListener('mousedown', handleClickOutside)
280
+ return () => document.removeEventListener('mousedown', handleClickOutside)
281
+ }, [])
282
+
283
+ // Escape key
284
+ useEffect(() => {
285
+ if (!isOpen) return
286
+ const handleKey = (e: KeyboardEvent) => {
287
+ if (e.key === 'Escape') {
288
+ setIsOpen(false)
289
+ setSearch('')
290
+ }
291
+ }
292
+ document.addEventListener('keydown', handleKey)
293
+ return () => document.removeEventListener('keydown', handleKey)
294
+ }, [isOpen])
295
+
296
+ // Viewport flip
297
+ useEffect(() => {
298
+ if (!isOpen || !panelRef.current || !containerRef.current) return
299
+ const panel = panelRef.current
300
+ const triggerRect = containerRef.current.getBoundingClientRect()
301
+ const panelHeight = panel.offsetHeight
302
+ const spaceBelow = window.innerHeight - triggerRect.bottom - 8
303
+
304
+ if (spaceBelow < panelHeight && triggerRect.top > panelHeight) {
305
+ panel.style.top = 'auto'
306
+ panel.style.bottom = '100%'
307
+ panel.style.marginBottom = '4px'
308
+ panel.style.marginTop = '0'
309
+ } else {
310
+ panel.style.top = '100%'
311
+ panel.style.bottom = 'auto'
312
+ panel.style.marginTop = '4px'
313
+ panel.style.marginBottom = '0'
314
+ }
315
+ }, [isOpen])
316
+
317
+ // Reset tab when opening: Libraries if variable assigned, Custom otherwise
318
+ useEffect(() => {
319
+ if (isOpen) {
320
+ setActiveTab(varExpression ? 'libraries' : 'custom')
321
+ setSearch('')
322
+ }
323
+ }, [isOpen, varExpression])
324
+
325
+ // ─── Mode A: Detached — show standard ColorPicker with reattach link ───
326
+ if (isDetached && varExpression) {
327
+ return (
328
+ <div>
329
+ <ColorPicker
330
+ label={label}
331
+ value={value}
332
+ onChange={(val) => onChange(property, val)}
333
+ />
334
+ <button
335
+ onClick={() => onReattach(varExpression)}
336
+ className="text-[10px] ml-[72px] mt-0.5 hover:underline"
337
+ style={{ color: 'var(--accent)' }}
338
+ >
339
+ Reattach to {varName}
340
+ </button>
341
+ </div>
342
+ )
343
+ }
344
+
345
+ const handleSelectVariable = (name: string) => {
346
+ onChange(property, `var(${name})`)
347
+ setIsOpen(false)
348
+ setSearch('')
349
+ }
350
+
351
+ const handleCustomColorPick = (val: string) => {
352
+ if (varExpression) onDetach()
353
+ onChange(property, val)
354
+ }
355
+
356
+ const toggleFamily = (prefix: string) => {
357
+ setCollapsedFamilies((prev) => {
358
+ const next = new Set(prev)
359
+ if (next.has(prefix)) next.delete(prefix)
360
+ else next.add(prefix)
361
+ return next
362
+ })
363
+ }
364
+
365
+ return (
366
+ <div className="variable-color-picker relative" ref={containerRef}>
367
+ {/* ─── Trigger Row ─────────────────────────────── */}
368
+ <div className="flex items-center gap-2">
369
+ {label && (
370
+ <label
371
+ className="text-[11px] w-16 flex-shrink-0 truncate"
372
+ style={{ color: 'var(--text-muted)' }}
373
+ >
374
+ {label}
375
+ </label>
376
+ )}
377
+ <button
378
+ onClick={() => setIsOpen(!isOpen)}
379
+ className="flex-1 flex items-center gap-1.5 px-1.5 py-1 rounded min-w-0"
380
+ style={{
381
+ background: 'var(--bg-tertiary)',
382
+ border: `1px solid ${isOpen ? 'var(--accent)' : 'var(--border)'}`,
383
+ color: 'var(--text-primary)',
384
+ }}
385
+ >
386
+ <div
387
+ className="w-4 h-4 rounded flex-shrink-0"
388
+ style={{
389
+ background: value || 'transparent',
390
+ border: '1px solid rgba(255,255,255,0.12)',
391
+ }}
392
+ />
393
+ <span className="flex-1 text-[11px] truncate text-left flex items-center gap-1">
394
+ <span className="truncate">
395
+ {displayName ||
396
+ varName ||
397
+ varExpression ||
398
+ value ||
399
+ 'Select variable'}
400
+ </span>
401
+ {tailwindClassName && (
402
+ <span
403
+ className="text-[9px] px-1 py-px rounded flex-shrink-0"
404
+ style={{
405
+ background: 'var(--bg-hover)',
406
+ color: 'var(--text-muted)',
407
+ }}
408
+ >
409
+ {tailwindClassName}
410
+ </span>
411
+ )}
412
+ </span>
413
+ <svg
414
+ width="9"
415
+ height="9"
416
+ viewBox="0 0 10 10"
417
+ className={`flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
418
+ style={{ color: 'var(--text-muted)' }}
419
+ >
420
+ <path
421
+ d="M2 3.5L5 6.5L8 3.5"
422
+ fill="none"
423
+ stroke="currentColor"
424
+ strokeWidth="1.5"
425
+ />
426
+ </svg>
427
+ </button>
428
+ </div>
429
+
430
+ {/* ─── Library Panel ───────────────────────────── */}
431
+ {isOpen && (
432
+ <div
433
+ ref={panelRef}
434
+ className="absolute left-0 right-0 rounded-lg shadow-xl z-[9999] flex flex-col"
435
+ onMouseDown={(e) => e.stopPropagation()}
436
+ style={{
437
+ background: 'var(--bg-secondary)',
438
+ border: '1px solid var(--border)',
439
+ maxHeight: '380px',
440
+ }}
441
+ >
442
+ {/* Tab Header */}
443
+ <div
444
+ className="flex items-center justify-between px-2 pt-2 pb-1.5 flex-shrink-0"
445
+ style={{ borderBottom: '1px solid var(--border)' }}
446
+ >
447
+ <div className="flex items-center gap-0.5">
448
+ <button
449
+ onClick={() => setActiveTab('libraries')}
450
+ className="text-[11px] px-2.5 py-1 rounded transition-colors"
451
+ style={{
452
+ background:
453
+ activeTab === 'libraries'
454
+ ? 'var(--bg-hover)'
455
+ : 'transparent',
456
+ color:
457
+ activeTab === 'libraries'
458
+ ? 'var(--text-primary)'
459
+ : 'var(--text-muted)',
460
+ }}
461
+ >
462
+ Libraries
463
+ </button>
464
+ <button
465
+ onClick={() => setActiveTab('custom')}
466
+ className="text-[11px] px-2.5 py-1 rounded transition-colors"
467
+ style={{
468
+ background:
469
+ activeTab === 'custom' ? 'var(--bg-hover)' : 'transparent',
470
+ color:
471
+ activeTab === 'custom'
472
+ ? 'var(--text-primary)'
473
+ : 'var(--text-muted)',
474
+ }}
475
+ >
476
+ Custom
477
+ </button>
478
+ </div>
479
+ <button
480
+ onClick={() => {
481
+ setIsOpen(false)
482
+ setSearch('')
483
+ }}
484
+ className="p-0.5 rounded"
485
+ style={{ color: 'var(--text-muted)' }}
486
+ >
487
+ <svg width="10" height="10" viewBox="0 0 10 10">
488
+ <path
489
+ d="M2 2L8 8M8 2L2 8"
490
+ stroke="currentColor"
491
+ strokeWidth="1.5"
492
+ strokeLinecap="round"
493
+ />
494
+ </svg>
495
+ </button>
496
+ </div>
497
+
498
+ {/* ─── Libraries Tab ───────────────────────── */}
499
+ {activeTab === 'libraries' && (
500
+ <>
501
+ {/* Search */}
502
+ <div
503
+ className="px-2 py-1.5 flex-shrink-0"
504
+ style={{ borderBottom: '1px solid var(--border)' }}
505
+ >
506
+ <div className="relative">
507
+ <svg
508
+ width="12"
509
+ height="12"
510
+ viewBox="0 0 12 12"
511
+ className="absolute left-2 top-1/2 -translate-y-1/2"
512
+ style={{ color: 'var(--text-muted)' }}
513
+ >
514
+ <circle
515
+ cx="5"
516
+ cy="5"
517
+ r="3.5"
518
+ fill="none"
519
+ stroke="currentColor"
520
+ strokeWidth="1.2"
521
+ />
522
+ <path
523
+ d="M7.5 7.5L10 10"
524
+ stroke="currentColor"
525
+ strokeWidth="1.2"
526
+ strokeLinecap="round"
527
+ />
528
+ </svg>
529
+ <input
530
+ autoFocus
531
+ type="text"
532
+ placeholder="Search tokens..."
533
+ value={search}
534
+ onChange={(e) => setSearch(e.target.value)}
535
+ className="w-full text-[11px] py-1.5 pl-7 pr-2 rounded"
536
+ style={{
537
+ background: 'var(--bg-primary)',
538
+ border: '1px solid var(--border)',
539
+ color: 'var(--text-primary)',
540
+ outline: 'none',
541
+ }}
542
+ />
543
+ </div>
544
+ </div>
545
+
546
+ {/* Scrollable token list */}
547
+ <div
548
+ className="overflow-y-auto flex-1 py-1"
549
+ style={{ maxHeight: '300px' }}
550
+ >
551
+ {filteredFamilies.map((family) => (
552
+ <FamilySection
553
+ key={family.prefix}
554
+ family={family}
555
+ activeVarName={varName}
556
+ isCollapsed={collapsedFamilies.has(family.prefix)}
557
+ tailwindClassForVar={tailwindClassForVar}
558
+ onToggleCollapse={() => toggleFamily(family.prefix)}
559
+ onSelect={handleSelectVariable}
560
+ />
561
+ ))}
562
+
563
+ {/* Divider between families and ungrouped */}
564
+ {filteredFamilies.length > 0 &&
565
+ filteredUngrouped.length > 0 && (
566
+ <div
567
+ className="h-px mx-3 my-1"
568
+ style={{ background: 'var(--border)' }}
569
+ />
570
+ )}
571
+
572
+ {/* Ungrouped tokens */}
573
+ {filteredUngrouped.length > 0 && (
574
+ <div>
575
+ {!search && (
576
+ <div
577
+ className="text-[10px] font-medium px-3 py-1"
578
+ style={{ color: 'var(--text-muted)' }}
579
+ >
580
+ Other
581
+ </div>
582
+ )}
583
+ {filteredUngrouped.map(([name, def]) => (
584
+ <TokenRow
585
+ key={name}
586
+ name={name}
587
+ resolvedValue={def.resolvedValue}
588
+ isActive={name === varName}
589
+ tailwindClass={tailwindClassForVar[name]}
590
+ onSelect={() => handleSelectVariable(name)}
591
+ />
592
+ ))}
593
+ </div>
594
+ )}
595
+
596
+ {/* Empty state */}
597
+ {filteredFamilies.length === 0 &&
598
+ filteredUngrouped.length === 0 && (
599
+ <div
600
+ className="text-center py-6 text-[11px]"
601
+ style={{ color: 'var(--text-muted)' }}
602
+ >
603
+ {search
604
+ ? `No tokens match "${search}"`
605
+ : 'No color tokens detected'}
606
+ </div>
607
+ )}
608
+ </div>
609
+ </>
610
+ )}
611
+
612
+ {/* ─── Custom Tab ──────────────────────────── */}
613
+ {activeTab === 'custom' && (
614
+ <div className="p-2">
615
+ <ColorPicker value={value} onChange={handleCustomColorPick} />
616
+ </div>
617
+ )}
618
+ </div>
619
+ )}
620
+ </div>
621
+ )
622
+ }