@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,160 @@
1
+ import type { GradientData, GradientStop } from '@/types/gradient'
2
+
3
+ /**
4
+ * Split a CSS function argument string by commas, respecting nested parentheses.
5
+ */
6
+ function splitArgs(str: string): string[] {
7
+ const parts: string[] = []
8
+ let depth = 0
9
+ let current = ''
10
+ for (let i = 0; i < str.length; i++) {
11
+ const ch = str[i]
12
+ if (ch === '(') depth++
13
+ else if (ch === ')') depth--
14
+ if (ch === ',' && depth === 0) {
15
+ parts.push(current.trim())
16
+ current = ''
17
+ } else {
18
+ current += ch
19
+ }
20
+ }
21
+ if (current.trim()) parts.push(current.trim())
22
+ return parts
23
+ }
24
+
25
+ /**
26
+ * Parse a single gradient stop string like "red 50%" or "rgba(0,0,0,0.5) 25%"
27
+ */
28
+ function parseStop(stopStr: string): GradientStop | null {
29
+ const trimmed = stopStr.trim()
30
+ // Try to match a position percentage at the end
31
+ const posMatch = trimmed.match(/\s+(\d+(?:\.\d+)?%?)$/)
32
+ let color: string
33
+ let position = 0
34
+
35
+ if (posMatch) {
36
+ color = trimmed.slice(0, trimmed.length - posMatch[0].length).trim()
37
+ const posVal = posMatch[1]
38
+ position = parseFloat(posVal)
39
+ } else {
40
+ color = trimmed
41
+ }
42
+
43
+ if (!color) return null
44
+
45
+ return { color, position, opacity: 1 }
46
+ }
47
+
48
+ /**
49
+ * Parse a CSS gradient string into a GradientData object.
50
+ * Supports both regular and repeating variants.
51
+ */
52
+ export function parseGradient(css: string): GradientData | null {
53
+ if (!css) return null
54
+ const trimmed = css.trim()
55
+
56
+ let type: 'linear' | 'radial' | 'conic'
57
+ let inner: string
58
+ let repeat = false
59
+
60
+ if (trimmed.startsWith('repeating-linear-gradient(')) {
61
+ type = 'linear'
62
+ repeat = true
63
+ inner = trimmed.slice('repeating-linear-gradient('.length, -1)
64
+ } else if (trimmed.startsWith('repeating-radial-gradient(')) {
65
+ type = 'radial'
66
+ repeat = true
67
+ inner = trimmed.slice('repeating-radial-gradient('.length, -1)
68
+ } else if (trimmed.startsWith('repeating-conic-gradient(')) {
69
+ type = 'conic'
70
+ repeat = true
71
+ inner = trimmed.slice('repeating-conic-gradient('.length, -1)
72
+ } else if (trimmed.startsWith('linear-gradient(')) {
73
+ type = 'linear'
74
+ inner = trimmed.slice('linear-gradient('.length, -1)
75
+ } else if (trimmed.startsWith('radial-gradient(')) {
76
+ type = 'radial'
77
+ inner = trimmed.slice('radial-gradient('.length, -1)
78
+ } else if (trimmed.startsWith('conic-gradient(')) {
79
+ type = 'conic'
80
+ inner = trimmed.slice('conic-gradient('.length, -1)
81
+ } else {
82
+ return null
83
+ }
84
+
85
+ const args = splitArgs(inner)
86
+ if (args.length < 2) return null
87
+
88
+ let angle = 180
89
+ let stopStartIndex = 0
90
+
91
+ // Check if first arg is an angle
92
+ const firstArg = args[0].trim()
93
+ const angleMatch = firstArg.match(/^(\d+(?:\.\d+)?)deg$/)
94
+ if (angleMatch) {
95
+ angle = parseFloat(angleMatch[1])
96
+ stopStartIndex = 1
97
+ } else if (firstArg.startsWith('from ')) {
98
+ const fromMatch = firstArg.match(/^from\s+(\d+(?:\.\d+)?)deg$/)
99
+ if (fromMatch) {
100
+ angle = parseFloat(fromMatch[1])
101
+ stopStartIndex = 1
102
+ }
103
+ } else if (firstArg === 'to top') {
104
+ angle = 0
105
+ stopStartIndex = 1
106
+ } else if (firstArg === 'to right') {
107
+ angle = 90
108
+ stopStartIndex = 1
109
+ } else if (firstArg === 'to bottom') {
110
+ angle = 180
111
+ stopStartIndex = 1
112
+ } else if (firstArg === 'to left') {
113
+ angle = 270
114
+ stopStartIndex = 1
115
+ } else if (firstArg.startsWith('to ')) {
116
+ stopStartIndex = 1
117
+ }
118
+
119
+ const stops: GradientStop[] = []
120
+ const stopArgs = args.slice(stopStartIndex)
121
+
122
+ for (let i = 0; i < stopArgs.length; i++) {
123
+ const stop = parseStop(stopArgs[i])
124
+ if (stop) {
125
+ // Auto-distribute positions if not specified
126
+ if (stop.position === 0 && i > 0) {
127
+ stop.position = Math.round((i / (stopArgs.length - 1)) * 100)
128
+ }
129
+ if (i === 0 && stop.position === 0) {
130
+ stop.position = 0
131
+ }
132
+ if (i === stopArgs.length - 1 && stop.position === 0) {
133
+ stop.position = 100
134
+ }
135
+ stops.push(stop)
136
+ }
137
+ }
138
+
139
+ if (stops.length < 2) return null
140
+
141
+ return { type, angle, stops, repeat }
142
+ }
143
+
144
+ /**
145
+ * Serialize a GradientData object back to a valid CSS gradient string.
146
+ * Supports repeating variants when data.repeat is true.
147
+ */
148
+ export function serializeGradient(data: GradientData): string {
149
+ const stopStrs = data.stops.map((s) => `${s.color} ${s.position}%`)
150
+ const prefix = data.repeat ? 'repeating-' : ''
151
+
152
+ switch (data.type) {
153
+ case 'linear':
154
+ return `${prefix}linear-gradient(${data.angle}deg, ${stopStrs.join(', ')})`
155
+ case 'radial':
156
+ return `${prefix}radial-gradient(${stopStrs.join(', ')})`
157
+ case 'conic':
158
+ return `${prefix}conic-gradient(from ${data.angle}deg, ${stopStrs.join(', ')})`
159
+ }
160
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Shared project scanner logic.
3
+ * Used by both the Next.js /api/project-scan route and the bridge server.
4
+ */
5
+
6
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
7
+ import path from 'node:path'
8
+ import type { RouteEntry } from '@/types/claude'
9
+
10
+ export interface ProjectScanData {
11
+ projectName: string
12
+ componentCount: number
13
+ componentFileMap: Record<string, string>
14
+ framework: string | null
15
+ cssStrategy: string[]
16
+ cssFiles: string[]
17
+ srcDirs: string[]
18
+ assetDirs: string[]
19
+ routes: RouteEntry[]
20
+ }
21
+
22
+ const SKIP_DIRS = new Set([
23
+ 'node_modules',
24
+ '.next',
25
+ 'dist',
26
+ 'build',
27
+ '.git',
28
+ '__tests__',
29
+ '__mocks__',
30
+ '.turbo',
31
+ '.vercel',
32
+ 'coverage',
33
+ ])
34
+
35
+ const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx'])
36
+ const MAYBE_COMPONENT_EXTENSIONS = new Set(['.ts', '.js'])
37
+ const CSS_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less'])
38
+ const MAX_FILES = 5000
39
+
40
+ const CONVENTION_FILES = new Set([
41
+ 'page',
42
+ 'layout',
43
+ 'loading',
44
+ 'error',
45
+ 'not-found',
46
+ 'template',
47
+ 'default',
48
+ 'route',
49
+ 'proxy',
50
+ 'middleware',
51
+ 'global-error',
52
+ 'instrumentation',
53
+ ])
54
+
55
+ const ROUTE_CONVENTION_MAP: Record<string, RouteEntry['type']> = {
56
+ page: 'page',
57
+ layout: 'layout',
58
+ loading: 'loading',
59
+ error: 'error',
60
+ 'not-found': 'not-found',
61
+ template: 'template',
62
+ }
63
+
64
+ const PREFERRED_SEGMENTS = new Set(['components', 'ui', 'common', 'shared'])
65
+
66
+ const ASSET_DIR_NAMES = new Set([
67
+ 'images',
68
+ 'img',
69
+ 'fonts',
70
+ 'icons',
71
+ 'media',
72
+ 'assets',
73
+ 'static',
74
+ 'videos',
75
+ 'svgs',
76
+ 'illustrations',
77
+ ])
78
+
79
+ function isPascalCase(name: string): boolean {
80
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name)
81
+ }
82
+
83
+ function hasPreferredSegment(relativePath: string): boolean {
84
+ const segments = relativePath.split(path.sep)
85
+ return segments.some((s) => PREFERRED_SEGMENTS.has(s))
86
+ }
87
+
88
+ function filePathToUrlPattern(relativePath: string): string {
89
+ const parts = relativePath.split(path.sep)
90
+ const routeParts = parts.slice(1, -1)
91
+ const filtered = routeParts.filter((p) => !p.startsWith('('))
92
+ return '/' + filtered.join('/')
93
+ }
94
+
95
+ interface WalkCollectors {
96
+ componentFileMap: Record<string, string>
97
+ cssFiles: string[]
98
+ routes: RouteEntry[]
99
+ assetDirs: Set<string>
100
+ hasCssModules: boolean
101
+ }
102
+
103
+ function walkDir(
104
+ dir: string,
105
+ projectRoot: string,
106
+ collectors: WalkCollectors,
107
+ counter: { count: number },
108
+ ): void {
109
+ if (counter.count >= MAX_FILES) return
110
+
111
+ let entries: import('node:fs').Dirent[]
112
+ try {
113
+ entries = readdirSync(dir, { withFileTypes: true })
114
+ } catch {
115
+ return
116
+ }
117
+
118
+ for (const entry of entries) {
119
+ if (counter.count >= MAX_FILES) return
120
+ if (SKIP_DIRS.has(entry.name)) continue
121
+
122
+ const fullPath = path.join(dir, entry.name)
123
+
124
+ if (entry.isDirectory()) {
125
+ if (ASSET_DIR_NAMES.has(entry.name.toLowerCase())) {
126
+ const relDir = path.relative(projectRoot, fullPath)
127
+ collectors.assetDirs.add(relDir)
128
+ }
129
+ walkDir(fullPath, projectRoot, collectors, counter)
130
+ continue
131
+ }
132
+
133
+ if (!entry.isFile()) continue
134
+ counter.count++
135
+
136
+ const ext = path.extname(entry.name)
137
+ const baseName = path.basename(entry.name, ext)
138
+ const relativePath = path.relative(projectRoot, fullPath)
139
+
140
+ if (baseName.startsWith('.')) continue
141
+
142
+ if (CSS_EXTENSIONS.has(ext)) {
143
+ collectors.cssFiles.push(relativePath)
144
+ if (entry.name.includes('.module.')) {
145
+ collectors.hasCssModules = true
146
+ }
147
+ continue
148
+ }
149
+
150
+ if (baseName.endsWith('.test') || baseName.endsWith('.spec')) continue
151
+
152
+ const routeType = ROUTE_CONVENTION_MAP[baseName]
153
+ if (
154
+ routeType &&
155
+ (COMPONENT_EXTENSIONS.has(ext) || MAYBE_COMPONENT_EXTENSIONS.has(ext))
156
+ ) {
157
+ if (
158
+ relativePath.startsWith('app' + path.sep) ||
159
+ relativePath.startsWith('src' + path.sep + 'app' + path.sep)
160
+ ) {
161
+ collectors.routes.push({
162
+ urlPattern: filePathToUrlPattern(
163
+ relativePath.startsWith('src' + path.sep)
164
+ ? relativePath.slice(4)
165
+ : relativePath,
166
+ ),
167
+ filePath: relativePath,
168
+ type: routeType,
169
+ })
170
+ }
171
+ continue
172
+ }
173
+
174
+ if (baseName === 'index') continue
175
+ if (CONVENTION_FILES.has(baseName)) continue
176
+
177
+ const isComponentExt = COMPONENT_EXTENSIONS.has(ext)
178
+ const isMaybeComponentExt = MAYBE_COMPONENT_EXTENSIONS.has(ext)
179
+
180
+ if (!isComponentExt && !isMaybeComponentExt) continue
181
+ if (isMaybeComponentExt && !isPascalCase(baseName)) continue
182
+
183
+ if (collectors.componentFileMap[baseName]) {
184
+ const existingPreferred = hasPreferredSegment(
185
+ collectors.componentFileMap[baseName],
186
+ )
187
+ const newPreferred = hasPreferredSegment(relativePath)
188
+ if (newPreferred && !existingPreferred) {
189
+ collectors.componentFileMap[baseName] = relativePath
190
+ }
191
+ } else {
192
+ collectors.componentFileMap[baseName] = relativePath
193
+ }
194
+ }
195
+ }
196
+
197
+ export function detectFramework(
198
+ deps: Record<string, string>,
199
+ devDeps: Record<string, string>,
200
+ ): string | null {
201
+ const all = { ...deps, ...devDeps }
202
+ if (all['next']) return 'Next.js'
203
+ if (all['@remix-run/react'] || all['remix']) return 'Remix'
204
+ if (all['gatsby']) return 'Gatsby'
205
+ if (all['astro']) return 'Astro'
206
+ if (all['@angular/core']) return 'Angular'
207
+ if (all['vue']) return 'Vue'
208
+ if (all['svelte']) return 'Svelte'
209
+ if (all['react']) return 'React'
210
+ return null
211
+ }
212
+
213
+ export function detectCssStrategy(
214
+ deps: Record<string, string>,
215
+ devDeps: Record<string, string>,
216
+ hasCssModules: boolean,
217
+ cssFiles: string[],
218
+ ): string[] {
219
+ const all = { ...deps, ...devDeps }
220
+ const strategies: string[] = []
221
+
222
+ if (all['tailwindcss']) strategies.push('Tailwind')
223
+ if (hasCssModules) strategies.push('CSS Modules')
224
+ if (all['styled-components']) strategies.push('styled-components')
225
+ if (all['@emotion/react'] || all['@emotion/styled'])
226
+ strategies.push('Emotion')
227
+ if (all['sass'] || all['node-sass']) strategies.push('Sass')
228
+ if (all['less']) strategies.push('Less')
229
+ if (all['@vanilla-extract/css']) strategies.push('Vanilla Extract')
230
+
231
+ if (strategies.length === 0 && cssFiles.length > 0) {
232
+ strategies.push('CSS')
233
+ }
234
+
235
+ return strategies
236
+ }
237
+
238
+ function detectRootConfigs(resolved: string, cssFiles: string[]) {
239
+ const configPatterns = [
240
+ 'tailwind.config.js',
241
+ 'tailwind.config.ts',
242
+ 'tailwind.config.mjs',
243
+ 'tailwind.config.cjs',
244
+ 'postcss.config.js',
245
+ 'postcss.config.ts',
246
+ 'postcss.config.mjs',
247
+ 'postcss.config.cjs',
248
+ ]
249
+ for (const config of configPatterns) {
250
+ const configPath = path.join(resolved, config)
251
+ if (existsSync(configPath)) {
252
+ cssFiles.push(config)
253
+ }
254
+ }
255
+ }
256
+
257
+ function detectAssetDirs(resolved: string, assetDirs: Set<string>) {
258
+ const publicDir = path.join(resolved, 'public')
259
+ if (existsSync(publicDir)) {
260
+ try {
261
+ const entries = readdirSync(publicDir, { withFileTypes: true })
262
+ for (const entry of entries) {
263
+ if (
264
+ entry.isDirectory() &&
265
+ ASSET_DIR_NAMES.has(entry.name.toLowerCase())
266
+ ) {
267
+ assetDirs.add('public/' + entry.name)
268
+ }
269
+ }
270
+ if (entries.some((e) => e.isFile())) {
271
+ assetDirs.add('public')
272
+ }
273
+ } catch {
274
+ /* skip */
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Scan a project directory and return structured metadata.
281
+ * Requires a validated, resolved absolute path.
282
+ */
283
+ export function scanProject(resolvedRoot: string): ProjectScanData {
284
+ const pkgPath = path.join(resolvedRoot, 'package.json')
285
+ let projectName = 'unknown'
286
+ let deps: Record<string, string> = {}
287
+ let devDeps: Record<string, string> = {}
288
+
289
+ if (existsSync(pkgPath)) {
290
+ try {
291
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
292
+ projectName = pkg.name || 'unknown'
293
+ deps = pkg.dependencies || {}
294
+ devDeps = pkg.devDependencies || {}
295
+ } catch {
296
+ // couldn't parse — continue with defaults
297
+ }
298
+ }
299
+
300
+ const collectors: WalkCollectors = {
301
+ componentFileMap: {},
302
+ cssFiles: [],
303
+ routes: [],
304
+ assetDirs: new Set(),
305
+ hasCssModules: false,
306
+ }
307
+ const counter = { count: 0 }
308
+ const scannedRoots: string[] = []
309
+ const candidateDirs = ['src', 'app', 'components', 'lib', 'pages', 'styles']
310
+ const srcDirs: string[] = []
311
+
312
+ for (const dir of candidateDirs) {
313
+ const fullDir = path.join(resolvedRoot, dir)
314
+ if (!existsSync(fullDir)) continue
315
+
316
+ const alreadyCovered = scannedRoots.some((root) =>
317
+ fullDir.startsWith(root + path.sep),
318
+ )
319
+ if (alreadyCovered) continue
320
+
321
+ try {
322
+ const entries = readdirSync(fullDir, { withFileTypes: true })
323
+ if (entries.length > 0) {
324
+ walkDir(fullDir, resolvedRoot, collectors, counter)
325
+ scannedRoots.push(fullDir)
326
+ srcDirs.push(dir)
327
+ }
328
+ } catch {
329
+ /* skip inaccessible dirs */
330
+ }
331
+ }
332
+
333
+ detectRootConfigs(resolvedRoot, collectors.cssFiles)
334
+ detectAssetDirs(resolvedRoot, collectors.assetDirs)
335
+
336
+ const framework = detectFramework(deps, devDeps)
337
+ const cssStrategy = detectCssStrategy(
338
+ deps,
339
+ devDeps,
340
+ collectors.hasCssModules,
341
+ collectors.cssFiles,
342
+ )
343
+
344
+ return {
345
+ projectName,
346
+ componentCount: Object.keys(collectors.componentFileMap).length,
347
+ componentFileMap: collectors.componentFileMap,
348
+ framework,
349
+ cssStrategy,
350
+ cssFiles: collectors.cssFiles,
351
+ srcDirs,
352
+ assetDirs: Array.from(collectors.assetDirs),
353
+ routes: collectors.routes,
354
+ }
355
+ }