@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.
- package/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- 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
|
+
}
|