@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,1474 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState, useCallback, useRef } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { useChangeTracker } from '@/hooks/useChangeTracker'
6
+ import {
7
+ buildInstructionsFooter,
8
+ getBreakpointDeviceInfo,
9
+ } from '@/lib/constants'
10
+ import { inferSourcePath } from '@/lib/classifyElement'
11
+ import { camelToKebab } from '@/lib/utils'
12
+ import { consumeClaudeStream, formatStderrLine } from '@/lib/claude-stream'
13
+ import { EditablePre } from '@/components/common/EditablePre'
14
+ import { AiScanResultPanel } from './AiScanResultPanel'
15
+ import type {
16
+ StyleChange,
17
+ ElementSnapshot,
18
+ Breakpoint,
19
+ } from '@/types/changelog'
20
+ import type { FileMap, ClaudeScanResponse } from '@/types/claude'
21
+
22
+ type BreakpointGroupKey = 'all' | 'desktop-only' | 'tablet-only' | 'mobile-only'
23
+
24
+ const GROUP_ORDER: BreakpointGroupKey[] = [
25
+ 'all',
26
+ 'desktop-only',
27
+ 'tablet-only',
28
+ 'mobile-only',
29
+ ]
30
+
31
+ const GROUP_META: Record<BreakpointGroupKey, { label: string }> = {
32
+ all: { label: 'All' },
33
+ 'desktop-only': { label: 'Desktop Only' },
34
+ 'tablet-only': { label: 'Tablet Only' },
35
+ 'mobile-only': { label: 'Mobile Only' },
36
+ }
37
+
38
+ function getGroupKey(change: StyleChange): BreakpointGroupKey {
39
+ const scope = change.changeScope ?? 'all'
40
+ if (scope === 'all') return 'all'
41
+ return `${change.breakpoint}-only` as BreakpointGroupKey
42
+ }
43
+
44
+ function truncateText(text: string, maxLen: number): string {
45
+ if (!text) return '(empty)'
46
+ if (text.length <= maxLen) return `"${text}"`
47
+ return `"${text.substring(0, maxLen)}..."`
48
+ }
49
+
50
+ /** Extract component name from c- prefixed class (e.g. "c-header" → "Header", "c-nav-bar" → "Nav Bar") */
51
+ function getComponentName(className: string | null | undefined): string | null {
52
+ if (!className) return null
53
+ const match = className
54
+ .split(/\s+/)
55
+ .find((cls) => cls.startsWith('c-') && cls.length > 2)
56
+ if (!match) return null
57
+ return match
58
+ .substring(2)
59
+ .split('-')
60
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
61
+ .join(' ')
62
+ }
63
+
64
+ function buildComponentCreationLog(extraction: StyleChange): string {
65
+ const lines: string[] = []
66
+ try {
67
+ const data = JSON.parse(extraction.newValue) as {
68
+ name: string
69
+ variants: Array<{ groupName: string; options: string[] }>
70
+ }
71
+ const kebabName = data.name
72
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
73
+ .replace(/\s+/g, '-')
74
+ .toLowerCase()
75
+
76
+ lines.push('=== COMPONENT EXTRACTION ===')
77
+ lines.push('')
78
+ lines.push(`### ${data.name} Component`)
79
+ lines.push(`- Selector: \`${extraction.elementSelector}\``)
80
+ lines.push(`- Suggested file: \`src/components/${kebabName}.tsx\``)
81
+ if (data.variants.length > 0) {
82
+ lines.push('- Suggested props:')
83
+ for (const v of data.variants) {
84
+ lines.push(` - ${v.groupName.toLowerCase()}: ${v.options.join(' | ')}`)
85
+ }
86
+ }
87
+ lines.push('')
88
+ lines.push('## Instructions for Claude Code')
89
+ lines.push(
90
+ `Extract the element at selector \`${extraction.elementSelector}\` into a`,
91
+ )
92
+ lines.push(`reusable React component named \`${data.name}\`.`)
93
+ if (data.variants.length > 0) {
94
+ lines.push('Accept the following props for variant control:')
95
+ for (const v of data.variants) {
96
+ lines.push(` - ${v.groupName.toLowerCase()}: ${v.options.join(' | ')}`)
97
+ }
98
+ }
99
+ lines.push('=== END COMPONENT EXTRACTION ===')
100
+ } catch {
101
+ lines.push(`Create component from ${extraction.elementSelector}`)
102
+ }
103
+ return lines.join('\n')
104
+ }
105
+
106
+ function CopyIcon({ size = 14 }: { size?: number }) {
107
+ return (
108
+ <svg
109
+ width={size}
110
+ height={size}
111
+ viewBox="0 0 24 24"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ strokeWidth={2}
115
+ strokeLinecap="round"
116
+ strokeLinejoin="round"
117
+ >
118
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
119
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
120
+ </svg>
121
+ )
122
+ }
123
+
124
+ function CheckIcon({ size = 14 }: { size?: number }) {
125
+ return (
126
+ <svg
127
+ width={size}
128
+ height={size}
129
+ viewBox="0 0 24 24"
130
+ fill="none"
131
+ stroke="currentColor"
132
+ strokeWidth={2}
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ >
136
+ <polyline points="20 6 9 17 4 12" />
137
+ </svg>
138
+ )
139
+ }
140
+
141
+ function buildSingleElementLog(
142
+ snapshot: ElementSnapshot,
143
+ changes: StyleChange[],
144
+ fileMap?: FileMap | null,
145
+ projectRoot?: string | null,
146
+ framework?: string | null,
147
+ cssStrategy?: string[] | null,
148
+ ): string {
149
+ const lines: string[] = []
150
+ const isMobileApp = framework === 'flutter' || framework === 'react-native'
151
+
152
+ const attrParts: string[] = []
153
+ if (snapshot.elementId) attrParts.push(`id="${snapshot.elementId}"`)
154
+ if (snapshot.className) attrParts.push(`class="${snapshot.className}"`)
155
+ const tag = `<${snapshot.tagName}${attrParts.length ? ' ' + attrParts.join(' ') : ''}>`
156
+
157
+ const changeBp = (changes[0]?.breakpoint || 'mobile') as Breakpoint
158
+ const { deviceName, range } = getBreakpointDeviceInfo(changeBp)
159
+
160
+ const compName = getComponentName(snapshot.className)
161
+ if (compName) {
162
+ lines.push('COMPONENT NAME')
163
+ lines.push(compName)
164
+ lines.push('')
165
+ }
166
+
167
+ lines.push('CHANGES')
168
+ for (const c of changes) {
169
+ if (c.property === '__element_inserted__') {
170
+ lines.push(` element inserted (${snapshot.tagName})`)
171
+ } else if (c.property === '__element_moved__') {
172
+ lines.push(` element moved → ${c.newValue}`)
173
+ } else if (c.property === '__element_deleted__') {
174
+ lines.push(` element deleted (was display: ${c.originalValue})`)
175
+ } else if (c.property === '__text_content__') {
176
+ lines.push(` text content: "${c.originalValue}" → "${c.newValue}"`)
177
+ } else {
178
+ const cInfo = getBreakpointDeviceInfo(c.breakpoint)
179
+ lines.push(
180
+ ` ${camelToKebab(c.property)}: "${c.originalValue}" → "${c.newValue}" [${cInfo.deviceName} ${cInfo.range}]`,
181
+ )
182
+ }
183
+ }
184
+ lines.push('')
185
+
186
+ lines.push('PAGE NAME')
187
+ lines.push(snapshot.pagePath || '/')
188
+ lines.push('')
189
+
190
+ lines.push('ELEMENT')
191
+ lines.push(tag)
192
+ lines.push('')
193
+
194
+ if (!isMobileApp) {
195
+ lines.push('DEVICE')
196
+ lines.push(`Device Name: ${deviceName}`)
197
+ lines.push(`Breakpoint: ${range}`)
198
+ lines.push('')
199
+ lines.push('APPLIES TO')
200
+ lines.push(
201
+ snapshot.changeScope === 'all'
202
+ ? 'All breakpoints'
203
+ : `${deviceName} (${range})`,
204
+ )
205
+ lines.push('')
206
+ }
207
+
208
+ const attrEntries = Object.entries(snapshot.attributes)
209
+ if (attrEntries.length > 0) {
210
+ lines.push('ATTRIBUTES')
211
+ for (const [key, value] of attrEntries) {
212
+ lines.push(` ${key}: ${value}`)
213
+ }
214
+ lines.push('')
215
+ }
216
+
217
+ if (snapshot.innerText) {
218
+ lines.push('INNER TEXT')
219
+ lines.push(snapshot.innerText)
220
+ lines.push('')
221
+ }
222
+
223
+ lines.push(
224
+ buildInstructionsFooter(changes.length, 1, { framework, cssStrategy }),
225
+ )
226
+
227
+ return lines.join('\n').trim()
228
+ }
229
+
230
+ function buildElementSection(
231
+ snapshot: ElementSnapshot,
232
+ changes: StyleChange[],
233
+ fileMap?: FileMap | null,
234
+ projectRoot?: string | null,
235
+ framework?: string | null,
236
+ cssStrategy?: string[] | null,
237
+ ): string {
238
+ const lines: string[] = []
239
+ const isMobileApp = framework === 'flutter' || framework === 'react-native'
240
+
241
+ const attrParts: string[] = []
242
+ if (snapshot.elementId) attrParts.push(`id="${snapshot.elementId}"`)
243
+ if (snapshot.className) attrParts.push(`class="${snapshot.className}"`)
244
+ const tag = `<${snapshot.tagName}${attrParts.length ? ' ' + attrParts.join(' ') : ''}>`
245
+
246
+ const changeBp = (changes[0]?.breakpoint || 'mobile') as Breakpoint
247
+ const { deviceName: elDevice, range: elRange } =
248
+ getBreakpointDeviceInfo(changeBp)
249
+
250
+ const compName = getComponentName(snapshot.className)
251
+ if (compName) {
252
+ lines.push('COMPONENT NAME')
253
+ lines.push(compName)
254
+ lines.push('')
255
+ }
256
+
257
+ lines.push('CHANGES')
258
+ for (const c of changes) {
259
+ if (c.property === '__element_inserted__') {
260
+ lines.push(` element inserted (${snapshot.tagName})`)
261
+ } else if (c.property === '__element_moved__') {
262
+ lines.push(` element moved → ${c.newValue}`)
263
+ } else if (c.property === '__element_deleted__') {
264
+ lines.push(` element deleted (was display: ${c.originalValue})`)
265
+ } else if (c.property === '__text_content__') {
266
+ lines.push(` text content: "${c.originalValue}" → "${c.newValue}"`)
267
+ } else {
268
+ const cInfo = getBreakpointDeviceInfo(c.breakpoint)
269
+ lines.push(
270
+ ` ${camelToKebab(c.property)}: "${c.originalValue}" → "${c.newValue}" [${cInfo.deviceName} ${cInfo.range}]`,
271
+ )
272
+ }
273
+ }
274
+ lines.push('')
275
+
276
+ lines.push('PAGE NAME')
277
+ lines.push(snapshot.pagePath || '/')
278
+ lines.push('')
279
+
280
+ lines.push('ELEMENT')
281
+ lines.push(tag)
282
+ lines.push('')
283
+
284
+ if (!isMobileApp) {
285
+ lines.push('APPLIES TO')
286
+ lines.push(
287
+ snapshot.changeScope === 'all'
288
+ ? 'All breakpoints'
289
+ : `${elDevice} (${elRange})`,
290
+ )
291
+ lines.push('')
292
+ }
293
+
294
+ const attrEntries = Object.entries(snapshot.attributes)
295
+ if (attrEntries.length > 0) {
296
+ lines.push('ATTRIBUTES')
297
+ for (const [key, value] of attrEntries) {
298
+ lines.push(` ${key}: ${value}`)
299
+ }
300
+ lines.push('')
301
+ }
302
+
303
+ if (snapshot.innerText) {
304
+ lines.push('INNER TEXT')
305
+ lines.push(snapshot.innerText)
306
+ lines.push('')
307
+ }
308
+
309
+ return lines.join('\n')
310
+ }
311
+
312
+ function buildGroupLog(opts: {
313
+ groupLabel: string
314
+ elements: Array<{ snapshot: ElementSnapshot; changes: StyleChange[] }>
315
+ targetUrl: string | null
316
+ pagePath: string
317
+ breakpoint: Breakpoint
318
+ fileMap?: FileMap | null
319
+ projectRoot?: string | null
320
+ framework?: string | null
321
+ cssStrategy?: string[] | null
322
+ }): string {
323
+ const lines: string[] = []
324
+ const isMobileApp =
325
+ opts.framework === 'flutter' || opts.framework === 'react-native'
326
+ const totalChanges = opts.elements.reduce(
327
+ (sum, g) => sum + g.changes.length,
328
+ 0,
329
+ )
330
+ const { deviceName, range } = getBreakpointDeviceInfo(opts.breakpoint)
331
+
332
+ lines.push('=== DEV EDITOR CHANGELOG ===')
333
+ lines.push(`Scope: ${opts.groupLabel}`)
334
+ if (opts.targetUrl) {
335
+ lines.push(`Project URL: ${opts.targetUrl}`)
336
+ lines.push(`Page: ${opts.pagePath || '/'}`)
337
+ if (!isMobileApp) {
338
+ lines.push(`Device Name: ${deviceName}`)
339
+ lines.push(`Breakpoint: ${range}`)
340
+ }
341
+ }
342
+ lines.push(`Generated: ${new Date().toISOString()}`)
343
+ lines.push('')
344
+
345
+ for (let i = 0; i < opts.elements.length; i++) {
346
+ const { snapshot, changes } = opts.elements[i]
347
+ lines.push(
348
+ buildElementSection(
349
+ snapshot,
350
+ changes,
351
+ opts.fileMap,
352
+ opts.projectRoot,
353
+ opts.framework,
354
+ opts.cssStrategy,
355
+ ),
356
+ )
357
+ if (i < opts.elements.length - 1) {
358
+ lines.push('')
359
+ lines.push('---')
360
+ lines.push('')
361
+ }
362
+ }
363
+
364
+ lines.push('')
365
+ lines.push(
366
+ buildInstructionsFooter(totalChanges, opts.elements.length, {
367
+ framework: opts.framework,
368
+ cssStrategy: opts.cssStrategy,
369
+ }),
370
+ )
371
+
372
+ return lines.join('\n').trim()
373
+ }
374
+
375
+ function useCopy() {
376
+ const [copied, setCopied] = useState(false)
377
+ const copy = useCallback(async (text: string) => {
378
+ try {
379
+ await navigator.clipboard.writeText(text)
380
+ } catch {
381
+ const textarea = document.createElement('textarea')
382
+ textarea.value = text
383
+ textarea.style.position = 'fixed'
384
+ textarea.style.opacity = '0'
385
+ document.body.appendChild(textarea)
386
+ textarea.select()
387
+ document.execCommand('copy')
388
+ document.body.removeChild(textarea)
389
+ }
390
+ setCopied(true)
391
+ setTimeout(() => setCopied(false), 2000)
392
+ }, [])
393
+ return { copied, copy }
394
+ }
395
+
396
+ function CopyButton({ text, size = 11 }: { text: string; size?: number }) {
397
+ const { copied, copy } = useCopy()
398
+ return (
399
+ <button
400
+ onClick={(e) => {
401
+ e.stopPropagation()
402
+ copy(text)
403
+ }}
404
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0"
405
+ style={{
406
+ color: copied ? 'var(--success)' : 'var(--text-muted)',
407
+ background: 'transparent',
408
+ }}
409
+ title="Copy to clipboard"
410
+ >
411
+ {copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
412
+ {copied ? 'Copied' : 'Copy'}
413
+ </button>
414
+ )
415
+ }
416
+
417
+ function ClearIcon({ size = 14 }: { size?: number }) {
418
+ return (
419
+ <svg
420
+ width={size}
421
+ height={size}
422
+ viewBox="0 0 24 24"
423
+ fill="none"
424
+ stroke="currentColor"
425
+ strokeWidth={2}
426
+ strokeLinecap="round"
427
+ strokeLinejoin="round"
428
+ >
429
+ <line x1="18" y1="6" x2="6" y2="18" />
430
+ <line x1="6" y1="6" x2="18" y2="18" />
431
+ </svg>
432
+ )
433
+ }
434
+
435
+ function ElementAccordion({
436
+ snapshot,
437
+ changes,
438
+ onRevert,
439
+ liveStyles,
440
+ fileMap,
441
+ projectRoot,
442
+ framework,
443
+ cssStrategy,
444
+ }: {
445
+ snapshot: ElementSnapshot
446
+ changes: StyleChange[]
447
+ onRevert: (id: string, selector: string, property: string) => void
448
+ /** When provided (current element), use these as display values instead of change.newValue. */
449
+ liveStyles?: Record<string, string>
450
+ fileMap?: FileMap | null
451
+ projectRoot?: string | null
452
+ framework?: string | null
453
+ cssStrategy?: string[] | null
454
+ }) {
455
+ const [open, setOpen] = useState(false)
456
+ const editedTextRef = useRef<string | null>(null)
457
+
458
+ const logText = useMemo(
459
+ () =>
460
+ buildSingleElementLog(
461
+ snapshot,
462
+ changes,
463
+ fileMap,
464
+ projectRoot,
465
+ framework,
466
+ cssStrategy,
467
+ ),
468
+ [snapshot, changes, fileMap, projectRoot, framework, cssStrategy],
469
+ )
470
+
471
+ const copyText = editedTextRef.current ?? logText
472
+
473
+ const handleTextChange = useCallback(
474
+ (edited: string) => {
475
+ editedTextRef.current = edited === logText ? null : edited
476
+ },
477
+ [logText],
478
+ )
479
+
480
+ const sourcePath = inferSourcePath({
481
+ tagName: snapshot.tagName,
482
+ className: snapshot.className,
483
+ id: snapshot.elementId,
484
+ selectorPath: snapshot.selectorPath,
485
+ pagePath: snapshot.pagePath,
486
+ fileMap,
487
+ sourceInfo: snapshot.sourceInfo,
488
+ projectRoot,
489
+ })
490
+
491
+ const compName = getComponentName(snapshot.className)
492
+ const label = compName
493
+ ? compName
494
+ : snapshot.elementId
495
+ ? `${snapshot.tagName}#${snapshot.elementId}`
496
+ : snapshot.tagName
497
+
498
+ return (
499
+ <div style={{ borderBottom: '1px solid var(--border)' }}>
500
+ {/* Accordion header */}
501
+ <div
502
+ onClick={() => setOpen(!open)}
503
+ className="flex items-center w-full px-3 py-2 text-xs hover:bg-[var(--bg-hover)] transition-colors cursor-pointer"
504
+ role="button"
505
+ tabIndex={0}
506
+ onKeyDown={(e) => {
507
+ if (e.key === 'Enter' || e.key === ' ') {
508
+ e.preventDefault()
509
+ setOpen(!open)
510
+ }
511
+ }}
512
+ >
513
+ <span
514
+ className="mr-2 text-[10px] transition-transform"
515
+ style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
516
+ >
517
+
518
+ </span>
519
+ <span
520
+ className="flex-1 text-left truncate"
521
+ style={{ color: 'var(--text-secondary)' }}
522
+ >
523
+ <span style={{ color: compName ? '#4ade80' : 'var(--accent)' }}>
524
+ {label}
525
+ </span>
526
+ {compName && (
527
+ <span style={{ color: 'var(--text-muted)' }}>
528
+ {' '}
529
+ ({snapshot.tagName})
530
+ </span>
531
+ )}
532
+ <span style={{ color: 'var(--text-muted)' }}>
533
+ {' '}
534
+ · {sourcePath} · {changes.length} change
535
+ {changes.length !== 1 ? 's' : ''}
536
+ </span>
537
+ </span>
538
+ <CopyButton text={copyText} />
539
+ <button
540
+ onClick={(e) => {
541
+ e.stopPropagation()
542
+ for (const c of changes) {
543
+ onRevert(c.id, c.elementSelector, c.property)
544
+ }
545
+ }}
546
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
547
+ style={{ color: 'var(--text-muted)' }}
548
+ title="Clear all changes for this element"
549
+ >
550
+ <ClearIcon size={11} />
551
+ Clear
552
+ </button>
553
+ </div>
554
+
555
+ {/* Accordion body */}
556
+ {open && (
557
+ <div className="px-3 pb-3">
558
+ <EditablePre
559
+ text={logText}
560
+ onTextChange={handleTextChange}
561
+ className="text-[11px] font-mono whitespace-pre-wrap break-words leading-relaxed mb-2"
562
+ style={{ color: 'var(--text-muted)' }}
563
+ />
564
+
565
+ {/* Per-change undo buttons */}
566
+ <div className="space-y-1">
567
+ {changes.map((change) => {
568
+ const displayVal =
569
+ liveStyles &&
570
+ change.property !== '__text_content__' &&
571
+ change.property !== '__element_deleted__' &&
572
+ change.property !== '__element_inserted__' &&
573
+ change.property !== '__element_moved__'
574
+ ? (liveStyles[change.property] ?? change.newValue)
575
+ : change.newValue
576
+ return (
577
+ <div
578
+ key={change.id}
579
+ className="flex items-center justify-between text-xs"
580
+ >
581
+ <span
582
+ className="truncate"
583
+ style={{ color: 'var(--text-muted)' }}
584
+ >
585
+ {change.property === '__element_inserted__' ? (
586
+ <span style={{ color: 'var(--accent)' }}>
587
+ element inserted
588
+ </span>
589
+ ) : change.property === '__element_moved__' ? (
590
+ <span style={{ color: '#fbbf24' }}>element moved</span>
591
+ ) : change.property === '__element_deleted__' ? (
592
+ <span style={{ color: 'var(--error)' }}>
593
+ element deleted
594
+ </span>
595
+ ) : change.property === '__text_content__' ? (
596
+ <>
597
+ text:{' '}
598
+ <span
599
+ style={{
600
+ color: 'var(--text-muted)',
601
+ textDecoration: 'line-through',
602
+ }}
603
+ >
604
+ {truncateText(change.originalValue, 20)}
605
+ </span>
606
+ {' → '}
607
+ <span style={{ color: 'var(--success)' }}>
608
+ {truncateText(change.newValue, 20)}
609
+ </span>
610
+ </>
611
+ ) : (
612
+ <>
613
+ {camelToKebab(change.property)}:{' '}
614
+ <span style={{ color: 'var(--success)' }}>
615
+ {displayVal}
616
+ </span>
617
+ </>
618
+ )}
619
+ </span>
620
+ <button
621
+ onClick={() =>
622
+ onRevert(
623
+ change.id,
624
+ change.elementSelector,
625
+ change.property,
626
+ )
627
+ }
628
+ className="px-1.5 py-0.5 text-[10px] rounded flex-shrink-0 ml-2"
629
+ style={{
630
+ background: 'var(--bg-tertiary)',
631
+ color: 'var(--text-secondary)',
632
+ border: '1px solid var(--border)',
633
+ }}
634
+ >
635
+ Undo
636
+ </button>
637
+ </div>
638
+ )
639
+ })}
640
+ </div>
641
+ </div>
642
+ )}
643
+ </div>
644
+ )
645
+ }
646
+
647
+ interface BreakpointGroupData {
648
+ key: BreakpointGroupKey
649
+ label: string
650
+ elements: Array<{
651
+ selector: string
652
+ snapshot: ElementSnapshot
653
+ changes: StyleChange[]
654
+ }>
655
+ allChanges: StyleChange[]
656
+ }
657
+
658
+ function BreakpointGroupAccordion({
659
+ group,
660
+ targetUrl,
661
+ pagePath,
662
+ breakpoint,
663
+ onRevert,
664
+ isActiveBreakpoint,
665
+ selectorPath,
666
+ computedStyles,
667
+ fileMap,
668
+ projectRoot,
669
+ framework,
670
+ cssStrategy,
671
+ }: {
672
+ group: BreakpointGroupData
673
+ targetUrl: string | null
674
+ pagePath: string
675
+ breakpoint: Breakpoint
676
+ onRevert: (id: string, selector: string, property: string) => void
677
+ isActiveBreakpoint: boolean
678
+ selectorPath?: string | null
679
+ computedStyles?: Record<string, string>
680
+ fileMap?: FileMap | null
681
+ projectRoot?: string | null
682
+ framework?: string | null
683
+ cssStrategy?: string[] | null
684
+ }) {
685
+ const [open, setOpen] = useState(false)
686
+ const [showConfirm, setShowConfirm] = useState(false)
687
+
688
+ const groupLogText = useMemo(
689
+ () =>
690
+ buildGroupLog({
691
+ groupLabel: group.label,
692
+ elements: group.elements,
693
+ targetUrl,
694
+ pagePath,
695
+ breakpoint,
696
+ fileMap,
697
+ projectRoot,
698
+ framework,
699
+ cssStrategy,
700
+ }),
701
+ [
702
+ group,
703
+ targetUrl,
704
+ pagePath,
705
+ breakpoint,
706
+ fileMap,
707
+ projectRoot,
708
+ framework,
709
+ cssStrategy,
710
+ ],
711
+ )
712
+
713
+ const totalChanges = group.allChanges.length
714
+ const isEmpty = group.elements.length === 0
715
+
716
+ const handleClearGroup = useCallback(() => {
717
+ for (const c of group.allChanges) {
718
+ onRevert(c.id, c.elementSelector, c.property)
719
+ }
720
+ setShowConfirm(false)
721
+ }, [group.allChanges, onRevert])
722
+
723
+ return (
724
+ <div style={{ borderBottom: '2px solid var(--border)' }}>
725
+ {/* Group header */}
726
+ <div
727
+ onClick={() => setOpen(!open)}
728
+ className="flex items-center w-full px-3 py-2 text-xs hover:bg-[var(--bg-hover)] transition-colors cursor-pointer"
729
+ style={{ background: 'rgba(42,42,42,0.5)' }}
730
+ role="button"
731
+ tabIndex={0}
732
+ onKeyDown={(e) => {
733
+ if (e.key === 'Enter' || e.key === ' ') {
734
+ e.preventDefault()
735
+ setOpen(!open)
736
+ }
737
+ }}
738
+ >
739
+ <span
740
+ className="mr-2 text-[10px] transition-transform"
741
+ style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
742
+ >
743
+
744
+ </span>
745
+ <span
746
+ className="flex-1 text-left font-medium"
747
+ style={{ color: 'var(--text-primary)' }}
748
+ >
749
+ {group.label}
750
+ <span
751
+ className="font-normal ml-2"
752
+ style={{ color: 'var(--text-muted)' }}
753
+ >
754
+ {totalChanges} change{totalChanges !== 1 ? 's' : ''}
755
+ </span>
756
+ </span>
757
+ {!isEmpty && (
758
+ <>
759
+ <CopyButton text={groupLogText} size={11} />
760
+ {showConfirm ? (
761
+ <span
762
+ className="flex items-center gap-1 flex-shrink-0"
763
+ onClick={(e) => e.stopPropagation()}
764
+ >
765
+ <button
766
+ onClick={handleClearGroup}
767
+ className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors"
768
+ style={{ background: 'var(--error)', color: '#fff' }}
769
+ >
770
+ Confirm
771
+ </button>
772
+ <button
773
+ onClick={() => setShowConfirm(false)}
774
+ className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
775
+ style={{ color: 'var(--text-muted)' }}
776
+ >
777
+ Cancel
778
+ </button>
779
+ </span>
780
+ ) : (
781
+ <button
782
+ onClick={(e) => {
783
+ e.stopPropagation()
784
+ setShowConfirm(true)
785
+ }}
786
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
787
+ style={{ color: 'var(--text-muted)' }}
788
+ title={`Clear all ${group.label.toLowerCase()} changes`}
789
+ >
790
+ <ClearIcon size={11} />
791
+ Clear
792
+ </button>
793
+ )}
794
+ </>
795
+ )}
796
+ </div>
797
+
798
+ {/* Group body: scope toggle + element accordions or empty state */}
799
+ {open && (
800
+ <div>
801
+ {isActiveBreakpoint && <ChangeScopeToggle />}
802
+ {isEmpty ? (
803
+ <div
804
+ className="px-3 py-3 text-[11px]"
805
+ style={{ color: 'var(--text-muted)' }}
806
+ >
807
+ No changes
808
+ </div>
809
+ ) : (
810
+ group.elements.map(({ selector, snapshot, changes }) => (
811
+ <ElementAccordion
812
+ key={selector}
813
+ snapshot={snapshot}
814
+ changes={changes}
815
+ onRevert={onRevert}
816
+ liveStyles={
817
+ selector === selectorPath ? computedStyles : undefined
818
+ }
819
+ fileMap={fileMap}
820
+ projectRoot={projectRoot}
821
+ framework={framework}
822
+ cssStrategy={cssStrategy}
823
+ />
824
+ ))
825
+ )}
826
+ </div>
827
+ )}
828
+ </div>
829
+ )
830
+ }
831
+
832
+ function ChangeScopeToggle() {
833
+ const changeScope = useEditorStore((s) => s.changeScope)
834
+ const setChangeScope = useEditorStore((s) => s.setChangeScope)
835
+ const updateAllSnapshotsScope = useEditorStore(
836
+ (s) => s.updateAllSnapshotsScope,
837
+ )
838
+ const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
839
+
840
+ const handleScopeChange = useCallback(
841
+ (scope: 'all' | 'breakpoint-only') => {
842
+ setChangeScope(scope)
843
+ updateAllSnapshotsScope(scope)
844
+ },
845
+ [setChangeScope, updateAllSnapshotsScope],
846
+ )
847
+
848
+ return (
849
+ <div
850
+ className="flex items-center justify-between px-3 py-1.5"
851
+ style={{ borderBottom: '1px solid var(--border)' }}
852
+ >
853
+ <span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
854
+ Apply to
855
+ </span>
856
+ <div
857
+ className="flex items-center gap-0.5 rounded p-0.5"
858
+ style={{ background: 'var(--bg-tertiary)' }}
859
+ >
860
+ <button
861
+ onClick={() => handleScopeChange('all')}
862
+ className="px-2 py-0.5 text-[11px] rounded transition-colors"
863
+ style={{
864
+ background:
865
+ changeScope === 'all'
866
+ ? 'var(--accent-bg, rgba(74,158,255,0.15))'
867
+ : 'transparent',
868
+ color:
869
+ changeScope === 'all' ? 'var(--accent)' : 'var(--text-muted)',
870
+ }}
871
+ >
872
+ All
873
+ </button>
874
+ <button
875
+ onClick={() => handleScopeChange('breakpoint-only')}
876
+ className="px-2 py-0.5 text-[11px] rounded transition-colors capitalize"
877
+ style={{
878
+ background:
879
+ changeScope === 'breakpoint-only'
880
+ ? 'var(--accent-bg, rgba(74,158,255,0.15))'
881
+ : 'transparent',
882
+ color:
883
+ changeScope === 'breakpoint-only'
884
+ ? 'var(--accent)'
885
+ : 'var(--text-muted)',
886
+ }}
887
+ >
888
+ {activeBreakpoint} only
889
+ </button>
890
+ </div>
891
+ </div>
892
+ )
893
+ }
894
+
895
+ function ScanIcon({ size = 14 }: { size?: number }) {
896
+ return (
897
+ <svg
898
+ width={size}
899
+ height={size}
900
+ viewBox="0 0 24 24"
901
+ fill="none"
902
+ stroke="currentColor"
903
+ strokeWidth={2}
904
+ strokeLinecap="round"
905
+ strokeLinejoin="round"
906
+ >
907
+ <path d="M3 7V5a2 2 0 0 1 2-2h2" />
908
+ <path d="M17 3h2a2 2 0 0 1 2 2v2" />
909
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
910
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
911
+ <line x1="7" y1="12" x2="17" y2="12" />
912
+ </svg>
913
+ )
914
+ }
915
+
916
+ function SpinnerIcon({ size = 14 }: { size?: number }) {
917
+ return (
918
+ <svg
919
+ width={size}
920
+ height={size}
921
+ viewBox="0 0 24 24"
922
+ fill="none"
923
+ stroke="currentColor"
924
+ strokeWidth={2}
925
+ strokeLinecap="round"
926
+ className="animate-spin"
927
+ >
928
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
929
+ </svg>
930
+ )
931
+ }
932
+
933
+ function BottomActionBar({
934
+ copyAllText,
935
+ changeCount,
936
+ showClearConfirm,
937
+ onClearAll,
938
+ onShowClearConfirm,
939
+ onCancelClear,
940
+ onAiScan,
941
+ aiScanStatus,
942
+ }: {
943
+ copyAllText: string
944
+ changeCount: number
945
+ showClearConfirm: boolean
946
+ onClearAll: () => void
947
+ onShowClearConfirm: () => void
948
+ onCancelClear: () => void
949
+ onAiScan: () => void
950
+ aiScanStatus: 'idle' | 'scanning' | 'complete' | 'error'
951
+ }) {
952
+ const { copied, copy } = useCopy()
953
+
954
+ return (
955
+ <div
956
+ className="flex-shrink-0 px-3 py-3 flex flex-col gap-2"
957
+ style={{
958
+ borderTop: '1px solid var(--border)',
959
+ background:
960
+ 'linear-gradient(to top, rgba(30,30,30,0.95), rgba(30,30,30,0.8))',
961
+ backdropFilter: 'blur(8px)',
962
+ }}
963
+ >
964
+ {/* Primary: Copy All Changes */}
965
+ <button
966
+ onClick={() => copy(copyAllText)}
967
+ className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-md text-[12px] font-medium transition-all"
968
+ style={{
969
+ background: copied
970
+ ? 'rgba(74, 222, 128, 0.15)'
971
+ : 'rgba(74, 158, 255, 0.12)',
972
+ color: copied ? 'var(--success)' : 'var(--accent)',
973
+ border: `1px solid ${copied ? 'rgba(74, 222, 128, 0.3)' : 'rgba(74, 158, 255, 0.25)'}`,
974
+ }}
975
+ >
976
+ {copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
977
+ {copied ? 'Copied to clipboard' : `Copy All Changes (${changeCount})`}
978
+ </button>
979
+
980
+ {/* AI Scan */}
981
+ <button
982
+ onClick={onAiScan}
983
+ disabled={aiScanStatus === 'scanning'}
984
+ className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-md text-[12px] font-medium transition-all disabled:opacity-60"
985
+ style={{
986
+ background:
987
+ aiScanStatus === 'scanning'
988
+ ? 'rgba(168, 85, 247, 0.06)'
989
+ : 'rgba(168, 85, 247, 0.12)',
990
+ color: aiScanStatus === 'scanning' ? 'var(--text-muted)' : '#a855f7',
991
+ border: `1px solid ${aiScanStatus === 'scanning' ? 'var(--border)' : 'rgba(168, 85, 247, 0.25)'}`,
992
+ }}
993
+ >
994
+ {aiScanStatus === 'scanning' ? (
995
+ <SpinnerIcon size={14} />
996
+ ) : (
997
+ <ScanIcon size={14} />
998
+ )}
999
+ {aiScanStatus === 'scanning' ? 'Scanning...' : 'AI Scan'}
1000
+ </button>
1001
+
1002
+ {/* Secondary: Clear */}
1003
+ {showClearConfirm ? (
1004
+ <div className="flex items-center gap-2">
1005
+ <button
1006
+ onClick={onClearAll}
1007
+ className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-[12px] font-medium transition-all"
1008
+ style={{
1009
+ background: 'rgba(248, 113, 113, 0.15)',
1010
+ color: 'var(--error)',
1011
+ border: '1px solid rgba(248, 113, 113, 0.3)',
1012
+ }}
1013
+ >
1014
+ <ClearIcon size={12} />
1015
+ Confirm Clear
1016
+ </button>
1017
+ <button
1018
+ onClick={onCancelClear}
1019
+ className="py-2 px-4 rounded-md text-[12px] transition-all"
1020
+ style={{
1021
+ color: 'var(--text-muted)',
1022
+ border: '1px solid var(--border)',
1023
+ }}
1024
+ >
1025
+ Cancel
1026
+ </button>
1027
+ </div>
1028
+ ) : (
1029
+ <button
1030
+ onClick={onShowClearConfirm}
1031
+ className="w-full flex items-center justify-center gap-1.5 py-2 px-4 rounded-md text-[12px] transition-all"
1032
+ style={{
1033
+ color: 'var(--text-muted)',
1034
+ border: '1px solid var(--border)',
1035
+ }}
1036
+ title="Clear all changes"
1037
+ >
1038
+ <ClearIcon size={12} />
1039
+ Clear All Changes
1040
+ </button>
1041
+ )}
1042
+ </div>
1043
+ )
1044
+ }
1045
+
1046
+ export function ChangesPanel() {
1047
+ const styleChanges = useEditorStore((s) => s.styleChanges)
1048
+ const elementSnapshots = useEditorStore((s) => s.elementSnapshots)
1049
+ const targetUrl = useEditorStore((s) => s.targetUrl)
1050
+ const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
1051
+ const currentPagePath = useEditorStore((s) => s.currentPagePath)
1052
+ const selectorPath = useEditorStore((s) => s.selectorPath)
1053
+ const computedStyles = useEditorStore((s) => s.computedStyles)
1054
+ const getProjectScanForUrl = useEditorStore((s) => s.getProjectScanForUrl)
1055
+ const getProjectRootForUrl = useEditorStore((s) => s.getProjectRootForUrl)
1056
+ const aiScanStatus = useEditorStore((s) => s.aiScanStatus)
1057
+ const aiScanResult = useEditorStore((s) => s.aiScanResult)
1058
+ const aiScanError = useEditorStore((s) => s.aiScanError)
1059
+ const setAiScanStatus = useEditorStore((s) => s.setAiScanStatus)
1060
+ const setAiScanResult = useEditorStore((s) => s.setAiScanResult)
1061
+ const setAiScanError = useEditorStore((s) => s.setAiScanError)
1062
+ const resetAiScan = useEditorStore((s) => s.resetAiScan)
1063
+ const showToast = useEditorStore((s) => s.showToast)
1064
+ const setActiveRightTab = useEditorStore((s) => s.setActiveRightTab)
1065
+ const removeStyleChange = useEditorStore((s) => s.removeStyleChange)
1066
+ const removeCreatedComponent = useEditorStore((s) => s.removeCreatedComponent)
1067
+ const { revertChange } = useChangeTracker()
1068
+ const [showClearConfirm, setShowClearConfirm] = useState(false)
1069
+ const setActiveLeftTab = useEditorStore((s) => s.setActiveLeftTab)
1070
+ const scanAbortRef = useRef<AbortController | null>(null)
1071
+
1072
+ const projectScan = useMemo(() => {
1073
+ return getProjectScanForUrl(targetUrl)
1074
+ }, [targetUrl, getProjectScanForUrl])
1075
+
1076
+ const fileMap = projectScan?.fileMap ?? null
1077
+ const framework = projectScan?.framework ?? null
1078
+ const cssStrategy = projectScan?.cssStrategy ?? null
1079
+
1080
+ const projectRoot = useMemo(() => {
1081
+ return getProjectRootForUrl(targetUrl)
1082
+ }, [targetUrl, getProjectRootForUrl])
1083
+
1084
+ // Separate component extractions from regular changes
1085
+ const { componentExtractions, regularChanges } = useMemo(() => {
1086
+ const extractions: StyleChange[] = []
1087
+ const regular: StyleChange[] = []
1088
+ for (const change of styleChanges) {
1089
+ if (change.property === '__component_creation__') {
1090
+ extractions.push(change)
1091
+ } else {
1092
+ regular.push(change)
1093
+ }
1094
+ }
1095
+ return { componentExtractions: extractions, regularChanges: regular }
1096
+ }, [styleChanges])
1097
+
1098
+ // Filter regular changes to current breakpoint
1099
+ const breakpointChanges = useMemo(() => {
1100
+ return regularChanges.filter((c) => c.breakpoint === activeBreakpoint)
1101
+ }, [regularChanges, activeBreakpoint])
1102
+
1103
+ // Group filtered changes by element selector
1104
+ const elementGroups = useMemo(() => {
1105
+ const elementMap = new Map<string, StyleChange[]>()
1106
+ for (const change of breakpointChanges) {
1107
+ const existing = elementMap.get(change.elementSelector) || []
1108
+ existing.push(change)
1109
+ elementMap.set(change.elementSelector, existing)
1110
+ }
1111
+
1112
+ const elements: Array<{
1113
+ selector: string
1114
+ snapshot: ElementSnapshot
1115
+ changes: StyleChange[]
1116
+ }> = []
1117
+ for (const [selector, changes] of elementMap) {
1118
+ const snapshot = elementSnapshots[selector]
1119
+ if (snapshot) {
1120
+ elements.push({ selector, snapshot, changes })
1121
+ }
1122
+ }
1123
+ return elements
1124
+ }, [breakpointChanges, elementSnapshots])
1125
+
1126
+ // Build "Copy All" log text (includes component extractions)
1127
+ const copyAllText = useMemo(() => {
1128
+ const hasChanges = elementGroups.length > 0
1129
+ const hasExtractions = componentExtractions.length > 0
1130
+ if (!hasChanges && !hasExtractions) return ''
1131
+
1132
+ const parts: string[] = []
1133
+
1134
+ // Component extractions section
1135
+ if (hasExtractions) {
1136
+ for (const extraction of componentExtractions) {
1137
+ parts.push(buildComponentCreationLog(extraction))
1138
+ }
1139
+ }
1140
+
1141
+ // Style changes section
1142
+ if (hasChanges) {
1143
+ parts.push(
1144
+ buildGroupLog({
1145
+ groupLabel: 'All Changes',
1146
+ elements: elementGroups,
1147
+ targetUrl,
1148
+ pagePath: currentPagePath,
1149
+ breakpoint: activeBreakpoint,
1150
+ fileMap,
1151
+ projectRoot,
1152
+ framework,
1153
+ cssStrategy,
1154
+ }),
1155
+ )
1156
+ }
1157
+
1158
+ return parts.join('\n\n')
1159
+ }, [
1160
+ elementGroups,
1161
+ componentExtractions,
1162
+ targetUrl,
1163
+ currentPagePath,
1164
+ activeBreakpoint,
1165
+ fileMap,
1166
+ projectRoot,
1167
+ framework,
1168
+ cssStrategy,
1169
+ ])
1170
+
1171
+ const handleRevertExtraction = useCallback(
1172
+ (extraction: StyleChange) => {
1173
+ removeStyleChange(extraction.id)
1174
+ removeCreatedComponent(extraction.elementSelector)
1175
+ },
1176
+ [removeStyleChange, removeCreatedComponent],
1177
+ )
1178
+
1179
+ const handleClearAll = useCallback(() => {
1180
+ // Revert component extractions
1181
+ for (const extraction of componentExtractions) {
1182
+ handleRevertExtraction(extraction)
1183
+ }
1184
+ // Revert style changes
1185
+ for (const { changes } of elementGroups) {
1186
+ for (const c of changes) {
1187
+ revertChange(c.id, c.elementSelector, c.property)
1188
+ }
1189
+ }
1190
+ resetAiScan()
1191
+ setShowClearConfirm(false)
1192
+ }, [
1193
+ elementGroups,
1194
+ componentExtractions,
1195
+ revertChange,
1196
+ handleRevertExtraction,
1197
+ resetAiScan,
1198
+ ])
1199
+
1200
+ const handleAiScan = useCallback(() => {
1201
+ if (!targetUrl || !projectRoot || breakpointChanges.length === 0) return
1202
+
1203
+ setAiScanStatus('scanning')
1204
+ setAiScanError(null)
1205
+
1206
+ // Auto-switch to Terminal tab so user sees progress
1207
+ setActiveLeftTab('terminal')
1208
+
1209
+ // Write header to terminal
1210
+ const write = useEditorStore.getState().writeToTerminal
1211
+ write?.('\r\n\x1b[1;35m AI Scan: Analyzing project...\x1b[0m\r\n')
1212
+
1213
+ // Abort any previous scan stream
1214
+ scanAbortRef.current?.abort()
1215
+
1216
+ const controller = consumeClaudeStream<ClaudeScanResponse>(
1217
+ '/api/claude/scan',
1218
+ { changelog: copyAllText, projectRoot, projectScan },
1219
+ {
1220
+ onStderr: (line: string) => {
1221
+ const w = useEditorStore.getState().writeToTerminal
1222
+ const formatted = formatStderrLine(line)
1223
+ if (formatted) w?.(formatted + '\r\n')
1224
+ },
1225
+ onResult: (data: ClaudeScanResponse) => {
1226
+ setAiScanResult(data)
1227
+ setAiScanStatus('complete')
1228
+ showToast('success', 'AI Scan complete')
1229
+ const w = useEditorStore.getState().writeToTerminal
1230
+ w?.('\x1b[32m AI Scan complete.\x1b[0m\r\n')
1231
+ },
1232
+ onError: (err: { code: string; message: string }) => {
1233
+ setAiScanStatus('error')
1234
+ setAiScanError(err.message)
1235
+ showToast('error', err.message || 'AI Scan failed')
1236
+ const w = useEditorStore.getState().writeToTerminal
1237
+ w?.(`\x1b[31m Error: ${err.message}\x1b[0m\r\n`)
1238
+ },
1239
+ },
1240
+ )
1241
+
1242
+ scanAbortRef.current = controller
1243
+ }, [
1244
+ targetUrl,
1245
+ projectRoot,
1246
+ breakpointChanges.length,
1247
+ copyAllText,
1248
+ projectScan,
1249
+ setAiScanStatus,
1250
+ setAiScanError,
1251
+ setAiScanResult,
1252
+ showToast,
1253
+ setActiveLeftTab,
1254
+ ])
1255
+
1256
+ const handleSendToClaudeCode = useCallback(
1257
+ (prompt: string) => {
1258
+ // Store the edited prompt so ClaudeIntegrationPanel can pick it up
1259
+ setAiScanResult({ ...aiScanResult!, smartPrompt: prompt })
1260
+ setActiveRightTab('claude')
1261
+ },
1262
+ [aiScanResult, setAiScanResult, setActiveRightTab],
1263
+ )
1264
+
1265
+ if (styleChanges.length === 0) {
1266
+ return (
1267
+ <div
1268
+ className="flex items-center justify-center h-full text-xs"
1269
+ style={{ color: 'var(--text-muted)' }}
1270
+ >
1271
+ No changes tracked yet
1272
+ </div>
1273
+ )
1274
+ }
1275
+
1276
+ if (breakpointChanges.length === 0 && componentExtractions.length === 0) {
1277
+ return (
1278
+ <div className="flex flex-col h-full">
1279
+ <ChangeScopeToggle />
1280
+ <div
1281
+ className="flex items-center justify-center flex-1 text-xs"
1282
+ style={{ color: 'var(--text-muted)' }}
1283
+ >
1284
+ No changes on {activeBreakpoint}
1285
+ </div>
1286
+ </div>
1287
+ )
1288
+ }
1289
+
1290
+ return (
1291
+ <div className="flex flex-col h-full">
1292
+ {/* Scope toggle */}
1293
+ <ChangeScopeToggle />
1294
+
1295
+ {/* Header */}
1296
+ <div
1297
+ className="flex items-center justify-between px-3 py-1.5 flex-shrink-0"
1298
+ style={{ borderBottom: '1px solid var(--border)' }}
1299
+ >
1300
+ <span className="text-xs" style={{ color: 'var(--text-muted)' }}>
1301
+ {breakpointChanges.length} change
1302
+ {breakpointChanges.length !== 1 ? 's' : ''} · {elementGroups.length}{' '}
1303
+ element{elementGroups.length !== 1 ? 's' : ''}
1304
+ </span>
1305
+ <span className="flex items-center gap-1">
1306
+ <CopyButton text={copyAllText} size={11} />
1307
+ {showClearConfirm ? (
1308
+ <span
1309
+ className="flex items-center gap-1"
1310
+ onClick={(e) => e.stopPropagation()}
1311
+ >
1312
+ <button
1313
+ onClick={handleClearAll}
1314
+ className="px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors"
1315
+ style={{ background: 'var(--error)', color: '#fff' }}
1316
+ >
1317
+ Confirm
1318
+ </button>
1319
+ <button
1320
+ onClick={() => setShowClearConfirm(false)}
1321
+ className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
1322
+ style={{ color: 'var(--text-muted)' }}
1323
+ >
1324
+ Cancel
1325
+ </button>
1326
+ </span>
1327
+ ) : (
1328
+ <button
1329
+ onClick={() => setShowClearConfirm(true)}
1330
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
1331
+ style={{ color: 'var(--text-muted)' }}
1332
+ title="Clear all changes"
1333
+ >
1334
+ <ClearIcon size={11} />
1335
+ Clear
1336
+ </button>
1337
+ )}
1338
+ </span>
1339
+ </div>
1340
+
1341
+ {/* Flat element list */}
1342
+ <div className="flex-1 overflow-y-auto">
1343
+ {/* Component extraction entries */}
1344
+ {componentExtractions.map((extraction) => {
1345
+ let name = 'Component'
1346
+ try {
1347
+ const data = JSON.parse(extraction.newValue)
1348
+ name = data.name || 'Component'
1349
+ } catch {
1350
+ /* use default */
1351
+ }
1352
+ return (
1353
+ <div
1354
+ key={extraction.id}
1355
+ className="flex items-center gap-2 px-3 py-2 text-xs"
1356
+ style={{
1357
+ borderBottom: '1px solid var(--border)',
1358
+ borderLeft: '2px solid var(--accent)',
1359
+ }}
1360
+ >
1361
+ <svg
1362
+ width="14"
1363
+ height="14"
1364
+ viewBox="0 0 16 16"
1365
+ fill="none"
1366
+ stroke="var(--accent)"
1367
+ strokeWidth="1.5"
1368
+ strokeLinecap="round"
1369
+ strokeLinejoin="round"
1370
+ className="flex-shrink-0"
1371
+ >
1372
+ <rect x="1" y="4" width="14" height="8" rx="1.5" />
1373
+ <path d="M4 4V2.5A1.5 1.5 0 0 1 5.5 1h5A1.5 1.5 0 0 1 12 2.5V4" />
1374
+ </svg>
1375
+ <div className="truncate flex-1">
1376
+ <div
1377
+ className="truncate font-medium"
1378
+ style={{ color: 'var(--accent)' }}
1379
+ >
1380
+ Create {name} component
1381
+ </div>
1382
+ <div
1383
+ className="truncate"
1384
+ style={{ color: 'var(--text-muted)', fontSize: '10px' }}
1385
+ >
1386
+ {extraction.elementSelector}
1387
+ </div>
1388
+ </div>
1389
+ <CopyButton
1390
+ text={buildComponentCreationLog(extraction)}
1391
+ size={11}
1392
+ />
1393
+ <button
1394
+ onClick={() => handleRevertExtraction(extraction)}
1395
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors flex-shrink-0 hover:bg-[var(--bg-hover)]"
1396
+ style={{ color: 'var(--text-muted)' }}
1397
+ title="Remove component extraction"
1398
+ >
1399
+ <ClearIcon size={11} />
1400
+ Clear
1401
+ </button>
1402
+ </div>
1403
+ )
1404
+ })}
1405
+
1406
+ {elementGroups.map(({ selector, snapshot, changes }) => (
1407
+ <ElementAccordion
1408
+ key={selector}
1409
+ snapshot={snapshot}
1410
+ changes={changes}
1411
+ onRevert={revertChange}
1412
+ liveStyles={selector === selectorPath ? computedStyles : undefined}
1413
+ fileMap={fileMap}
1414
+ projectRoot={projectRoot}
1415
+ framework={framework}
1416
+ cssStrategy={cssStrategy}
1417
+ />
1418
+ ))}
1419
+ </div>
1420
+
1421
+ {/* AI Scan result panel */}
1422
+ {aiScanStatus === 'complete' && aiScanResult && (
1423
+ <AiScanResultPanel
1424
+ result={aiScanResult}
1425
+ onDismiss={resetAiScan}
1426
+ onSendToClaudeCode={handleSendToClaudeCode}
1427
+ />
1428
+ )}
1429
+
1430
+ {/* AI Scan error */}
1431
+ {aiScanStatus === 'error' && (
1432
+ <div
1433
+ className="mx-3 mt-2 px-2 py-1.5 rounded text-[11px]"
1434
+ style={{
1435
+ background: 'rgba(248, 113, 113, 0.08)',
1436
+ border: '1px solid rgba(248, 113, 113, 0.25)',
1437
+ color: 'var(--error)',
1438
+ }}
1439
+ >
1440
+ {aiScanError?.includes('not authenticated') ||
1441
+ aiScanError?.includes('claude login') ? (
1442
+ <>
1443
+ <span className="font-medium">Claude CLI not authenticated.</span>{' '}
1444
+ Run{' '}
1445
+ <code
1446
+ className="px-1 py-0.5 rounded text-[10px]"
1447
+ style={{ background: 'rgba(248, 113, 113, 0.15)' }}
1448
+ >
1449
+ claude login
1450
+ </code>{' '}
1451
+ in your terminal, then try again.
1452
+ </>
1453
+ ) : (
1454
+ aiScanError || 'AI Scan failed. Try again.'
1455
+ )}
1456
+ </div>
1457
+ )}
1458
+
1459
+ {/* Bottom action bar */}
1460
+ {breakpointChanges.length > 0 && (
1461
+ <BottomActionBar
1462
+ copyAllText={copyAllText}
1463
+ changeCount={breakpointChanges.length}
1464
+ showClearConfirm={showClearConfirm}
1465
+ onClearAll={handleClearAll}
1466
+ onShowClearConfirm={() => setShowClearConfirm(true)}
1467
+ onCancelClear={() => setShowClearConfirm(false)}
1468
+ onAiScan={handleAiScan}
1469
+ aiScanStatus={aiScanStatus}
1470
+ />
1471
+ )}
1472
+ </div>
1473
+ )
1474
+ }