@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,79 @@
1
+ 'use client'
2
+
3
+ import React, { useState } from 'react'
4
+
5
+ interface SectionHeaderProps {
6
+ title: string
7
+ defaultOpen?: boolean
8
+ actions?: React.ReactNode
9
+ children: React.ReactNode
10
+ hasChanges?: boolean
11
+ onReset?: () => void
12
+ }
13
+
14
+ export function SectionHeader({
15
+ title,
16
+ defaultOpen = true,
17
+ actions,
18
+ children,
19
+ hasChanges,
20
+ onReset,
21
+ }: SectionHeaderProps) {
22
+ const [isOpen, setIsOpen] = useState(defaultOpen)
23
+
24
+ return (
25
+ <div style={{ borderBottom: '1px solid var(--border)' }}>
26
+ <div
27
+ role="button"
28
+ tabIndex={0}
29
+ onClick={() => setIsOpen(!isOpen)}
30
+ onKeyDown={(e) => {
31
+ if (e.key === 'Enter' || e.key === ' ') {
32
+ e.preventDefault()
33
+ setIsOpen(!isOpen)
34
+ }
35
+ }}
36
+ className="flex items-center justify-between w-full px-3 py-3 text-sm font-semibold hover:bg-[var(--bg-hover)] transition-colors cursor-pointer select-none"
37
+ style={{ color: 'var(--text-secondary)' }}
38
+ >
39
+ <span className="flex items-center">
40
+ <span
41
+ className="mr-2 text-[10px] transition-transform"
42
+ style={{ transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)' }}
43
+ >
44
+
45
+ </span>
46
+ {title}
47
+ {hasChanges && (
48
+ <span
49
+ className="ml-1.5 w-1.5 h-1.5 rounded-full flex-shrink-0"
50
+ style={{ background: 'var(--accent)' }}
51
+ />
52
+ )}
53
+ </span>
54
+ <span
55
+ onClick={(e) => e.stopPropagation()}
56
+ className="flex items-center gap-1"
57
+ >
58
+ {hasChanges && onReset && (
59
+ <button
60
+ type="button"
61
+ onClick={onReset}
62
+ className="text-[9px] px-1.5 py-0.5 rounded hover:opacity-80"
63
+ style={{
64
+ color: '#f87171',
65
+ background: 'rgba(248, 113, 113, 0.10)',
66
+ border: 'none',
67
+ cursor: 'pointer',
68
+ }}
69
+ >
70
+ Reset
71
+ </button>
72
+ )}
73
+ {actions}
74
+ </span>
75
+ </div>
76
+ {isOpen && <div className="px-3 pb-3 space-y-2">{children}</div>}
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,388 @@
1
+ 'use client'
2
+
3
+ import { useState, useMemo, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import {
6
+ formatTokenDisplayName,
7
+ isColorValue,
8
+ toDisplayableColor,
9
+ } from '@/lib/cssVariableUtils'
10
+ import type {
11
+ CSSVariableFamily,
12
+ CSSVariableFamilyMember,
13
+ } from '@/types/cssVariables'
14
+
15
+ // ─── Filter types ────────────────────────────────────────────────
16
+
17
+ type FilterType = 'all' | 'colors' | 'sizing' | 'other'
18
+
19
+ function classifyValue(value: string): FilterType {
20
+ if (isColorValue(value)) return 'colors'
21
+ const trimmed = value.trim().toLowerCase()
22
+ if (
23
+ /^-?[\d.]+\s*(px|rem|em|%|vh|vw|vmin|vmax|ch|ex|svh|dvh|lvh|cqw|cqi)$/.test(
24
+ trimmed,
25
+ )
26
+ )
27
+ return 'sizing'
28
+ return 'other'
29
+ }
30
+
31
+ // ─── Variable Row ────────────────────────────────────────────────
32
+
33
+ function VariableRow({
34
+ name,
35
+ value,
36
+ resolvedValue,
37
+ onCopy,
38
+ }: {
39
+ name: string
40
+ value: string
41
+ resolvedValue: string
42
+ onCopy: (varName: string) => void
43
+ }) {
44
+ const displayName = formatTokenDisplayName(name)
45
+ const isColor = isColorValue(resolvedValue)
46
+
47
+ return (
48
+ <button
49
+ onClick={() => onCopy(name)}
50
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:brightness-110 transition-colors"
51
+ style={{ background: 'transparent' }}
52
+ title={`Click to copy var(${name})\nValue: ${value}`}
53
+ >
54
+ {isColor ? (
55
+ <div
56
+ className="w-4 h-4 rounded flex-shrink-0"
57
+ style={{
58
+ background: toDisplayableColor(resolvedValue),
59
+ border: '1px solid rgba(255,255,255,0.1)',
60
+ }}
61
+ />
62
+ ) : (
63
+ <div
64
+ className="w-4 h-4 rounded flex-shrink-0 flex items-center justify-center text-[8px] font-bold"
65
+ style={{
66
+ background: 'var(--bg-input)',
67
+ border: '1px solid var(--border)',
68
+ color: 'var(--text-muted)',
69
+ }}
70
+ >
71
+ V
72
+ </div>
73
+ )}
74
+ <span
75
+ className="flex-1 text-[11px] truncate"
76
+ style={{ color: 'var(--text-primary)' }}
77
+ >
78
+ {displayName}
79
+ </span>
80
+ <span
81
+ className="text-[10px] flex-shrink-0 truncate max-w-[100px] text-right tabular-nums"
82
+ style={{ color: 'var(--text-muted)' }}
83
+ >
84
+ {value}
85
+ </span>
86
+ </button>
87
+ )
88
+ }
89
+
90
+ // ─── Family Section ──────────────────────────────────────────────
91
+
92
+ function FamilySection({
93
+ family,
94
+ isCollapsed,
95
+ onToggleCollapse,
96
+ onCopy,
97
+ }: {
98
+ family: CSSVariableFamily
99
+ isCollapsed: boolean
100
+ onToggleCollapse: () => void
101
+ onCopy: (varName: string) => void
102
+ }) {
103
+ const displayPrefix = formatTokenDisplayName(family.prefix)
104
+
105
+ return (
106
+ <div>
107
+ <button
108
+ onClick={onToggleCollapse}
109
+ className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left"
110
+ style={{ color: 'var(--text-secondary)' }}
111
+ >
112
+ <svg
113
+ width="8"
114
+ height="8"
115
+ viewBox="0 0 8 8"
116
+ className={`flex-shrink-0 transition-transform ${isCollapsed ? '-rotate-90' : ''}`}
117
+ >
118
+ <path
119
+ d="M1.5 2L4 5.5L6.5 2"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="1.2"
123
+ />
124
+ </svg>
125
+ <span className="text-[10px] font-medium tracking-wide">
126
+ {displayPrefix}
127
+ </span>
128
+ <span
129
+ className="text-[9px] ml-auto"
130
+ style={{ color: 'var(--text-muted)' }}
131
+ >
132
+ {family.members.length}
133
+ </span>
134
+ </button>
135
+ {!isCollapsed &&
136
+ family.members.map((member: CSSVariableFamilyMember) => (
137
+ <VariableRow
138
+ key={member.name}
139
+ name={member.name}
140
+ value={member.value}
141
+ resolvedValue={member.resolvedValue}
142
+ onCopy={onCopy}
143
+ />
144
+ ))}
145
+ </div>
146
+ )
147
+ }
148
+
149
+ // ─── Main Panel ──────────────────────────────────────────────────
150
+
151
+ export function VariablesPanel() {
152
+ const definitions = useEditorStore((s) => s.cssVariableDefinitions)
153
+ const families = useEditorStore((s) => s.cssVariableFamilies)
154
+ const showToast = useEditorStore((s) => s.showToast)
155
+
156
+ const [search, setSearch] = useState('')
157
+ const [filter, setFilter] = useState<FilterType>('all')
158
+ const [collapsedFamilies, setCollapsedFamilies] = useState<Set<string>>(
159
+ new Set(),
160
+ )
161
+
162
+ const totalCount = Object.keys(definitions).length
163
+
164
+ // Filter definitions by type
165
+ const filteredDefinitions = useMemo(() => {
166
+ if (filter === 'all') return definitions
167
+ const result: typeof definitions = {}
168
+ for (const [name, def] of Object.entries(definitions)) {
169
+ if (classifyValue(def.resolvedValue) === filter) {
170
+ result[name] = def
171
+ }
172
+ }
173
+ return result
174
+ }, [definitions, filter])
175
+
176
+ // Filter families to only include members matching filter + search
177
+ const filteredFamilies = useMemo(() => {
178
+ const lower = search.toLowerCase()
179
+ return families
180
+ .map((fam) => ({
181
+ ...fam,
182
+ members: fam.members.filter((m) => {
183
+ if (!(m.name in filteredDefinitions)) return false
184
+ if (search && !m.name.toLowerCase().includes(lower)) return false
185
+ return true
186
+ }),
187
+ }))
188
+ .filter((fam) => fam.members.length > 0)
189
+ }, [families, filteredDefinitions, search])
190
+
191
+ // Track which vars are in families
192
+ const familyMemberNames = useMemo(() => {
193
+ const set = new Set<string>()
194
+ filteredFamilies.forEach((fam) =>
195
+ fam.members.forEach((m) => set.add(m.name)),
196
+ )
197
+ return set
198
+ }, [filteredFamilies])
199
+
200
+ // Ungrouped = filtered vars not in any family
201
+ const ungroupedVars = useMemo(() => {
202
+ const lower = search.toLowerCase()
203
+ return Object.entries(filteredDefinitions).filter(
204
+ ([name]) =>
205
+ !familyMemberNames.has(name) &&
206
+ (!search || name.toLowerCase().includes(lower)),
207
+ )
208
+ }, [filteredDefinitions, familyMemberNames, search])
209
+
210
+ const visibleCount =
211
+ filteredFamilies.reduce((sum, f) => sum + f.members.length, 0) +
212
+ ungroupedVars.length
213
+
214
+ const handleCopy = useCallback(
215
+ (varName: string) => {
216
+ navigator.clipboard.writeText(`var(${varName})`)
217
+ showToast('info', `Copied var(${varName})`)
218
+ },
219
+ [showToast],
220
+ )
221
+
222
+ const toggleFamily = useCallback((prefix: string) => {
223
+ setCollapsedFamilies((prev) => {
224
+ const next = new Set(prev)
225
+ if (next.has(prefix)) next.delete(prefix)
226
+ else next.add(prefix)
227
+ return next
228
+ })
229
+ }, [])
230
+
231
+ const filters: { id: FilterType; label: string }[] = [
232
+ { id: 'all', label: 'All' },
233
+ { id: 'colors', label: 'Colors' },
234
+ { id: 'sizing', label: 'Sizing' },
235
+ { id: 'other', label: 'Other' },
236
+ ]
237
+
238
+ return (
239
+ <div
240
+ className="flex flex-col h-full"
241
+ style={{ color: 'var(--text-primary)' }}
242
+ >
243
+ {/* Header */}
244
+ <div
245
+ className="flex items-center justify-between px-3 py-1.5 flex-shrink-0"
246
+ style={{ borderBottom: '1px solid var(--border)' }}
247
+ >
248
+ <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
249
+ {visibleCount} variable{visibleCount !== 1 ? 's' : ''}
250
+ {filter !== 'all' && ` of ${totalCount}`}
251
+ </span>
252
+ </div>
253
+
254
+ {/* Search */}
255
+ <div
256
+ className="px-3 py-1.5 flex-shrink-0"
257
+ style={{ borderBottom: '1px solid var(--border)' }}
258
+ >
259
+ <div className="relative">
260
+ <svg
261
+ width="12"
262
+ height="12"
263
+ viewBox="0 0 12 12"
264
+ className="absolute left-2 top-1/2 -translate-y-1/2"
265
+ style={{ color: 'var(--text-muted)' }}
266
+ >
267
+ <circle
268
+ cx="5"
269
+ cy="5"
270
+ r="3.5"
271
+ fill="none"
272
+ stroke="currentColor"
273
+ strokeWidth="1.2"
274
+ />
275
+ <path
276
+ d="M7.5 7.5L10 10"
277
+ stroke="currentColor"
278
+ strokeWidth="1.2"
279
+ strokeLinecap="round"
280
+ />
281
+ </svg>
282
+ <input
283
+ type="text"
284
+ placeholder="Search variables..."
285
+ value={search}
286
+ onChange={(e) => setSearch(e.target.value)}
287
+ className="w-full text-[11px] py-1.5 pl-7 pr-2 rounded"
288
+ style={{
289
+ background: 'var(--bg-input)',
290
+ border: '1px solid var(--border)',
291
+ color: 'var(--text-primary)',
292
+ outline: 'none',
293
+ }}
294
+ />
295
+ </div>
296
+ </div>
297
+
298
+ {/* Filter chips */}
299
+ <div
300
+ className="flex items-center gap-1 px-3 py-1.5 flex-shrink-0"
301
+ style={{ borderBottom: '1px solid var(--border)' }}
302
+ >
303
+ {filters.map((f) => (
304
+ <button
305
+ key={f.id}
306
+ onClick={() => setFilter(f.id)}
307
+ className="px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors"
308
+ style={{
309
+ background: filter === f.id ? 'var(--accent)' : 'var(--bg-input)',
310
+ color: filter === f.id ? '#fff' : 'var(--text-secondary)',
311
+ }}
312
+ >
313
+ {f.label}
314
+ </button>
315
+ ))}
316
+ </div>
317
+
318
+ {/* Variable list */}
319
+ <div className="flex-1 overflow-y-auto">
320
+ {totalCount === 0 ? (
321
+ <div className="flex flex-col items-center justify-center h-full gap-2 px-4">
322
+ <span className="text-xs" style={{ color: 'var(--text-muted)' }}>
323
+ No CSS variables detected
324
+ </span>
325
+ <span
326
+ className="text-[10px] text-center"
327
+ style={{ color: 'var(--text-muted)' }}
328
+ >
329
+ Connect to a project with CSS custom properties to see them here
330
+ </span>
331
+ </div>
332
+ ) : visibleCount === 0 ? (
333
+ <div className="flex items-center justify-center h-32">
334
+ <span
335
+ className="text-[11px]"
336
+ style={{ color: 'var(--text-muted)' }}
337
+ >
338
+ {search
339
+ ? `No variables match "${search}"`
340
+ : 'No variables in this category'}
341
+ </span>
342
+ </div>
343
+ ) : (
344
+ <div className="py-1">
345
+ {filteredFamilies.map((family) => (
346
+ <FamilySection
347
+ key={family.prefix}
348
+ family={family}
349
+ isCollapsed={collapsedFamilies.has(family.prefix)}
350
+ onToggleCollapse={() => toggleFamily(family.prefix)}
351
+ onCopy={handleCopy}
352
+ />
353
+ ))}
354
+
355
+ {filteredFamilies.length > 0 && ungroupedVars.length > 0 && (
356
+ <div
357
+ className="h-px mx-3 my-1"
358
+ style={{ background: 'var(--border)' }}
359
+ />
360
+ )}
361
+
362
+ {ungroupedVars.length > 0 && (
363
+ <div>
364
+ {!search && (
365
+ <div
366
+ className="text-[10px] font-medium px-3 py-1"
367
+ style={{ color: 'var(--text-muted)' }}
368
+ >
369
+ Ungrouped
370
+ </div>
371
+ )}
372
+ {ungroupedVars.map(([name, def]) => (
373
+ <VariableRow
374
+ key={name}
375
+ name={name}
376
+ value={def.value}
377
+ resolvedValue={def.resolvedValue}
378
+ onCopy={handleCopy}
379
+ />
380
+ ))}
381
+ </div>
382
+ )}
383
+ </div>
384
+ )}
385
+ </div>
386
+ </div>
387
+ )
388
+ }
@@ -0,0 +1,95 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { isEditorOnLocalhost } from '@/hooks/usePostMessage'
6
+
7
+ const DEFAULT_BRIDGE_PORT = 4002
8
+
9
+ /**
10
+ * Auto-discovers and manages the local companion bridge server.
11
+ *
12
+ * When the editor is deployed remotely (e.g., Vercel), this hook probes
13
+ * http://localhost:4002/health on mount to detect a running bridge.
14
+ * Also checks for ?bridge=host:port URL parameter.
15
+ */
16
+ export function useBridge() {
17
+ const bridgeUrl = useEditorStore((s) => s.bridgeUrl)
18
+ const bridgeStatus = useEditorStore((s) => s.bridgeStatus)
19
+ const setBridgeUrl = useEditorStore((s) => s.setBridgeUrl)
20
+ const setBridgeStatus = useEditorStore((s) => s.setBridgeStatus)
21
+
22
+ const probe = useCallback(
23
+ async (url: string) => {
24
+ setBridgeStatus('checking')
25
+ try {
26
+ const controller = new AbortController()
27
+ const timeout = setTimeout(() => controller.abort(), 3000)
28
+ const res = await fetch(`${url}/health`, {
29
+ mode: 'cors',
30
+ signal: controller.signal,
31
+ })
32
+ clearTimeout(timeout)
33
+
34
+ if (res.ok) {
35
+ const data = await res.json()
36
+ if (data.bridge) {
37
+ setBridgeUrl(url)
38
+ setBridgeStatus('connected')
39
+ return true
40
+ }
41
+ }
42
+ } catch {
43
+ // Bridge not available
44
+ }
45
+ setBridgeStatus('unavailable')
46
+ return false
47
+ },
48
+ [setBridgeUrl, setBridgeStatus],
49
+ )
50
+
51
+ // Auto-discover on mount when running remotely
52
+ useEffect(() => {
53
+ if (typeof window === 'undefined') return
54
+ if (isEditorOnLocalhost()) return
55
+
56
+ // Check URL params first: ?bridge=localhost:4002
57
+ const params = new URLSearchParams(window.location.search)
58
+ const bridgeParam = params.get('bridge')
59
+ if (bridgeParam) {
60
+ const url = bridgeParam.startsWith('http')
61
+ ? bridgeParam
62
+ : `http://${bridgeParam}`
63
+ probe(url)
64
+ return
65
+ }
66
+
67
+ // Check saved bridge URL
68
+ const saved = useEditorStore.getState().bridgeUrl
69
+ if (saved) {
70
+ probe(saved)
71
+ return
72
+ }
73
+
74
+ // Probe default port
75
+ probe(`http://localhost:${DEFAULT_BRIDGE_PORT}`)
76
+ }, [probe])
77
+
78
+ const reconnect = useCallback(() => {
79
+ const url = bridgeUrl || `http://localhost:${DEFAULT_BRIDGE_PORT}`
80
+ probe(url)
81
+ }, [bridgeUrl, probe])
82
+
83
+ const disconnect = useCallback(() => {
84
+ setBridgeUrl(null)
85
+ setBridgeStatus('disconnected')
86
+ }, [setBridgeUrl, setBridgeStatus])
87
+
88
+ return {
89
+ bridgeUrl,
90
+ bridgeStatus,
91
+ isBridgeConnected: bridgeStatus === 'connected',
92
+ reconnect,
93
+ disconnect,
94
+ }
95
+ }