@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,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
+ }
@@ -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
+ }