@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,409 @@
1
+ import type {
2
+ CSSVariableDefinition,
3
+ CSSVariableFamily,
4
+ } from '@/types/cssVariables'
5
+
6
+ /**
7
+ * Extract the variable name from a var() expression.
8
+ * e.g. 'var(--primary-500)' → '--primary-500'
9
+ * 'var(--primary-500, #fff)' → '--primary-500'
10
+ */
11
+ export function extractVariableName(expr: string): string | null {
12
+ const match = expr.match(/var\(\s*(--[^,)]+)/)
13
+ return match ? match[1].trim() : null
14
+ }
15
+
16
+ /**
17
+ * Group CSS variable definitions into families by shared prefix.
18
+ * Only creates a family when 2+ members share the same prefix.
19
+ * e.g. --primary-100, --primary-200 → family prefix '--primary'
20
+ */
21
+ export function groupVariablesIntoFamilies(
22
+ definitions: Record<string, CSSVariableDefinition>,
23
+ ): CSSVariableFamily[] {
24
+ const prefixMap = new Map<
25
+ string,
26
+ { name: string; suffix: string; value: string; resolvedValue: string }[]
27
+ >()
28
+
29
+ for (const [name, def] of Object.entries(definitions)) {
30
+ // Find last hyphen-separated segment as suffix
31
+ const lastDash = name.lastIndexOf('-')
32
+ if (lastDash <= 2) continue // skip if no meaningful prefix (-- is index 0-1)
33
+
34
+ const prefix = name.substring(0, lastDash)
35
+ const suffix = name.substring(lastDash + 1)
36
+
37
+ if (!prefixMap.has(prefix)) {
38
+ prefixMap.set(prefix, [])
39
+ }
40
+ prefixMap.get(prefix)!.push({
41
+ name,
42
+ suffix,
43
+ value: def.value,
44
+ resolvedValue: def.resolvedValue,
45
+ })
46
+ }
47
+
48
+ const families: CSSVariableFamily[] = []
49
+ for (const [prefix, members] of prefixMap) {
50
+ if (members.length >= 2) {
51
+ families.push({ prefix, members })
52
+ }
53
+ }
54
+
55
+ return families
56
+ }
57
+
58
+ /**
59
+ * Find the family that contains a given variable name.
60
+ */
61
+ export function findFamilyForVariable(
62
+ name: string,
63
+ families: CSSVariableFamily[],
64
+ ): CSSVariableFamily | null {
65
+ for (const family of families) {
66
+ if (family.members.some((m) => m.name === name)) {
67
+ return family
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ const COLOR_PATTERN =
74
+ /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|transparent|currentcolor|inherit)$/i
75
+
76
+ // Tailwind CSS channel formats: space-separated RGB (e.g. "5 5 5", "74 255 215")
77
+ // or HSL (e.g. "0 0% 3.9%", "220 70% 50%") used with opacity support.
78
+ const RGB_CHANNELS_PATTERN = /^\d{1,3}\s+\d{1,3}\s+\d{1,3}$/
79
+ const HSL_CHANNELS_PATTERN =
80
+ /^\d{1,3}(\.\d+)?\s+\d{1,3}(\.\d+)?%\s+\d{1,3}(\.\d+)?%$/
81
+
82
+ const NAMED_COLORS = new Set([
83
+ 'aliceblue',
84
+ 'antiquewhite',
85
+ 'aqua',
86
+ 'aquamarine',
87
+ 'azure',
88
+ 'beige',
89
+ 'bisque',
90
+ 'black',
91
+ 'blanchedalmond',
92
+ 'blue',
93
+ 'blueviolet',
94
+ 'brown',
95
+ 'burlywood',
96
+ 'cadetblue',
97
+ 'chartreuse',
98
+ 'chocolate',
99
+ 'coral',
100
+ 'cornflowerblue',
101
+ 'cornsilk',
102
+ 'crimson',
103
+ 'cyan',
104
+ 'darkblue',
105
+ 'darkcyan',
106
+ 'darkgoldenrod',
107
+ 'darkgray',
108
+ 'darkgreen',
109
+ 'darkgrey',
110
+ 'darkkhaki',
111
+ 'darkmagenta',
112
+ 'darkolivegreen',
113
+ 'darkorange',
114
+ 'darkorchid',
115
+ 'darkred',
116
+ 'darksalmon',
117
+ 'darkseagreen',
118
+ 'darkslateblue',
119
+ 'darkslategray',
120
+ 'darkslategrey',
121
+ 'darkturquoise',
122
+ 'darkviolet',
123
+ 'deeppink',
124
+ 'deepskyblue',
125
+ 'dimgray',
126
+ 'dimgrey',
127
+ 'dodgerblue',
128
+ 'firebrick',
129
+ 'floralwhite',
130
+ 'forestgreen',
131
+ 'fuchsia',
132
+ 'gainsboro',
133
+ 'ghostwhite',
134
+ 'gold',
135
+ 'goldenrod',
136
+ 'gray',
137
+ 'green',
138
+ 'greenyellow',
139
+ 'grey',
140
+ 'honeydew',
141
+ 'hotpink',
142
+ 'indianred',
143
+ 'indigo',
144
+ 'ivory',
145
+ 'khaki',
146
+ 'lavender',
147
+ 'lavenderblush',
148
+ 'lawngreen',
149
+ 'lemonchiffon',
150
+ 'lightblue',
151
+ 'lightcoral',
152
+ 'lightcyan',
153
+ 'lightgoldenrodyellow',
154
+ 'lightgray',
155
+ 'lightgreen',
156
+ 'lightgrey',
157
+ 'lightpink',
158
+ 'lightsalmon',
159
+ 'lightseagreen',
160
+ 'lightskyblue',
161
+ 'lightslategray',
162
+ 'lightslategrey',
163
+ 'lightsteelblue',
164
+ 'lightyellow',
165
+ 'lime',
166
+ 'limegreen',
167
+ 'linen',
168
+ 'magenta',
169
+ 'maroon',
170
+ 'mediumaquamarine',
171
+ 'mediumblue',
172
+ 'mediumorchid',
173
+ 'mediumpurple',
174
+ 'mediumseagreen',
175
+ 'mediumslateblue',
176
+ 'mediumspringgreen',
177
+ 'mediumturquoise',
178
+ 'mediumvioletred',
179
+ 'midnightblue',
180
+ 'mintcream',
181
+ 'mistyrose',
182
+ 'moccasin',
183
+ 'navajowhite',
184
+ 'navy',
185
+ 'oldlace',
186
+ 'olive',
187
+ 'olivedrab',
188
+ 'orange',
189
+ 'orangered',
190
+ 'orchid',
191
+ 'palegoldenrod',
192
+ 'palegreen',
193
+ 'paleturquoise',
194
+ 'palevioletred',
195
+ 'papayawhip',
196
+ 'peachpuff',
197
+ 'peru',
198
+ 'pink',
199
+ 'plum',
200
+ 'powderblue',
201
+ 'purple',
202
+ 'rebeccapurple',
203
+ 'red',
204
+ 'rosybrown',
205
+ 'royalblue',
206
+ 'saddlebrown',
207
+ 'salmon',
208
+ 'sandybrown',
209
+ 'seagreen',
210
+ 'seashell',
211
+ 'sienna',
212
+ 'silver',
213
+ 'skyblue',
214
+ 'slateblue',
215
+ 'slategray',
216
+ 'slategrey',
217
+ 'snow',
218
+ 'springgreen',
219
+ 'steelblue',
220
+ 'tan',
221
+ 'teal',
222
+ 'thistle',
223
+ 'tomato',
224
+ 'turquoise',
225
+ 'violet',
226
+ 'wheat',
227
+ 'white',
228
+ 'whitesmoke',
229
+ 'yellow',
230
+ 'yellowgreen',
231
+ ])
232
+
233
+ /**
234
+ * Check if a resolved value looks like a color.
235
+ */
236
+ export function isColorValue(value: string): boolean {
237
+ const trimmed = value.trim().toLowerCase()
238
+ if (COLOR_PATTERN.test(trimmed)) return true
239
+ if (NAMED_COLORS.has(trimmed)) return true
240
+ // Tailwind-style space-separated RGB channels (e.g. "5 5 5", "74 255 215")
241
+ if (RGB_CHANNELS_PATTERN.test(trimmed)) return true
242
+ // Tailwind-style space-separated HSL channels (e.g. "0 0% 3.9%", "220 70% 50%")
243
+ if (HSL_CHANNELS_PATTERN.test(trimmed)) return true
244
+ return false
245
+ }
246
+
247
+ /**
248
+ * Convert a resolved value to a displayable CSS color string.
249
+ * Handles Tailwind-style channel values (e.g. "5 5 5" → "rgb(5, 5, 5)",
250
+ * "220 70% 50%" → "hsl(220, 70%, 50%)") that aren't valid CSS by themselves.
251
+ * Returns the original value if it's already a valid CSS color.
252
+ */
253
+ export function toDisplayableColor(value: string): string {
254
+ const trimmed = value.trim()
255
+ if (RGB_CHANNELS_PATTERN.test(trimmed)) {
256
+ const parts = trimmed.split(/\s+/)
257
+ return `rgb(${parts[0]}, ${parts[1]}, ${parts[2]})`
258
+ }
259
+ if (HSL_CHANNELS_PATTERN.test(trimmed)) {
260
+ const parts = trimmed.split(/\s+/)
261
+ return `hsl(${parts[0]}, ${parts[1]}, ${parts[2]})`
262
+ }
263
+ return trimmed
264
+ }
265
+
266
+ /**
267
+ * Filter variable definitions to only those whose resolved values are colors.
268
+ */
269
+ export function filterColorVariables(
270
+ definitions: Record<string, CSSVariableDefinition>,
271
+ ): Record<string, CSSVariableDefinition> {
272
+ const result: Record<string, CSSVariableDefinition> = {}
273
+ for (const [name, def] of Object.entries(definitions)) {
274
+ if (isColorValue(def.resolvedValue)) {
275
+ result[name] = def
276
+ }
277
+ }
278
+ return result
279
+ }
280
+
281
+ /**
282
+ * Format a CSS variable name as a slash-separated token path for display.
283
+ * e.g. '--primary-500' → 'primary/500'
284
+ * '--color-red-400' → 'color/red/400'
285
+ */
286
+ export function formatTokenDisplayName(cssVarName: string): string {
287
+ const stripped = cssVarName.startsWith('--')
288
+ ? cssVarName.slice(2)
289
+ : cssVarName
290
+ return stripped.replace(/-/g, '/')
291
+ }
292
+
293
+ /**
294
+ * Convert a camelCase or PascalCase string to kebab-case.
295
+ * e.g. 'coreBlue' → 'core-blue', 'textPrimary' → 'text-primary'
296
+ */
297
+ function camelToKebab(str: string): string {
298
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
299
+ }
300
+
301
+ // Matches: key: '#hex' or key: 'rgba(...)' or key: "value" or key: number
302
+ const TOKEN_ENTRY_RE =
303
+ /(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|(\d+(?:\.\d+)?))\s*,?/g
304
+
305
+ // Matches: export const NAME = { ... } as const (captures NAME and the braced body)
306
+ const EXPORT_BLOCK_RE =
307
+ /export\s+const\s+(\w+)\s*=\s*\{([^]*?)\}\s*(?:as\s+const\s*)?;/g
308
+
309
+ /**
310
+ * Extract design tokens from a JS/TS/Dart source file and convert them
311
+ * to CSSVariableDefinition records.
312
+ *
313
+ * Handles patterns like:
314
+ * export const colors = { teal: '#2CEAE1', coreBlue: '#1F8EE7' } as const;
315
+ * export const spacing = { xs: 4, sm: 8 } as const;
316
+ *
317
+ * Produces:
318
+ * '--colors-teal': { value: '#2CEAE1', resolvedValue: '#2CEAE1', selector: 'tokens' }
319
+ * '--spacing-xs': { value: '4', resolvedValue: '4', selector: 'tokens' }
320
+ */
321
+ export function extractDesignTokensFromSource(
322
+ source: string,
323
+ filePath: string,
324
+ ): Record<string, CSSVariableDefinition> {
325
+ const results: Record<string, CSSVariableDefinition> = {}
326
+
327
+ // Strip single-line and block comments to avoid matching inside them
328
+ const cleaned = source.replace(/\/\/.*$/gm, '').replace(/\/\*[^]*?\*\//g, '')
329
+
330
+ EXPORT_BLOCK_RE.lastIndex = 0
331
+ let blockMatch: RegExpExecArray | null
332
+
333
+ while ((blockMatch = EXPORT_BLOCK_RE.exec(cleaned)) !== null) {
334
+ const groupName = blockMatch[1] // e.g. 'colors', 'spacing', 'onGradient'
335
+ const body = blockMatch[2]
336
+
337
+ // Check for nested objects: key: { subKey: value }
338
+ // Split into top-level entries and nested blocks
339
+ const nestedRe = /(\w+)\s*:\s*\{([^}]*)\}/g
340
+ const nestedKeys = new Set<string>()
341
+ let nestedMatch: RegExpExecArray | null
342
+ nestedRe.lastIndex = 0
343
+
344
+ while ((nestedMatch = nestedRe.exec(body)) !== null) {
345
+ const nestedGroupName = nestedMatch[1]
346
+ nestedKeys.add(nestedGroupName)
347
+ const nestedBody = nestedMatch[2]
348
+
349
+ TOKEN_ENTRY_RE.lastIndex = 0
350
+ let entryMatch: RegExpExecArray | null
351
+ while ((entryMatch = TOKEN_ENTRY_RE.exec(nestedBody)) !== null) {
352
+ const key = entryMatch[1]
353
+ const value = entryMatch[2] ?? entryMatch[3] ?? entryMatch[4] ?? ''
354
+ if (!value) continue
355
+
356
+ const varName = `--${camelToKebab(groupName)}-${camelToKebab(nestedGroupName)}-${camelToKebab(key)}`
357
+ results[varName] = {
358
+ value,
359
+ resolvedValue: value,
360
+ selector: `tokens:${filePath}`,
361
+ }
362
+ }
363
+ }
364
+
365
+ // Top-level entries (skip keys that were nested objects)
366
+ TOKEN_ENTRY_RE.lastIndex = 0
367
+ let entryMatch: RegExpExecArray | null
368
+ while ((entryMatch = TOKEN_ENTRY_RE.exec(body)) !== null) {
369
+ const key = entryMatch[1]
370
+ if (nestedKeys.has(key)) continue
371
+ // Skip non-value keys (functions, objects, arrays)
372
+ const value = entryMatch[2] ?? entryMatch[3] ?? entryMatch[4] ?? ''
373
+ if (!value) continue
374
+
375
+ const varName = `--${camelToKebab(groupName)}-${camelToKebab(key)}`
376
+ results[varName] = {
377
+ value,
378
+ resolvedValue: value,
379
+ selector: `tokens:${filePath}`,
380
+ }
381
+ }
382
+ }
383
+
384
+ return results
385
+ }
386
+
387
+ /** File names commonly used for design tokens in JS/TS/Dart projects */
388
+ export const TOKEN_FILE_NAMES = new Set([
389
+ 'colors.ts',
390
+ 'colors.js',
391
+ 'colors.dart',
392
+ 'theme.ts',
393
+ 'theme.js',
394
+ 'theme.dart',
395
+ 'tokens.ts',
396
+ 'tokens.js',
397
+ 'tokens.dart',
398
+ 'design-tokens.ts',
399
+ 'design-tokens.js',
400
+ 'palette.ts',
401
+ 'palette.js',
402
+ 'palette.dart',
403
+ 'app_colors.dart',
404
+ 'app_theme.dart',
405
+ 'constants.ts',
406
+ 'constants.js',
407
+ 'styles.ts',
408
+ 'styles.js',
409
+ ])
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unified diff parser for Claude CLI output.
3
+ *
4
+ * Parses standard unified diff text into structured `ParsedDiff` objects
5
+ * that the editor can render in the diff viewer.
6
+ *
7
+ * The parser is intentionally lenient: Claude output may include
8
+ * commentary, fenced code blocks, or slight formatting variations.
9
+ */
10
+
11
+ import type { ParsedDiff, DiffHunk, DiffLine } from '@/types/claude'
12
+
13
+ /**
14
+ * Strip wrapping markdown fenced code blocks (``` or ```diff) that
15
+ * Claude sometimes emits around diff output.
16
+ */
17
+ function stripCodeFences(text: string): string {
18
+ // Remove fenced blocks that wrap the entire output or individual diffs.
19
+ // We keep the inner content intact.
20
+ return text.replace(/^```(?:diff)?\s*$/gm, '')
21
+ }
22
+
23
+ /**
24
+ * Detect whether a line is the start of a new file diff header.
25
+ *
26
+ * We accept both `--- a/path` and `--- path` variants.
27
+ */
28
+ function isFileHeaderLine(line: string): boolean {
29
+ return /^---\s+(?:a\/)?/.test(line)
30
+ }
31
+
32
+ /**
33
+ * Extract the file path from a `+++ b/path` line.
34
+ *
35
+ * Falls back to the `--- a/path` line if the +++ variant is
36
+ * missing or malformed.
37
+ */
38
+ function extractFilePath(plusLine: string, minusLine: string): string {
39
+ // Try +++ first — this represents the "new" file.
40
+ const plusMatch = plusLine.match(/^\+\+\+\s+(?:b\/)?(.+)/)
41
+ if (plusMatch) {
42
+ return plusMatch[1].trim()
43
+ }
44
+
45
+ // Fall back to --- (the "old" file).
46
+ const minusMatch = minusLine.match(/^---\s+(?:a\/)?(.+)/)
47
+ if (minusMatch) {
48
+ return minusMatch[1].trim()
49
+ }
50
+
51
+ return 'unknown'
52
+ }
53
+
54
+ /**
55
+ * Parse a single hunk starting from the @@ header line.
56
+ *
57
+ * Returns the parsed hunk and the index of the first line AFTER
58
+ * this hunk (i.e. the next @@ header or file header).
59
+ */
60
+ function parseHunk(
61
+ lines: string[],
62
+ startIndex: number,
63
+ ): { hunk: DiffHunk; nextIndex: number } {
64
+ const header = lines[startIndex]
65
+ const hunkLines: DiffLine[] = []
66
+
67
+ let i = startIndex + 1
68
+ while (i < lines.length) {
69
+ const line = lines[i]
70
+
71
+ // Stop at the next hunk or file header.
72
+ if (line.startsWith('@@') || isFileHeaderLine(line)) {
73
+ break
74
+ }
75
+
76
+ // Classify the line.
77
+ if (line.startsWith('+')) {
78
+ hunkLines.push({ type: 'addition', content: line.slice(1) })
79
+ } else if (line.startsWith('-')) {
80
+ hunkLines.push({ type: 'removal', content: line.slice(1) })
81
+ } else {
82
+ // Context line — may start with a space, or may be a bare line
83
+ // if Claude omitted the leading space.
84
+ const content = line.startsWith(' ') ? line.slice(1) : line
85
+ hunkLines.push({ type: 'context', content })
86
+ }
87
+
88
+ i++
89
+ }
90
+
91
+ return {
92
+ hunk: { header, lines: hunkLines },
93
+ nextIndex: i,
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Parse a single file diff block (from `---` through all its hunks).
99
+ *
100
+ * Returns the parsed diff and the index of the first line AFTER
101
+ * this file block.
102
+ */
103
+ function parseFileDiff(
104
+ lines: string[],
105
+ startIndex: number,
106
+ ): { diff: ParsedDiff; nextIndex: number } | null {
107
+ const minusLine = lines[startIndex]
108
+
109
+ // The +++ line should follow immediately.
110
+ const plusLineIndex = startIndex + 1
111
+ if (plusLineIndex >= lines.length) {
112
+ return null
113
+ }
114
+
115
+ const plusLine = lines[plusLineIndex]
116
+ if (!plusLine.startsWith('+++')) {
117
+ // Malformed — skip this block.
118
+ return null
119
+ }
120
+
121
+ const filePath = extractFilePath(plusLine, minusLine)
122
+
123
+ const hunks: DiffHunk[] = []
124
+ let linesAdded = 0
125
+ let linesRemoved = 0
126
+
127
+ let i = plusLineIndex + 1
128
+
129
+ // Consume all hunks belonging to this file.
130
+ while (i < lines.length) {
131
+ const line = lines[i]
132
+
133
+ // A new file header means we are done with this file.
134
+ if (isFileHeaderLine(line)) {
135
+ break
136
+ }
137
+
138
+ if (line.startsWith('@@')) {
139
+ const { hunk, nextIndex } = parseHunk(lines, i)
140
+ hunks.push(hunk)
141
+
142
+ // Count additions / removals.
143
+ for (const hl of hunk.lines) {
144
+ if (hl.type === 'addition') linesAdded++
145
+ if (hl.type === 'removal') linesRemoved++
146
+ }
147
+
148
+ i = nextIndex
149
+ } else {
150
+ // Non-hunk, non-header line (e.g. blank line between file blocks
151
+ // or stray commentary). Skip it.
152
+ i++
153
+ }
154
+ }
155
+
156
+ // If we found no hunks at all this was probably not a real diff block.
157
+ if (hunks.length === 0) {
158
+ return null
159
+ }
160
+
161
+ return {
162
+ diff: { filePath, hunks, linesAdded, linesRemoved },
163
+ nextIndex: i,
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Parse unified diff output (potentially mixed with commentary) into
169
+ * an array of `ParsedDiff` objects.
170
+ *
171
+ * Handles:
172
+ * - Standard `git diff` / unified diff format.
173
+ * - Multiple files in one output block.
174
+ * - Markdown code fences wrapping the diffs.
175
+ * - Leading/trailing prose from Claude.
176
+ * - Empty or entirely non-diff input (returns `[]`).
177
+ */
178
+ export function parseDiffs(output: string): ParsedDiff[] {
179
+ if (!output || output.trim().length === 0) {
180
+ return []
181
+ }
182
+
183
+ const cleaned = stripCodeFences(output)
184
+ const lines = cleaned.split('\n')
185
+ const results: ParsedDiff[] = []
186
+
187
+ let i = 0
188
+ while (i < lines.length) {
189
+ const line = lines[i]
190
+
191
+ if (isFileHeaderLine(line)) {
192
+ const parsed = parseFileDiff(lines, i)
193
+ if (parsed) {
194
+ results.push(parsed.diff)
195
+ i = parsed.nextIndex
196
+ } else {
197
+ // Could not parse — skip past this line.
198
+ i++
199
+ }
200
+ } else {
201
+ i++
202
+ }
203
+ }
204
+
205
+ return results
206
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Client-side folder picker utility.
3
+ *
4
+ * On deployed (non-localhost): uses the File System Access API (showDirectoryPicker),
5
+ * or the bridge server's native picker if connected.
6
+ * On localhost: falls back to the server-side /api/claude/pick-folder endpoint.
7
+ */
8
+
9
+ import { getApiBase } from '@/lib/apiBase'
10
+
11
+ export type FolderPickResult =
12
+ | { type: 'handle'; handle: FileSystemDirectoryHandle; name: string }
13
+ | { type: 'path'; path: string }
14
+ | { type: 'cancelled' }
15
+ | { type: 'error'; message: string }
16
+
17
+ /** Whether the browser supports the File System Access API (Chrome/Edge). */
18
+ export function isFolderPickerSupported(): boolean {
19
+ return typeof window !== 'undefined' && 'showDirectoryPicker' in window
20
+ }
21
+
22
+ /** Whether the editor is running on localhost (can use server-side picker). */
23
+ function isLocal(): boolean {
24
+ const h = window.location.hostname
25
+ return h === 'localhost' || h === '127.0.0.1'
26
+ }
27
+
28
+ /**
29
+ * Pick a folder.
30
+ * - Deployed + Chrome/Edge: File System Access API (client-side, no server needed)
31
+ * - Deployed + unsupported browser: returns error with guidance
32
+ * - Localhost: server-side /api/claude/pick-folder (osascript / zenity)
33
+ */
34
+ export async function pickFolder(): Promise<FolderPickResult> {
35
+ // On localhost, prefer the server-side native picker (works in every browser)
36
+ if (isLocal()) {
37
+ return pickFolderServer()
38
+ }
39
+
40
+ // On deployed with bridge, use the bridge's native picker
41
+ const apiBase = getApiBase()
42
+ if (apiBase) {
43
+ return pickFolderServer()
44
+ }
45
+
46
+ // On deployed without bridge, use File System Access API
47
+ if (!isFolderPickerSupported()) {
48
+ return {
49
+ type: 'error',
50
+ message:
51
+ 'Folder picker requires Chrome or Edge, or run the bridge server locally.',
52
+ }
53
+ }
54
+
55
+ return pickFolderClient()
56
+ }
57
+
58
+ async function pickFolderClient(): Promise<FolderPickResult> {
59
+ try {
60
+ const handle = await window.showDirectoryPicker!({ mode: 'read' })
61
+ return { type: 'handle', handle, name: handle.name }
62
+ } catch (err) {
63
+ if (err instanceof DOMException && err.name === 'AbortError') {
64
+ return { type: 'cancelled' }
65
+ }
66
+ return { type: 'error', message: 'Failed to open folder picker' }
67
+ }
68
+ }
69
+
70
+ async function pickFolderServer(): Promise<FolderPickResult> {
71
+ try {
72
+ const res = await fetch(`${getApiBase()}/api/claude/pick-folder`)
73
+ const data = await res.json()
74
+ if (data.cancelled) {
75
+ return { type: 'cancelled' }
76
+ }
77
+ if (data.path) {
78
+ return { type: 'path', path: data.path }
79
+ }
80
+ return { type: 'error', message: data.error || 'Unknown error' }
81
+ } catch {
82
+ return { type: 'error', message: 'Failed to open folder picker' }
83
+ }
84
+ }