@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,609 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { usePostMessage } from '@/hooks/usePostMessage'
6
+ import { MESSAGE_TYPES } from '@/lib/constants'
7
+ import type { DetectedComponent, VariantGroup } from '@/types/component'
8
+
9
+ interface ComponentTreeNode {
10
+ component: DetectedComponent
11
+ children: ComponentTreeNode[]
12
+ depth: number
13
+ instanceCount: number
14
+ }
15
+
16
+ function buildComponentTree(
17
+ components: DetectedComponent[],
18
+ ): ComponentTreeNode[] {
19
+ const sorted = [...components].sort(
20
+ (a, b) => a.selectorPath.length - b.selectorPath.length,
21
+ )
22
+ const roots: ComponentTreeNode[] = []
23
+ const nodeMap = new Map<string, ComponentTreeNode>()
24
+
25
+ for (const component of sorted) {
26
+ const node: ComponentTreeNode = {
27
+ component,
28
+ children: [],
29
+ depth: 0,
30
+ instanceCount: 1,
31
+ }
32
+ let parentNode: ComponentTreeNode | null = null
33
+
34
+ for (const [path, candidate] of nodeMap) {
35
+ if (
36
+ component.selectorPath.startsWith(path + ' ') ||
37
+ component.selectorPath.startsWith(path + ' > ')
38
+ ) {
39
+ if (
40
+ !parentNode ||
41
+ path.length > parentNode.component.selectorPath.length
42
+ ) {
43
+ parentNode = candidate
44
+ }
45
+ }
46
+ }
47
+
48
+ if (parentNode) {
49
+ node.depth = parentNode.depth + 1
50
+ parentNode.children.push(node)
51
+ } else {
52
+ roots.push(node)
53
+ }
54
+ nodeMap.set(component.selectorPath, node)
55
+ }
56
+ return roots
57
+ }
58
+
59
+ function flattenVisible(
60
+ nodes: ComponentTreeNode[],
61
+ collapsed: Set<string>,
62
+ searchActive: boolean,
63
+ ): ComponentTreeNode[] {
64
+ const result: ComponentTreeNode[] = []
65
+ for (const node of nodes) {
66
+ result.push(node)
67
+ if (
68
+ node.children.length > 0 &&
69
+ (searchActive || !collapsed.has(node.component.selectorPath))
70
+ ) {
71
+ result.push(...flattenVisible(node.children, collapsed, searchActive))
72
+ }
73
+ }
74
+ return result
75
+ }
76
+
77
+ /** Structural fingerprint: name + tagName + recursive child shape. */
78
+ function structuralKey(node: ComponentTreeNode): string {
79
+ if (node.children.length === 0)
80
+ return `${node.component.name}:${node.component.tagName}`
81
+ const childKeys = node.children.map(structuralKey).sort().join(',')
82
+ return `${node.component.name}:${node.component.tagName}[${childKeys}]`
83
+ }
84
+
85
+ /** Merge sibling nodes with the same structural shape into one entry with a count. */
86
+ function deduplicateSiblings(nodes: ComponentTreeNode[]): ComponentTreeNode[] {
87
+ const result: ComponentTreeNode[] = []
88
+ const seen = new Map<string, ComponentTreeNode>()
89
+
90
+ for (const node of nodes) {
91
+ const dedupedChildren = deduplicateSiblings(node.children)
92
+ const processed = { ...node, children: dedupedChildren, instanceCount: 1 }
93
+ const key = structuralKey(processed)
94
+ const existing = seen.get(key)
95
+ if (existing) {
96
+ existing.instanceCount++
97
+ } else {
98
+ seen.set(key, processed)
99
+ result.push(processed)
100
+ }
101
+ }
102
+
103
+ return result
104
+ }
105
+
106
+ export default function ComponentsPanel() {
107
+ const detectedComponents = useEditorStore((s) => s.detectedComponents)
108
+ const componentSearchQuery = useEditorStore((s) => s.componentSearchQuery)
109
+ const setComponentSearchQuery = useEditorStore(
110
+ (s) => s.setComponentSearchQuery,
111
+ )
112
+ const selectedComponentPath = useEditorStore((s) => s.selectedComponentPath)
113
+ const setSelectedComponentPath = useEditorStore(
114
+ (s) => s.setSelectedComponentPath,
115
+ )
116
+ const updateComponentVariantActiveIndex = useEditorStore(
117
+ (s) => s.updateComponentVariantActiveIndex,
118
+ )
119
+ const selectorPath = useEditorStore((s) => s.selectorPath)
120
+ const connectionStatus = useEditorStore((s) => s.connectionStatus)
121
+ const createdComponents = useEditorStore((s) => s.createdComponents)
122
+ const addCreatedComponent = useEditorStore((s) => s.addCreatedComponent)
123
+ const addStyleChange = useEditorStore((s) => s.addStyleChange)
124
+ const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
125
+ const setActiveLeftTab = useEditorStore((s) => s.setActiveLeftTab)
126
+
127
+ const { sendToInspector } = usePostMessage()
128
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
129
+
130
+ // Track active variants for revert-on-deselect
131
+ const activeVariantsRef = useRef<{
132
+ selectorPath: string
133
+ variants: Array<{ groupIndex: number; group: VariantGroup }>
134
+ } | null>(null)
135
+
136
+ // Revert variants when selection changes away from a component
137
+ useEffect(() => {
138
+ const prev = activeVariantsRef.current
139
+ if (prev && prev.selectorPath !== selectorPath) {
140
+ for (const { group } of prev.variants) {
141
+ if (group.activeIndex === 0) continue // default, nothing to revert
142
+ const activeOption = group.options[group.activeIndex]
143
+ if (group.type === 'class' && activeOption?.className) {
144
+ sendToInspector({
145
+ type: MESSAGE_TYPES.REVERT_VARIANT as 'REVERT_VARIANT',
146
+ payload: {
147
+ selectorPath: prev.selectorPath,
148
+ removeClassName: activeOption.className,
149
+ restoreClassName: group.options[0]?.className || undefined,
150
+ },
151
+ })
152
+ } else if (group.type === 'pseudo' && activeOption?.pseudoStyles) {
153
+ sendToInspector({
154
+ type: MESSAGE_TYPES.REVERT_VARIANT as 'REVERT_VARIANT',
155
+ payload: {
156
+ selectorPath: prev.selectorPath,
157
+ revertPseudo: true,
158
+ pseudoProperties: Object.keys(activeOption.pseudoStyles),
159
+ },
160
+ })
161
+ }
162
+ }
163
+ activeVariantsRef.current = null
164
+ }
165
+ }, [selectorPath, sendToInspector])
166
+
167
+ // Filter by search query only (no selectorPath scoping — tree handles hierarchy)
168
+ const filteredComponents = useMemo(() => {
169
+ if (!componentSearchQuery) return detectedComponents
170
+ const query = componentSearchQuery.toLowerCase()
171
+ return detectedComponents.filter(
172
+ (c) =>
173
+ c.name.toLowerCase().includes(query) ||
174
+ c.tagName.toLowerCase().includes(query) ||
175
+ (c.className && c.className.toLowerCase().includes(query)),
176
+ )
177
+ }, [detectedComponents, componentSearchQuery])
178
+
179
+ const tree = useMemo(
180
+ () => deduplicateSiblings(buildComponentTree(filteredComponents)),
181
+ [filteredComponents],
182
+ )
183
+
184
+ const visibleNodes = useMemo(
185
+ () => flattenVisible(tree, collapsed, !!componentSearchQuery),
186
+ [tree, collapsed, componentSearchQuery],
187
+ )
188
+
189
+ const toggleCollapse = useCallback((path: string, e: React.MouseEvent) => {
190
+ e.stopPropagation()
191
+ setCollapsed((prev) => {
192
+ const next = new Set(prev)
193
+ if (next.has(path)) next.delete(path)
194
+ else next.add(path)
195
+ return next
196
+ })
197
+ }, [])
198
+
199
+ const handleComponentClick = useCallback(
200
+ (component: DetectedComponent) => {
201
+ setSelectedComponentPath(component.selectorPath)
202
+ sendToInspector({
203
+ type: MESSAGE_TYPES.SELECT_ELEMENT as 'SELECT_ELEMENT',
204
+ payload: { selectorPath: component.selectorPath },
205
+ })
206
+ // Switch to Layers tab so the user sees the selected node in the tree
207
+ setActiveLeftTab('layers')
208
+ },
209
+ [sendToInspector, setSelectedComponentPath, setActiveLeftTab],
210
+ )
211
+
212
+ const handleVariantChange = useCallback(
213
+ (component: DetectedComponent, groupIndex: number, optionIndex: number) => {
214
+ const group = component.variants[groupIndex]
215
+ if (!group) return
216
+
217
+ const option = group.options[optionIndex]
218
+ if (!option) return
219
+
220
+ // Track active variant for revert-on-deselect
221
+ const existing = activeVariantsRef.current
222
+ if (!existing || existing.selectorPath !== component.selectorPath) {
223
+ activeVariantsRef.current = {
224
+ selectorPath: component.selectorPath,
225
+ variants: [],
226
+ }
227
+ }
228
+ const tracked = activeVariantsRef.current!
229
+ const existingEntry = tracked.variants.find(
230
+ (v) => v.groupIndex === groupIndex,
231
+ )
232
+ if (existingEntry) {
233
+ existingEntry.group = { ...group, activeIndex: optionIndex }
234
+ } else {
235
+ tracked.variants.push({
236
+ groupIndex,
237
+ group: { ...group, activeIndex: optionIndex },
238
+ })
239
+ }
240
+
241
+ if (group.type === 'class') {
242
+ sendToInspector({
243
+ type: MESSAGE_TYPES.APPLY_VARIANT as 'APPLY_VARIANT',
244
+ payload: {
245
+ selectorPath: component.selectorPath,
246
+ type: 'class',
247
+ addClassName: option.className || undefined,
248
+ removeClassNames: option.removeClassNames || undefined,
249
+ },
250
+ })
251
+ } else if (group.type === 'pseudo') {
252
+ if (optionIndex === 0) {
253
+ // Revert to default
254
+ const currentActive = group.options[group.activeIndex]
255
+ if (currentActive?.pseudoStyles) {
256
+ sendToInspector({
257
+ type: MESSAGE_TYPES.REVERT_VARIANT as 'REVERT_VARIANT',
258
+ payload: {
259
+ selectorPath: component.selectorPath,
260
+ revertPseudo: true,
261
+ pseudoProperties: Object.keys(currentActive.pseudoStyles),
262
+ },
263
+ })
264
+ }
265
+ } else if (option.pseudoStyles) {
266
+ // First revert current if not default
267
+ if (group.activeIndex !== 0) {
268
+ const currentActive = group.options[group.activeIndex]
269
+ if (currentActive?.pseudoStyles) {
270
+ sendToInspector({
271
+ type: MESSAGE_TYPES.APPLY_VARIANT as 'APPLY_VARIANT',
272
+ payload: {
273
+ selectorPath: component.selectorPath,
274
+ type: 'pseudo',
275
+ revertPseudo: true,
276
+ pseudoStyles: currentActive.pseudoStyles,
277
+ },
278
+ })
279
+ }
280
+ }
281
+ sendToInspector({
282
+ type: MESSAGE_TYPES.APPLY_VARIANT as 'APPLY_VARIANT',
283
+ payload: {
284
+ selectorPath: component.selectorPath,
285
+ type: 'pseudo',
286
+ pseudoStyles: option.pseudoStyles,
287
+ },
288
+ })
289
+ }
290
+ }
291
+
292
+ updateComponentVariantActiveIndex(
293
+ component.selectorPath,
294
+ groupIndex,
295
+ optionIndex,
296
+ )
297
+ },
298
+ [sendToInspector, updateComponentVariantActiveIndex],
299
+ )
300
+
301
+ const handleCreateComponent = useCallback(
302
+ (component: DetectedComponent) => {
303
+ const change = {
304
+ id: crypto.randomUUID(),
305
+ elementSelector: component.selectorPath,
306
+ property: '__component_creation__',
307
+ originalValue: '',
308
+ newValue: JSON.stringify({
309
+ name: component.name,
310
+ variants: component.variants.map((g) => ({
311
+ groupName: g.groupName,
312
+ options: g.options.map((o) => o.label),
313
+ })),
314
+ timestamp: Date.now(),
315
+ }),
316
+ breakpoint: activeBreakpoint,
317
+ timestamp: Date.now(),
318
+ }
319
+ addStyleChange(change)
320
+ addCreatedComponent({
321
+ name: component.name,
322
+ selectorPath: component.selectorPath,
323
+ timestamp: Date.now(),
324
+ })
325
+ },
326
+ [addStyleChange, addCreatedComponent, activeBreakpoint],
327
+ )
328
+
329
+ if (connectionStatus !== 'connected') {
330
+ return (
331
+ <div
332
+ className="flex items-center justify-center flex-1 text-xs"
333
+ style={{ color: 'var(--text-muted)' }}
334
+ >
335
+ Connect to inspect
336
+ </div>
337
+ )
338
+ }
339
+
340
+ return (
341
+ <div className="flex flex-col flex-1 overflow-hidden">
342
+ {/* Search */}
343
+ <div
344
+ className="px-2 py-1.5 flex-shrink-0"
345
+ style={{ borderBottom: '1px solid var(--border)' }}
346
+ >
347
+ <input
348
+ type="text"
349
+ value={componentSearchQuery}
350
+ onChange={(e) => setComponentSearchQuery(e.target.value)}
351
+ placeholder="Filter components..."
352
+ className="w-full text-xs py-1 px-2"
353
+ style={{
354
+ background: 'var(--bg-input)',
355
+ border: '1px solid var(--border)',
356
+ borderRadius: '3px',
357
+ color: 'var(--text-primary)',
358
+ outline: 'none',
359
+ }}
360
+ />
361
+ </div>
362
+
363
+ {/* Count header */}
364
+ <div
365
+ className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider flex-shrink-0"
366
+ style={{
367
+ color: 'var(--text-muted)',
368
+ borderBottom: '1px solid var(--border)',
369
+ }}
370
+ >
371
+ {visibleNodes.length} component{visibleNodes.length !== 1 ? 's' : ''}{' '}
372
+ found
373
+ {filteredComponents.length > visibleNodes.length && (
374
+ <span style={{ color: 'var(--text-muted)', fontWeight: 'normal' }}>
375
+ {' '}
376
+ · {filteredComponents.length} instances
377
+ </span>
378
+ )}
379
+ </div>
380
+
381
+ {/* Component tree */}
382
+ <div className="flex-1 overflow-y-auto">
383
+ {visibleNodes.length > 0 ? (
384
+ visibleNodes.map(({ component, children, depth, instanceCount }) => {
385
+ const isSelected = selectedComponentPath === component.selectorPath
386
+ const isCreated = !!createdComponents[component.selectorPath]
387
+ const isActualComponent =
388
+ component.detectionMethod === 'semantic-html' ||
389
+ component.detectionMethod === 'custom-element' ||
390
+ component.detectionMethod === 'data-attribute'
391
+ const hasChildren = children.length > 0
392
+ const isCollapsed = collapsed.has(component.selectorPath)
393
+ const nameColor = isSelected
394
+ ? 'var(--accent)'
395
+ : isActualComponent
396
+ ? '#c084fc'
397
+ : 'var(--text-primary)'
398
+ const iconColor = isSelected
399
+ ? 'var(--accent)'
400
+ : isActualComponent
401
+ ? '#c084fc'
402
+ : 'var(--text-muted)'
403
+ return (
404
+ <div key={component.selectorPath}>
405
+ <div
406
+ onClick={() => handleComponentClick(component)}
407
+ className="w-full text-left py-1.5 text-xs transition-colors flex items-center gap-1.5 cursor-pointer"
408
+ style={{
409
+ paddingLeft: `${8 + depth * 16}px`,
410
+ paddingRight: '12px',
411
+ color: isSelected ? 'var(--accent)' : 'var(--text-primary)',
412
+ background: isSelected
413
+ ? 'rgba(74, 158, 255, 0.08)'
414
+ : 'transparent',
415
+ }}
416
+ title={component.selectorPath}
417
+ role="button"
418
+ tabIndex={0}
419
+ onKeyDown={(e) => {
420
+ if (e.key === 'Enter' || e.key === ' ') {
421
+ e.preventDefault()
422
+ handleComponentClick(component)
423
+ }
424
+ }}
425
+ >
426
+ {/* Collapse/expand toggle */}
427
+ {hasChildren ? (
428
+ <button
429
+ onClick={(e) => toggleCollapse(component.selectorPath, e)}
430
+ className="flex-shrink-0 flex items-center justify-center"
431
+ style={{ width: '14px', height: '14px' }}
432
+ >
433
+ <svg
434
+ width="8"
435
+ height="8"
436
+ viewBox="0 0 16 16"
437
+ fill="none"
438
+ stroke={
439
+ isSelected ? 'var(--accent)' : 'var(--text-muted)'
440
+ }
441
+ strokeWidth="2.5"
442
+ strokeLinecap="round"
443
+ strokeLinejoin="round"
444
+ className="transition-transform"
445
+ style={{
446
+ transform: isCollapsed
447
+ ? 'rotate(0deg)'
448
+ : 'rotate(90deg)',
449
+ }}
450
+ >
451
+ <path d="M6 4l4 4-4 4" />
452
+ </svg>
453
+ </button>
454
+ ) : (
455
+ <span style={{ width: '14px' }} className="flex-shrink-0" />
456
+ )}
457
+ {/* Component icon */}
458
+ <svg
459
+ width="12"
460
+ height="12"
461
+ viewBox="0 0 16 16"
462
+ fill="none"
463
+ stroke={iconColor}
464
+ strokeWidth="1.5"
465
+ strokeLinecap="round"
466
+ strokeLinejoin="round"
467
+ className="flex-shrink-0"
468
+ >
469
+ <rect x="1" y="4" width="14" height="8" rx="1.5" />
470
+ <path d="M4 4V2.5A1.5 1.5 0 0 1 5.5 1h5A1.5 1.5 0 0 1 12 2.5V4" />
471
+ </svg>
472
+ <span
473
+ className="truncate flex-1 font-medium"
474
+ style={{ color: nameColor }}
475
+ >
476
+ {component.name}
477
+ {instanceCount > 1 && (
478
+ <span
479
+ style={{
480
+ color: 'var(--text-muted)',
481
+ fontWeight: 'normal',
482
+ fontSize: '10px',
483
+ }}
484
+ >
485
+ {' '}
486
+ ×{instanceCount}
487
+ </span>
488
+ )}
489
+ <span
490
+ style={{
491
+ color: 'var(--text-muted)',
492
+ fontWeight: 'normal',
493
+ }}
494
+ >
495
+ {' '}
496
+ ({component.tagName})
497
+ </span>
498
+ </span>
499
+ {/* Create as Component button */}
500
+ {isCreated ? (
501
+ <span
502
+ className="flex items-center gap-1 flex-shrink-0"
503
+ style={{ color: '#4ade80', fontSize: '10px' }}
504
+ >
505
+ <svg
506
+ width="10"
507
+ height="10"
508
+ viewBox="0 0 16 16"
509
+ fill="none"
510
+ stroke="currentColor"
511
+ strokeWidth="2"
512
+ >
513
+ <path d="M2 8l4 4 8-8" />
514
+ </svg>
515
+ Created
516
+ </span>
517
+ ) : !isActualComponent ? (
518
+ <button
519
+ onClick={(e) => {
520
+ e.stopPropagation()
521
+ handleCreateComponent(component)
522
+ }}
523
+ className="flex-shrink-0 text-[10px] px-1.5 py-0.5 transition-colors"
524
+ style={{
525
+ color: 'var(--text-secondary)',
526
+ border: '1px solid var(--border)',
527
+ borderRadius: '3px',
528
+ background: 'transparent',
529
+ }}
530
+ title="Create as Component"
531
+ >
532
+ + Create
533
+ </button>
534
+ ) : null}
535
+ {isSelected && !hasChildren && (
536
+ <div
537
+ className="w-1.5 h-1.5 rounded-full flex-shrink-0"
538
+ style={{ background: 'var(--accent)' }}
539
+ />
540
+ )}
541
+ </div>
542
+
543
+ {/* Variant dropdowns — only visible when selected */}
544
+ {isSelected && component.variants.length > 0 && (
545
+ <div
546
+ className="pb-2 pt-1 flex flex-col gap-2"
547
+ style={{
548
+ paddingLeft: `${24 + depth * 16}px`,
549
+ paddingRight: '12px',
550
+ background: 'rgba(74, 158, 255, 0.04)',
551
+ }}
552
+ >
553
+ {component.variants.map((group, gi) => (
554
+ <div key={group.groupName}>
555
+ <div
556
+ className="text-[10px] font-medium uppercase tracking-wider mb-1"
557
+ style={{ color: 'var(--text-muted)' }}
558
+ >
559
+ {group.groupName}
560
+ </div>
561
+ <div className="flex flex-wrap gap-1">
562
+ {group.options.map((option, oi) => {
563
+ const isActive = group.activeIndex === oi
564
+ return (
565
+ <button
566
+ key={option.label}
567
+ onClick={() =>
568
+ handleVariantChange(component, gi, oi)
569
+ }
570
+ className="px-2 py-0.5 text-[11px] rounded transition-colors"
571
+ style={{
572
+ background: isActive
573
+ ? 'var(--accent)'
574
+ : 'var(--bg-input)',
575
+ color: isActive
576
+ ? '#fff'
577
+ : 'var(--text-secondary)',
578
+ border: `1px solid ${isActive ? 'var(--accent)' : 'var(--border)'}`,
579
+ }}
580
+ >
581
+ {option.label}
582
+ </button>
583
+ )
584
+ })}
585
+ </div>
586
+ </div>
587
+ ))}
588
+ </div>
589
+ )}
590
+ </div>
591
+ )
592
+ })
593
+ ) : (
594
+ <div
595
+ className="px-3 py-6 text-center"
596
+ style={{ color: 'var(--text-muted)' }}
597
+ >
598
+ <div className="text-xs font-medium mb-1">
599
+ No components detected
600
+ </div>
601
+ <div style={{ fontSize: '10px' }}>
602
+ Select an element with recognizable components
603
+ </div>
604
+ </div>
605
+ )}
606
+ </div>
607
+ </div>
608
+ )
609
+ }
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ import { useEditorStore } from '@/store'
4
+ import { LEFT_ICON_SIDEBAR_WIDTH } from '@/lib/constants'
5
+ import {
6
+ LayersIcon,
7
+ PagesIcon,
8
+ ComponentsIcon,
9
+ AddElementIcon,
10
+ TerminalIcon,
11
+ } from './icons'
12
+
13
+ type LeftTab = 'layers' | 'pages' | 'components' | 'terminal' | 'add-element'
14
+
15
+ const tabs: {
16
+ id: LeftTab
17
+ icon: React.FC<React.SVGProps<SVGSVGElement>>
18
+ title: string
19
+ }[] = [
20
+ { id: 'layers', icon: LayersIcon, title: 'Navigator' },
21
+ { id: 'pages', icon: PagesIcon, title: 'Pages' },
22
+ { id: 'components', icon: ComponentsIcon, title: 'Components' },
23
+ { id: 'add-element', icon: AddElementIcon, title: 'Add Element' },
24
+ { id: 'terminal', icon: TerminalIcon, title: 'Terminal' },
25
+ ]
26
+
27
+ export function IconSidebar() {
28
+ const activeTab = useEditorStore((s) => s.activeLeftTab)
29
+ const leftPanelOpen = useEditorStore((s) => s.leftPanelOpen)
30
+ const setActiveTab = useEditorStore((s) => s.setActiveLeftTab)
31
+ const togglePanel = useEditorStore((s) => s.toggleLeftPanel)
32
+
33
+ const handleClick = (tabId: LeftTab) => {
34
+ if (tabId === activeTab && leftPanelOpen) {
35
+ togglePanel()
36
+ } else {
37
+ setActiveTab(tabId)
38
+ if (!leftPanelOpen) {
39
+ togglePanel()
40
+ }
41
+ }
42
+ }
43
+
44
+ return (
45
+ <div
46
+ className="flex flex-col flex-shrink-0 h-full"
47
+ style={{
48
+ width: LEFT_ICON_SIDEBAR_WIDTH,
49
+ background: 'var(--bg-secondary)',
50
+ borderRight: '1px solid var(--border)',
51
+ }}
52
+ >
53
+ {tabs.map((tab) => {
54
+ const isActive = tab.id === activeTab && leftPanelOpen
55
+ const Icon = tab.icon
56
+ return (
57
+ <button
58
+ key={tab.id}
59
+ title={tab.title}
60
+ onClick={() => handleClick(tab.id)}
61
+ className="flex items-center justify-center flex-shrink-0"
62
+ style={{
63
+ width: LEFT_ICON_SIDEBAR_WIDTH,
64
+ height: 36,
65
+ color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
66
+ background: isActive ? 'var(--bg-active)' : 'transparent',
67
+ borderTop: 'none',
68
+ borderRight: 'none',
69
+ borderBottom: 'none',
70
+ borderLeft: isActive
71
+ ? '2px solid var(--accent)'
72
+ : '2px solid transparent',
73
+ transition: 'background-color 0.15s, color 0.15s',
74
+ cursor: 'pointer',
75
+ outline: 'none',
76
+ padding: 0,
77
+ }}
78
+ onMouseEnter={(e) => {
79
+ if (!isActive) {
80
+ e.currentTarget.style.background = 'var(--bg-hover)'
81
+ e.currentTarget.style.color = 'var(--text-secondary)'
82
+ }
83
+ }}
84
+ onMouseLeave={(e) => {
85
+ e.currentTarget.style.background = isActive
86
+ ? 'var(--bg-active)'
87
+ : 'transparent'
88
+ e.currentTarget.style.color = isActive
89
+ ? 'var(--text-primary)'
90
+ : 'var(--text-muted)'
91
+ }}
92
+ >
93
+ <Icon />
94
+ </button>
95
+ )
96
+ })}
97
+ </div>
98
+ )
99
+ }