@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,248 @@
|
|
|
1
|
+
import type { CSSVariableDefinition } from '@/types/cssVariables'
|
|
2
|
+
|
|
3
|
+
export interface TailwindColorClass {
|
|
4
|
+
className: string // e.g. 'text-primary'
|
|
5
|
+
prefix: string // e.g. 'text'
|
|
6
|
+
tokenName: string // e.g. 'primary'
|
|
7
|
+
cssProperty: string // e.g. 'color'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Map of Tailwind color prefixes to their corresponding CSS properties.
|
|
12
|
+
*/
|
|
13
|
+
const PREFIX_TO_PROPERTY: Record<string, string> = {
|
|
14
|
+
text: 'color',
|
|
15
|
+
bg: 'backgroundColor',
|
|
16
|
+
border: 'borderColor',
|
|
17
|
+
outline: 'outlineColor',
|
|
18
|
+
ring: '--tw-ring-color',
|
|
19
|
+
decoration: 'textDecorationColor',
|
|
20
|
+
accent: 'accentColor',
|
|
21
|
+
fill: 'fill',
|
|
22
|
+
stroke: 'stroke',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Non-color suffixes that look like color classes but aren't.
|
|
27
|
+
* These follow a color prefix (e.g. text-center, bg-clip-text, border-collapse).
|
|
28
|
+
*/
|
|
29
|
+
const NON_COLOR_SUFFIXES = new Set([
|
|
30
|
+
// text- non-color
|
|
31
|
+
'center',
|
|
32
|
+
'left',
|
|
33
|
+
'right',
|
|
34
|
+
'justify',
|
|
35
|
+
'start',
|
|
36
|
+
'end',
|
|
37
|
+
'wrap',
|
|
38
|
+
'nowrap',
|
|
39
|
+
'balance',
|
|
40
|
+
'pretty',
|
|
41
|
+
'xs',
|
|
42
|
+
'sm',
|
|
43
|
+
'base',
|
|
44
|
+
'lg',
|
|
45
|
+
'xl',
|
|
46
|
+
'2xl',
|
|
47
|
+
'3xl',
|
|
48
|
+
'4xl',
|
|
49
|
+
'5xl',
|
|
50
|
+
'6xl',
|
|
51
|
+
'7xl',
|
|
52
|
+
'8xl',
|
|
53
|
+
'9xl',
|
|
54
|
+
'ellipsis',
|
|
55
|
+
'clip',
|
|
56
|
+
'truncate',
|
|
57
|
+
// bg- non-color
|
|
58
|
+
'repeat',
|
|
59
|
+
'no-repeat',
|
|
60
|
+
'contain',
|
|
61
|
+
'cover',
|
|
62
|
+
'auto',
|
|
63
|
+
'fixed',
|
|
64
|
+
'local',
|
|
65
|
+
'scroll',
|
|
66
|
+
'clip',
|
|
67
|
+
'origin',
|
|
68
|
+
'bottom',
|
|
69
|
+
'top',
|
|
70
|
+
'center',
|
|
71
|
+
'left',
|
|
72
|
+
'right',
|
|
73
|
+
'gradient-to-t',
|
|
74
|
+
'gradient-to-tr',
|
|
75
|
+
'gradient-to-r',
|
|
76
|
+
'gradient-to-br',
|
|
77
|
+
'gradient-to-b',
|
|
78
|
+
'gradient-to-bl',
|
|
79
|
+
'gradient-to-l',
|
|
80
|
+
'gradient-to-tl',
|
|
81
|
+
'none',
|
|
82
|
+
// border- non-color
|
|
83
|
+
'collapse',
|
|
84
|
+
'separate',
|
|
85
|
+
'solid',
|
|
86
|
+
'dashed',
|
|
87
|
+
'dotted',
|
|
88
|
+
'double',
|
|
89
|
+
'hidden',
|
|
90
|
+
'0',
|
|
91
|
+
'1',
|
|
92
|
+
'2',
|
|
93
|
+
'4',
|
|
94
|
+
'8',
|
|
95
|
+
'x',
|
|
96
|
+
'y',
|
|
97
|
+
't',
|
|
98
|
+
'r',
|
|
99
|
+
'b',
|
|
100
|
+
'l',
|
|
101
|
+
'e',
|
|
102
|
+
's',
|
|
103
|
+
'spacing',
|
|
104
|
+
// outline- non-color
|
|
105
|
+
'offset',
|
|
106
|
+
'dashed',
|
|
107
|
+
'dotted',
|
|
108
|
+
'double',
|
|
109
|
+
// fill/stroke non-color
|
|
110
|
+
'rule',
|
|
111
|
+
// ring- non-color
|
|
112
|
+
'offset',
|
|
113
|
+
'inset',
|
|
114
|
+
// General
|
|
115
|
+
'inherit',
|
|
116
|
+
'current',
|
|
117
|
+
'transparent',
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Multi-word non-color suffixes where the first token alone
|
|
122
|
+
* might look like a color but the combination is not.
|
|
123
|
+
*/
|
|
124
|
+
const NON_COLOR_COMPOUND = new Set([
|
|
125
|
+
'clip-text',
|
|
126
|
+
'clip-border',
|
|
127
|
+
'clip-padding',
|
|
128
|
+
'clip-content',
|
|
129
|
+
'origin-border',
|
|
130
|
+
'origin-padding',
|
|
131
|
+
'origin-content',
|
|
132
|
+
'repeat-x',
|
|
133
|
+
'repeat-y',
|
|
134
|
+
'repeat-round',
|
|
135
|
+
'repeat-space',
|
|
136
|
+
'decoration-slice',
|
|
137
|
+
'decoration-clone',
|
|
138
|
+
'offset-0',
|
|
139
|
+
'offset-1',
|
|
140
|
+
'offset-2',
|
|
141
|
+
'offset-4',
|
|
142
|
+
'offset-8',
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse a className string and extract Tailwind color class mappings.
|
|
147
|
+
* Returns one mapping per CSS property (last class wins if duplicates).
|
|
148
|
+
*/
|
|
149
|
+
export function parseTailwindColorClasses(
|
|
150
|
+
className: string | null,
|
|
151
|
+
): TailwindColorClass[] {
|
|
152
|
+
if (!className) return []
|
|
153
|
+
|
|
154
|
+
const classes = className.trim().split(/\s+/).filter(Boolean)
|
|
155
|
+
const results: TailwindColorClass[] = []
|
|
156
|
+
|
|
157
|
+
for (const cls of classes) {
|
|
158
|
+
// Strip responsive/state prefixes: md:text-primary → text-primary
|
|
159
|
+
// Also handles chained: hover:md:text-primary → text-primary
|
|
160
|
+
const stripped = cls.replace(/^(?:[a-z0-9-]+:)+/, '')
|
|
161
|
+
|
|
162
|
+
// Skip arbitrary values: text-[#ff0000], bg-[rgb(0,0,0)]
|
|
163
|
+
if (stripped.includes('[')) continue
|
|
164
|
+
|
|
165
|
+
// Try to match each known prefix
|
|
166
|
+
for (const [prefix, cssProperty] of Object.entries(PREFIX_TO_PROPERTY)) {
|
|
167
|
+
const prefixWithDash = prefix + '-'
|
|
168
|
+
if (!stripped.startsWith(prefixWithDash)) continue
|
|
169
|
+
|
|
170
|
+
const suffix = stripped.slice(prefixWithDash.length)
|
|
171
|
+
if (!suffix) continue
|
|
172
|
+
|
|
173
|
+
// Check against non-color suffixes
|
|
174
|
+
if (NON_COLOR_SUFFIXES.has(suffix)) continue
|
|
175
|
+
if (NON_COLOR_COMPOUND.has(suffix)) continue
|
|
176
|
+
|
|
177
|
+
// Skip if suffix starts with a non-color compound prefix
|
|
178
|
+
const firstPart = suffix.split('-')[0]
|
|
179
|
+
if (NON_COLOR_SUFFIXES.has(firstPart) && suffix.includes('-')) {
|
|
180
|
+
// e.g. bg-clip-text → firstPart is 'clip' which is non-color
|
|
181
|
+
if (NON_COLOR_COMPOUND.has(suffix)) continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Skip pure numeric suffixes for non-border prefixes
|
|
185
|
+
// (border-2 is sizing, but text-500 could be a shade if no base)
|
|
186
|
+
if (/^\d+$/.test(suffix) && prefix !== 'border') continue
|
|
187
|
+
|
|
188
|
+
results.push({
|
|
189
|
+
className: stripped,
|
|
190
|
+
prefix,
|
|
191
|
+
tokenName: suffix,
|
|
192
|
+
cssProperty,
|
|
193
|
+
})
|
|
194
|
+
break // matched a prefix, stop checking others
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return results
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a Tailwind token name to a CSS variable from available definitions.
|
|
203
|
+
*
|
|
204
|
+
* Search order:
|
|
205
|
+
* 1. --{tokenName} (e.g. --primary)
|
|
206
|
+
* 2. --color-{tokenName} (Tailwind v4 convention)
|
|
207
|
+
* 3. --colors-{tokenName} (common naming)
|
|
208
|
+
*
|
|
209
|
+
* Returns the matching variable name or null.
|
|
210
|
+
*/
|
|
211
|
+
export function resolveTokenToVariable(
|
|
212
|
+
tokenName: string,
|
|
213
|
+
definitions: Record<string, CSSVariableDefinition>,
|
|
214
|
+
): string | null {
|
|
215
|
+
const candidates = [
|
|
216
|
+
`--${tokenName}`,
|
|
217
|
+
`--color-${tokenName}`,
|
|
218
|
+
`--colors-${tokenName}`,
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
for (const candidate of candidates) {
|
|
222
|
+
if (candidate in definitions) return candidate
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build a map of CSS property → TailwindColorClass with resolved variable,
|
|
230
|
+
* from a className string and available CSS variable definitions.
|
|
231
|
+
*/
|
|
232
|
+
export function buildTailwindClassMap(
|
|
233
|
+
className: string | null,
|
|
234
|
+
definitions: Record<string, CSSVariableDefinition>,
|
|
235
|
+
): Record<string, TailwindColorClass & { variableName: string | null }> {
|
|
236
|
+
const parsed = parseTailwindColorClasses(className)
|
|
237
|
+
const map: Record<
|
|
238
|
+
string,
|
|
239
|
+
TailwindColorClass & { variableName: string | null }
|
|
240
|
+
> = {}
|
|
241
|
+
|
|
242
|
+
for (const entry of parsed) {
|
|
243
|
+
const variableName = resolveTokenToVariable(entry.tokenName, definitions)
|
|
244
|
+
map[entry.cssProperty] = { ...entry, variableName }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return map
|
|
248
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface TextShadowData {
|
|
2
|
+
x: number
|
|
3
|
+
y: number
|
|
4
|
+
blur: number
|
|
5
|
+
color: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split a text-shadow CSS string by commas, respecting nested parentheses.
|
|
10
|
+
*/
|
|
11
|
+
function splitShadows(css: string): string[] {
|
|
12
|
+
const parts: string[] = []
|
|
13
|
+
let depth = 0
|
|
14
|
+
let current = ''
|
|
15
|
+
for (let i = 0; i < css.length; i++) {
|
|
16
|
+
const ch = css[i]
|
|
17
|
+
if (ch === '(') depth++
|
|
18
|
+
else if (ch === ')') depth--
|
|
19
|
+
if (ch === ',' && depth === 0) {
|
|
20
|
+
parts.push(current.trim())
|
|
21
|
+
current = ''
|
|
22
|
+
} else {
|
|
23
|
+
current += ch
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (current.trim()) parts.push(current.trim())
|
|
27
|
+
return parts
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse a single text-shadow string like "2px 4px 6px rgba(0,0,0,0.3)"
|
|
32
|
+
*/
|
|
33
|
+
function parseSingleTextShadow(str: string): TextShadowData | null {
|
|
34
|
+
const trimmed = str.trim()
|
|
35
|
+
if (!trimmed || trimmed === 'none') return null
|
|
36
|
+
|
|
37
|
+
let working = trimmed
|
|
38
|
+
let color = 'rgba(0,0,0,0.25)'
|
|
39
|
+
let numericPart = working
|
|
40
|
+
|
|
41
|
+
// Try color at end: rgb(...), rgba(...), hsl(...), hsla(...)
|
|
42
|
+
const colorFuncEnd = working.match(/((?:rgba?|hsla?)\([^)]+\))$/)
|
|
43
|
+
if (colorFuncEnd) {
|
|
44
|
+
color = colorFuncEnd[1]
|
|
45
|
+
numericPart = working
|
|
46
|
+
.slice(0, working.length - colorFuncEnd[0].length)
|
|
47
|
+
.trim()
|
|
48
|
+
} else {
|
|
49
|
+
const hexEnd = working.match(/(#[0-9a-fA-F]{3,8})$/)
|
|
50
|
+
if (hexEnd) {
|
|
51
|
+
color = hexEnd[1]
|
|
52
|
+
numericPart = working.slice(0, working.length - hexEnd[0].length).trim()
|
|
53
|
+
} else {
|
|
54
|
+
// Try color at start
|
|
55
|
+
const colorFuncStart = working.match(/^((?:rgba?|hsla?)\([^)]+\))\s+/)
|
|
56
|
+
if (colorFuncStart) {
|
|
57
|
+
color = colorFuncStart[1]
|
|
58
|
+
numericPart = working.slice(colorFuncStart[0].length).trim()
|
|
59
|
+
} else {
|
|
60
|
+
// Try named color at the end (last token)
|
|
61
|
+
const tokens = working.split(/\s+/)
|
|
62
|
+
if (tokens.length >= 3) {
|
|
63
|
+
const lastToken = tokens[tokens.length - 1]
|
|
64
|
+
if (!/^-?\d/.test(lastToken) && !lastToken.includes('px')) {
|
|
65
|
+
color = lastToken
|
|
66
|
+
numericPart = tokens.slice(0, -1).join(' ')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Parse numeric values: x y [blur]
|
|
74
|
+
const numTokens = numericPart.split(/\s+/).map((t) => parseFloat(t))
|
|
75
|
+
const x = numTokens[0] || 0
|
|
76
|
+
const y = numTokens[1] || 0
|
|
77
|
+
const blur = numTokens[2] || 0
|
|
78
|
+
|
|
79
|
+
return { x, y, blur, color }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a text-shadow CSS string into an array of TextShadowData objects.
|
|
84
|
+
* Returns [] for "none" or unparseable.
|
|
85
|
+
*/
|
|
86
|
+
export function parseTextShadow(value: string): TextShadowData[] {
|
|
87
|
+
if (!value || value === 'none') return []
|
|
88
|
+
const parts = splitShadows(value)
|
|
89
|
+
const shadows: TextShadowData[] = []
|
|
90
|
+
for (const part of parts) {
|
|
91
|
+
const shadow = parseSingleTextShadow(part)
|
|
92
|
+
if (shadow) shadows.push(shadow)
|
|
93
|
+
}
|
|
94
|
+
return shadows
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Serialize TextShadowData array back to a valid CSS text-shadow string.
|
|
99
|
+
* Returns "none" for empty array.
|
|
100
|
+
*/
|
|
101
|
+
export function serializeTextShadow(shadows: TextShadowData[]): string {
|
|
102
|
+
if (shadows.length === 0) return 'none'
|
|
103
|
+
return shadows
|
|
104
|
+
.map((s) => `${s.x}px ${s.y}px ${s.blur}px ${s.color}`)
|
|
105
|
+
.join(', ')
|
|
106
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getBreakpointDeviceInfo,
|
|
3
|
+
buildInstructionsFooter,
|
|
4
|
+
} from '@/lib/constants'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a camelCase CSS property name to kebab-case.
|
|
8
|
+
* Handles vendor prefixes: webkitTextStroke → -webkit-text-stroke
|
|
9
|
+
*/
|
|
10
|
+
export function camelToKebab(s: string): string {
|
|
11
|
+
const k = s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
|
|
12
|
+
if (/^(webkit|moz|ms)-/.test(k)) return '-' + k
|
|
13
|
+
return k
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generate a CSS selector path for a DOM element.
|
|
18
|
+
*/
|
|
19
|
+
export function generateSelectorPath(element: Element): string {
|
|
20
|
+
const parts: string[] = []
|
|
21
|
+
let current: Element | null = element
|
|
22
|
+
|
|
23
|
+
while (current && current !== document.documentElement) {
|
|
24
|
+
let selector = current.tagName.toLowerCase()
|
|
25
|
+
|
|
26
|
+
if (current.id) {
|
|
27
|
+
selector += `#${current.id}`
|
|
28
|
+
parts.unshift(selector)
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (current.className && typeof current.className === 'string') {
|
|
33
|
+
const classes = current.className.trim().split(/\s+/).filter(Boolean)
|
|
34
|
+
if (classes.length > 0) {
|
|
35
|
+
selector += '.' + classes.join('.')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parent = current.parentElement
|
|
40
|
+
if (parent) {
|
|
41
|
+
const siblings = Array.from(parent.children).filter(
|
|
42
|
+
(child) => child.tagName === current!.tagName,
|
|
43
|
+
)
|
|
44
|
+
if (siblings.length > 1) {
|
|
45
|
+
const index = siblings.indexOf(current) + 1
|
|
46
|
+
selector += `:nth-of-type(${index})`
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
parts.unshift(selector)
|
|
51
|
+
current = current.parentElement
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parts.join(' > ')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find an element by its CSS selector path.
|
|
59
|
+
*/
|
|
60
|
+
export function findElementBySelector(selectorPath: string): Element | null {
|
|
61
|
+
try {
|
|
62
|
+
return document.querySelector(selectorPath)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a CSS value into number and unit parts.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCSSValue(value: string): { number: number; unit: string } {
|
|
72
|
+
const match = value.match(/^(-?\d*\.?\d+)(px|%|em|rem|vh|vw|pt|ch|ex)?$/)
|
|
73
|
+
if (match) {
|
|
74
|
+
return { number: parseFloat(match[1]), unit: match[2] || 'px' }
|
|
75
|
+
}
|
|
76
|
+
return { number: 0, unit: 'px' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format a CSS value from number and unit parts.
|
|
81
|
+
*/
|
|
82
|
+
export function formatCSSValue(num: number, unit: string): string {
|
|
83
|
+
if (unit === 'auto') return 'auto'
|
|
84
|
+
return `${num}${unit}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate that a URL is a localhost address.
|
|
89
|
+
*/
|
|
90
|
+
export function isLocalhostUrl(url: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(url)
|
|
93
|
+
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize a target URL (ensure trailing slash, etc.)
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeTargetUrl(url: string): string {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = new URL(url)
|
|
105
|
+
return parsed.origin
|
|
106
|
+
} catch {
|
|
107
|
+
return url
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a unique ID.
|
|
113
|
+
*/
|
|
114
|
+
export function generateId(): string {
|
|
115
|
+
return crypto.randomUUID()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Clamp a number between min and max.
|
|
120
|
+
*/
|
|
121
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
122
|
+
return Math.min(Math.max(value, min), max)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Strip control characters from a string (keep newlines and tabs).
|
|
127
|
+
*/
|
|
128
|
+
export function stripControlChars(str: string): string {
|
|
129
|
+
return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generate a structured changelog text from tracked changes.
|
|
134
|
+
*/
|
|
135
|
+
export function formatChangelog(opts: {
|
|
136
|
+
targetUrl: string
|
|
137
|
+
pagePath: string
|
|
138
|
+
breakpoint: import('@/types/changelog').Breakpoint
|
|
139
|
+
breakpointWidth: number
|
|
140
|
+
styleChanges: import('@/types/changelog').StyleChange[]
|
|
141
|
+
framework?: string | null
|
|
142
|
+
cssStrategy?: string[] | null
|
|
143
|
+
}): string {
|
|
144
|
+
const { targetUrl, pagePath, breakpoint, styleChanges } = opts
|
|
145
|
+
|
|
146
|
+
const { deviceName, range } = getBreakpointDeviceInfo(breakpoint)
|
|
147
|
+
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
const timestamp = new Date().toISOString()
|
|
150
|
+
|
|
151
|
+
lines.push('=== DEV EDITOR CHANGELOG ===')
|
|
152
|
+
lines.push(`Project URL: ${targetUrl}`)
|
|
153
|
+
lines.push(`Page: ${pagePath || '/'}`)
|
|
154
|
+
lines.push(`Device Name: ${deviceName}`)
|
|
155
|
+
lines.push(`Breakpoint: ${range}`)
|
|
156
|
+
lines.push(`Generated: ${timestamp}`)
|
|
157
|
+
lines.push('')
|
|
158
|
+
|
|
159
|
+
// Separate special entries from regular style changes
|
|
160
|
+
const componentExtractions = styleChanges.filter(
|
|
161
|
+
(c) => c.property === '__component_creation__',
|
|
162
|
+
)
|
|
163
|
+
const variableDefinitions = styleChanges.filter(
|
|
164
|
+
(c) => c.elementSelector === ':root' && c.property.startsWith('--'),
|
|
165
|
+
)
|
|
166
|
+
const regularChanges = styleChanges.filter(
|
|
167
|
+
(c) =>
|
|
168
|
+
c.property !== '__component_creation__' &&
|
|
169
|
+
!(c.elementSelector === ':root' && c.property.startsWith('--')),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
// Component Extractions section
|
|
173
|
+
if (componentExtractions.length > 0) {
|
|
174
|
+
lines.push('## Component Extractions')
|
|
175
|
+
lines.push('')
|
|
176
|
+
|
|
177
|
+
for (const extraction of componentExtractions) {
|
|
178
|
+
try {
|
|
179
|
+
const data = JSON.parse(extraction.newValue) as {
|
|
180
|
+
name: string
|
|
181
|
+
variants: Array<{ groupName: string; options: string[] }>
|
|
182
|
+
}
|
|
183
|
+
const kebabName = data.name
|
|
184
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
185
|
+
.replace(/\s+/g, '-')
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
lines.push(`### ${data.name} Component`)
|
|
188
|
+
lines.push(`- Selector: \`${extraction.elementSelector}\``)
|
|
189
|
+
lines.push(`- Suggested file: \`src/components/${kebabName}.tsx\``)
|
|
190
|
+
if (data.variants.length > 0) {
|
|
191
|
+
lines.push('- Suggested props:')
|
|
192
|
+
for (const v of data.variants) {
|
|
193
|
+
lines.push(
|
|
194
|
+
` - ${v.groupName.toLowerCase()}: ${v.options.join(' | ')}`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push('')
|
|
199
|
+
} catch {
|
|
200
|
+
// Skip malformed extraction entries
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// CSS Variable Definitions section
|
|
206
|
+
if (variableDefinitions.length > 0) {
|
|
207
|
+
lines.push('## CSS Variable Definitions')
|
|
208
|
+
lines.push('')
|
|
209
|
+
lines.push(
|
|
210
|
+
"Add these CSS custom properties to the project's root stylesheet (`:root` or `html` selector):",
|
|
211
|
+
)
|
|
212
|
+
lines.push('')
|
|
213
|
+
for (const v of variableDefinitions) {
|
|
214
|
+
// Find which element property references this variable
|
|
215
|
+
const varRef = `var(${v.property})`
|
|
216
|
+
const referencing = regularChanges.filter((c) => c.newValue === varRef)
|
|
217
|
+
lines.push(`- \`${v.property}: ${v.newValue}\``)
|
|
218
|
+
for (const ref of referencing) {
|
|
219
|
+
lines.push(
|
|
220
|
+
` - Used by: \`${ref.elementSelector}\` → ${ref.property}: var(${v.property})`,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
lines.push('')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Group regular style changes by element selector
|
|
228
|
+
if (regularChanges.length > 0) {
|
|
229
|
+
lines.push('## Style Changes')
|
|
230
|
+
lines.push('')
|
|
231
|
+
|
|
232
|
+
const grouped = new Map<string, typeof regularChanges>()
|
|
233
|
+
for (const change of regularChanges) {
|
|
234
|
+
const existing = grouped.get(change.elementSelector) || []
|
|
235
|
+
existing.push(change)
|
|
236
|
+
grouped.set(change.elementSelector, existing)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const [selector, changes] of grouped) {
|
|
240
|
+
lines.push(`### ${selector}`)
|
|
241
|
+
for (const c of changes) {
|
|
242
|
+
lines.push(
|
|
243
|
+
`- ${c.property}: "${c.originalValue}" → "${c.newValue}" [${c.breakpoint}]`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
lines.push('')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Summary + instructions footer (framework-aware)
|
|
251
|
+
const totalChanges = styleChanges.length
|
|
252
|
+
const uniqueElements = new Set(styleChanges.map((c) => c.elementSelector))
|
|
253
|
+
.size
|
|
254
|
+
|
|
255
|
+
// Temporarily override summary line in the footer with component extraction count
|
|
256
|
+
const summaryPrefix = `${totalChanges} change${totalChanges !== 1 ? 's' : ''} across ${uniqueElements} element${uniqueElements !== 1 ? 's' : ''}${componentExtractions.length > 0 ? ` (${componentExtractions.length} component extraction${componentExtractions.length !== 1 ? 's' : ''})` : ''}`
|
|
257
|
+
lines.push('---')
|
|
258
|
+
lines.push(`Summary: ${summaryPrefix}`)
|
|
259
|
+
lines.push('')
|
|
260
|
+
|
|
261
|
+
// Get the framework-aware instructions (skip the first 3 lines which are --- / Summary / blank)
|
|
262
|
+
const footer = buildInstructionsFooter(totalChanges, uniqueElements, {
|
|
263
|
+
framework: opts.framework,
|
|
264
|
+
cssStrategy: opts.cssStrategy,
|
|
265
|
+
})
|
|
266
|
+
const footerLines = footer.split('\n')
|
|
267
|
+
// Skip "---", "Summary: ...", and blank line from footer — we already wrote our own summary
|
|
268
|
+
const instructionsStart = footerLines.findIndex((l) =>
|
|
269
|
+
l.startsWith('## Instructions'),
|
|
270
|
+
)
|
|
271
|
+
if (instructionsStart >= 0) {
|
|
272
|
+
lines.push(footerLines.slice(instructionsStart).join('\n'))
|
|
273
|
+
} else {
|
|
274
|
+
// Fallback: append entire footer
|
|
275
|
+
lines.push(footer)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (variableDefinitions.length > 0) {
|
|
279
|
+
// Insert CSS variable guidance before the closing marker
|
|
280
|
+
const closingIdx = lines.lastIndexOf('=== END CHANGELOG ===')
|
|
281
|
+
const varLines = [
|
|
282
|
+
'',
|
|
283
|
+
'### CSS Variable Guidance',
|
|
284
|
+
'When the changelog includes CSS Variable Definitions, create the custom',
|
|
285
|
+
"properties in the project's root stylesheet or theme file. Then update",
|
|
286
|
+
'the referencing elements to use var(--name) instead of hardcoded values.',
|
|
287
|
+
'If using Tailwind, consider adding the variables to the theme config.',
|
|
288
|
+
]
|
|
289
|
+
if (closingIdx >= 0) {
|
|
290
|
+
lines.splice(closingIdx, 0, ...varLines)
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(...varLines)
|
|
293
|
+
lines.push('=== END CHANGELOG ===')
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const result = lines.join('\n')
|
|
298
|
+
return stripControlChars(result).slice(0, 50 * 1024)
|
|
299
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate that projectRoot is an absolute path, exists as a directory,
|
|
7
|
+
* and resides under the user's HOME directory.
|
|
8
|
+
*
|
|
9
|
+
* Returns an error message string if invalid, or null if valid.
|
|
10
|
+
*/
|
|
11
|
+
export function validateProjectRoot(projectRoot: string): string | null {
|
|
12
|
+
if (!path.isAbsolute(projectRoot)) {
|
|
13
|
+
return 'projectRoot must be an absolute path'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const resolvedHome = path.resolve(homedir())
|
|
17
|
+
const resolved = path.resolve(projectRoot)
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
!resolved.startsWith(resolvedHome + path.sep) &&
|
|
21
|
+
resolved !== resolvedHome
|
|
22
|
+
) {
|
|
23
|
+
return 'projectRoot must be under the user home directory'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!existsSync(resolved)) {
|
|
27
|
+
return 'projectRoot does not exist'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const stat = statSync(resolved)
|
|
32
|
+
if (!stat.isDirectory()) {
|
|
33
|
+
return 'projectRoot is not a directory'
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return 'Unable to stat projectRoot'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null
|
|
40
|
+
}
|