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