@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,158 @@
1
+ /**
2
+ * Browser-side SSE consumer for streaming Claude CLI activity.
3
+ * Uses fetch + ReadableStream (not EventSource, which only supports GET).
4
+ */
5
+
6
+ // ANSI escape codes
7
+ const ANSI = {
8
+ reset: '\x1b[0m',
9
+ bold: '\x1b[1m',
10
+ red: '\x1b[31m',
11
+ green: '\x1b[32m',
12
+ yellow: '\x1b[33m',
13
+ blue: '\x1b[34m',
14
+ magenta: '\x1b[35m',
15
+ cyan: '\x1b[36m',
16
+ gray: '\x1b[90m',
17
+ } as const
18
+
19
+ /**
20
+ * Classify a stderr line and wrap it in ANSI colors for xterm.js display.
21
+ */
22
+ export function formatStderrLine(line: string): string {
23
+ const trimmed = line.trim()
24
+ if (!trimmed) return ''
25
+
26
+ // File reads
27
+ if (
28
+ /^(Reading|Read)\s/i.test(trimmed) ||
29
+ /\.tsx?|\.jsx?|\.css|\.html/i.test(trimmed)
30
+ ) {
31
+ return `${ANSI.magenta} ${trimmed}${ANSI.reset}`
32
+ }
33
+ // Tool usage
34
+ if (/^Tool:\s/i.test(trimmed) || /^Using\s/i.test(trimmed)) {
35
+ return `${ANSI.cyan} ${trimmed}${ANSI.reset}`
36
+ }
37
+ // Success
38
+ if (/success|complete|done|finished/i.test(trimmed)) {
39
+ return `${ANSI.green} ${trimmed}${ANSI.reset}`
40
+ }
41
+ // Errors
42
+ if (/error|fail|exception/i.test(trimmed)) {
43
+ return `${ANSI.red} ${trimmed}${ANSI.reset}`
44
+ }
45
+ // Warnings
46
+ if (/warn|warning|caution/i.test(trimmed)) {
47
+ return `${ANSI.yellow} ${trimmed}${ANSI.reset}`
48
+ }
49
+ // Unclassified
50
+ return `${ANSI.gray} ${trimmed}${ANSI.reset}`
51
+ }
52
+
53
+ export interface StreamCallbacks<T> {
54
+ onStderr?: (line: string) => void
55
+ onResult?: (data: T) => void
56
+ onError?: (error: { code: string; message: string }) => void
57
+ onDone?: () => void
58
+ }
59
+
60
+ /**
61
+ * Send a POST with `Accept: text/event-stream` and consume the SSE response.
62
+ * Returns an AbortController for cancellation.
63
+ */
64
+ export function consumeClaudeStream<T>(
65
+ url: string,
66
+ body: Record<string, unknown>,
67
+ callbacks: StreamCallbacks<T>,
68
+ ): AbortController {
69
+ const controller = new AbortController()
70
+
71
+ ;(async () => {
72
+ try {
73
+ const res = await fetch(url, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ Accept: 'text/event-stream',
78
+ },
79
+ body: JSON.stringify(body),
80
+ signal: controller.signal,
81
+ })
82
+
83
+ if (!res.ok || !res.body) {
84
+ const data = await res.json().catch(() => ({}))
85
+ callbacks.onError?.({
86
+ code: data.code || 'HTTP_ERROR',
87
+ message: data.error || `Request failed with status ${res.status}`,
88
+ })
89
+ callbacks.onDone?.()
90
+ return
91
+ }
92
+
93
+ const reader = res.body.getReader()
94
+ const decoder = new TextDecoder()
95
+ let buffer = ''
96
+
97
+ while (true) {
98
+ const { done, value } = await reader.read()
99
+ if (done) break
100
+
101
+ buffer += decoder.decode(value, { stream: true })
102
+
103
+ // Parse SSE events from buffer
104
+ const parts = buffer.split('\n\n')
105
+ // Keep the last part — it may be incomplete
106
+ buffer = parts.pop() || ''
107
+
108
+ for (const part of parts) {
109
+ const lines = part.split('\n')
110
+ let eventType = ''
111
+ let dataStr = ''
112
+
113
+ for (const line of lines) {
114
+ if (line.startsWith('event: ')) {
115
+ eventType = line.slice(7)
116
+ } else if (line.startsWith('data: ')) {
117
+ dataStr = line.slice(6)
118
+ }
119
+ }
120
+
121
+ if (!eventType || !dataStr) continue
122
+
123
+ try {
124
+ const payload = JSON.parse(dataStr)
125
+
126
+ switch (eventType) {
127
+ case 'stderr':
128
+ callbacks.onStderr?.(payload.line)
129
+ break
130
+ case 'result':
131
+ callbacks.onResult?.(payload as T)
132
+ break
133
+ case 'error':
134
+ callbacks.onError?.(payload)
135
+ break
136
+ case 'done':
137
+ // Stream ended
138
+ break
139
+ }
140
+ } catch {
141
+ // Skip malformed JSON
142
+ }
143
+ }
144
+ }
145
+ } catch (err) {
146
+ if ((err as Error).name !== 'AbortError') {
147
+ callbacks.onError?.({
148
+ code: 'NETWORK_ERROR',
149
+ message: err instanceof Error ? err.message : 'Network error',
150
+ })
151
+ }
152
+ } finally {
153
+ callbacks.onDone?.()
154
+ }
155
+ })()
156
+
157
+ return controller
158
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Client-side project scanner using the File System Access API.
3
+ * Mirrors the server-side /api/project-scan logic but runs entirely in the browser.
4
+ */
5
+
6
+ import type { RouteEntry } from '@/types/claude'
7
+
8
+ export interface ClientScanResult {
9
+ projectName: string
10
+ componentCount: number
11
+ componentFileMap: Record<string, string>
12
+ framework: string | null
13
+ cssStrategy: string[]
14
+ cssFiles: string[]
15
+ srcDirs: string[]
16
+ assetDirs: string[]
17
+ routes: RouteEntry[]
18
+ }
19
+
20
+ const SKIP_DIRS = new Set([
21
+ 'node_modules',
22
+ '.next',
23
+ 'dist',
24
+ 'build',
25
+ '.git',
26
+ '__tests__',
27
+ '__mocks__',
28
+ '.turbo',
29
+ '.vercel',
30
+ 'coverage',
31
+ ])
32
+
33
+ const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx'])
34
+ const MAYBE_COMPONENT_EXTENSIONS = new Set(['.ts', '.js'])
35
+ const CSS_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less'])
36
+ const MAX_FILES = 5000
37
+
38
+ const CONVENTION_FILES = new Set([
39
+ 'page',
40
+ 'layout',
41
+ 'loading',
42
+ 'error',
43
+ 'not-found',
44
+ 'template',
45
+ 'default',
46
+ 'route',
47
+ 'proxy',
48
+ 'middleware',
49
+ 'global-error',
50
+ 'instrumentation',
51
+ ])
52
+
53
+ const ROUTE_CONVENTION_MAP: Record<string, RouteEntry['type']> = {
54
+ page: 'page',
55
+ layout: 'layout',
56
+ loading: 'loading',
57
+ error: 'error',
58
+ 'not-found': 'not-found',
59
+ template: 'template',
60
+ }
61
+
62
+ const PREFERRED_SEGMENTS = new Set(['components', 'ui', 'common', 'shared'])
63
+
64
+ const ASSET_DIR_NAMES = new Set([
65
+ 'images',
66
+ 'img',
67
+ 'fonts',
68
+ 'icons',
69
+ 'media',
70
+ 'assets',
71
+ 'static',
72
+ 'videos',
73
+ 'svgs',
74
+ 'illustrations',
75
+ ])
76
+
77
+ function isPascalCase(name: string): boolean {
78
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name)
79
+ }
80
+
81
+ function getExtension(name: string): string {
82
+ const i = name.lastIndexOf('.')
83
+ return i >= 0 ? name.slice(i) : ''
84
+ }
85
+
86
+ function getBaseName(name: string): string {
87
+ const ext = getExtension(name)
88
+ return ext ? name.slice(0, -ext.length) : name
89
+ }
90
+
91
+ function hasPreferredSegment(relativePath: string): boolean {
92
+ const segments = relativePath.split('/')
93
+ return segments.some((s) => PREFERRED_SEGMENTS.has(s))
94
+ }
95
+
96
+ function filePathToUrlPattern(relativePath: string): string {
97
+ const parts = relativePath.split('/')
98
+ const routeParts = parts.slice(1, -1)
99
+ const filtered = routeParts.filter((p) => !p.startsWith('('))
100
+ return '/' + filtered.join('/')
101
+ }
102
+
103
+ interface Collectors {
104
+ componentFileMap: Record<string, string>
105
+ cssFiles: string[]
106
+ routes: RouteEntry[]
107
+ assetDirs: Set<string>
108
+ hasCssModules: boolean
109
+ }
110
+
111
+ async function walkDir(
112
+ dirHandle: FileSystemDirectoryHandle,
113
+ relativePath: string,
114
+ collectors: Collectors,
115
+ counter: { count: number },
116
+ ): Promise<void> {
117
+ if (counter.count >= MAX_FILES) return
118
+
119
+ for await (const [name, entryHandle] of dirHandle.entries()) {
120
+ if (counter.count >= MAX_FILES) return
121
+ if (SKIP_DIRS.has(name)) continue
122
+
123
+ if (entryHandle.kind === 'directory') {
124
+ if (ASSET_DIR_NAMES.has(name.toLowerCase())) {
125
+ const relDir = relativePath ? `${relativePath}/${name}` : name
126
+ collectors.assetDirs.add(relDir)
127
+ }
128
+ const childPath = relativePath ? `${relativePath}/${name}` : name
129
+ await walkDir(
130
+ entryHandle as FileSystemDirectoryHandle,
131
+ childPath,
132
+ collectors,
133
+ counter,
134
+ )
135
+ continue
136
+ }
137
+
138
+ counter.count++
139
+ const ext = getExtension(name)
140
+ const baseName = getBaseName(name)
141
+ const filePath = relativePath ? `${relativePath}/${name}` : name
142
+
143
+ if (baseName.startsWith('.')) continue
144
+
145
+ // CSS files
146
+ if (CSS_EXTENSIONS.has(ext)) {
147
+ collectors.cssFiles.push(filePath)
148
+ if (name.includes('.module.')) {
149
+ collectors.hasCssModules = true
150
+ }
151
+ continue
152
+ }
153
+
154
+ // Test files
155
+ if (baseName.endsWith('.test') || baseName.endsWith('.spec')) continue
156
+
157
+ // Route/convention files
158
+ const routeType = ROUTE_CONVENTION_MAP[baseName]
159
+ if (
160
+ routeType &&
161
+ (COMPONENT_EXTENSIONS.has(ext) || MAYBE_COMPONENT_EXTENSIONS.has(ext))
162
+ ) {
163
+ if (filePath.startsWith('app/') || filePath.startsWith('src/app/')) {
164
+ const normalizedPath = filePath.startsWith('src/')
165
+ ? filePath.slice(4)
166
+ : filePath
167
+ collectors.routes.push({
168
+ urlPattern: filePathToUrlPattern(normalizedPath),
169
+ filePath,
170
+ type: routeType,
171
+ })
172
+ }
173
+ continue
174
+ }
175
+
176
+ if (baseName === 'index') continue
177
+ if (CONVENTION_FILES.has(baseName)) continue
178
+
179
+ // Component files
180
+ const isComponentExt = COMPONENT_EXTENSIONS.has(ext)
181
+ const isMaybeComponentExt = MAYBE_COMPONENT_EXTENSIONS.has(ext)
182
+ if (!isComponentExt && !isMaybeComponentExt) continue
183
+ if (isMaybeComponentExt && !isPascalCase(baseName)) continue
184
+
185
+ if (collectors.componentFileMap[baseName]) {
186
+ const existingPreferred = hasPreferredSegment(
187
+ collectors.componentFileMap[baseName],
188
+ )
189
+ const newPreferred = hasPreferredSegment(filePath)
190
+ if (newPreferred && !existingPreferred) {
191
+ collectors.componentFileMap[baseName] = filePath
192
+ }
193
+ } else {
194
+ collectors.componentFileMap[baseName] = filePath
195
+ }
196
+ }
197
+ }
198
+
199
+ function detectFramework(
200
+ deps: Record<string, string>,
201
+ devDeps: Record<string, string>,
202
+ ): string | null {
203
+ const all = { ...deps, ...devDeps }
204
+ if (all['next']) return 'Next.js'
205
+ if (all['@remix-run/react'] || all['remix']) return 'Remix'
206
+ if (all['gatsby']) return 'Gatsby'
207
+ if (all['astro']) return 'Astro'
208
+ if (all['@angular/core']) return 'Angular'
209
+ if (all['vue']) return 'Vue'
210
+ if (all['svelte']) return 'Svelte'
211
+ if (all['react']) return 'React'
212
+ return null
213
+ }
214
+
215
+ function detectCssStrategy(
216
+ deps: Record<string, string>,
217
+ devDeps: Record<string, string>,
218
+ hasCssModules: boolean,
219
+ cssFiles: string[],
220
+ ): string[] {
221
+ const all = { ...deps, ...devDeps }
222
+ const strategies: string[] = []
223
+ if (all['tailwindcss']) strategies.push('Tailwind')
224
+ if (hasCssModules) strategies.push('CSS Modules')
225
+ if (all['styled-components']) strategies.push('styled-components')
226
+ if (all['@emotion/react'] || all['@emotion/styled'])
227
+ strategies.push('Emotion')
228
+ if (all['sass'] || all['node-sass']) strategies.push('Sass')
229
+ if (all['less']) strategies.push('Less')
230
+ if (all['@vanilla-extract/css']) strategies.push('Vanilla Extract')
231
+ if (strategies.length === 0 && cssFiles.length > 0) strategies.push('CSS')
232
+ return strategies
233
+ }
234
+
235
+ /**
236
+ * Scan a project directory using the File System Access API.
237
+ * Returns the same shape as the server-side /api/project-scan endpoint.
238
+ */
239
+ export async function scanProjectClient(
240
+ rootHandle: FileSystemDirectoryHandle,
241
+ ): Promise<ClientScanResult> {
242
+ // Read package.json
243
+ let projectName = rootHandle.name || 'unknown'
244
+ let deps: Record<string, string> = {}
245
+ let devDeps: Record<string, string> = {}
246
+
247
+ try {
248
+ const pkgHandle = await rootHandle.getFileHandle('package.json')
249
+ const pkgFile = await pkgHandle.getFile()
250
+ const pkgText = await pkgFile.text()
251
+ const pkg = JSON.parse(pkgText)
252
+ projectName = pkg.name || projectName
253
+ deps = pkg.dependencies || {}
254
+ devDeps = pkg.devDependencies || {}
255
+ } catch {
256
+ // No package.json or couldn't parse — continue with defaults
257
+ }
258
+
259
+ const collectors: Collectors = {
260
+ componentFileMap: {},
261
+ cssFiles: [],
262
+ routes: [],
263
+ assetDirs: new Set(),
264
+ hasCssModules: false,
265
+ }
266
+ const counter = { count: 0 }
267
+ const srcDirs: string[] = []
268
+ const candidateDirs = ['src', 'app', 'components', 'lib', 'pages', 'styles']
269
+ const scannedRoots: string[] = []
270
+
271
+ for (const dir of candidateDirs) {
272
+ const alreadyCovered = scannedRoots.some((root) =>
273
+ dir.startsWith(root + '/'),
274
+ )
275
+ if (alreadyCovered) continue
276
+
277
+ try {
278
+ const dirHandle = await rootHandle.getDirectoryHandle(dir)
279
+ await walkDir(dirHandle, dir, collectors, counter)
280
+ scannedRoots.push(dir)
281
+ srcDirs.push(dir)
282
+ } catch {
283
+ // Directory doesn't exist — skip
284
+ }
285
+ }
286
+
287
+ // Detect root-level config files
288
+ const configPatterns = [
289
+ 'tailwind.config.js',
290
+ 'tailwind.config.ts',
291
+ 'tailwind.config.mjs',
292
+ 'tailwind.config.cjs',
293
+ 'postcss.config.js',
294
+ 'postcss.config.ts',
295
+ 'postcss.config.mjs',
296
+ 'postcss.config.cjs',
297
+ ]
298
+ for (const config of configPatterns) {
299
+ try {
300
+ await rootHandle.getFileHandle(config)
301
+ collectors.cssFiles.push(config)
302
+ } catch {
303
+ // Not found — skip
304
+ }
305
+ }
306
+
307
+ // Detect asset dirs under public/
308
+ try {
309
+ const publicHandle = await rootHandle.getDirectoryHandle('public')
310
+ for await (const [name, entry] of publicHandle.entries()) {
311
+ if (
312
+ entry.kind === 'directory' &&
313
+ ASSET_DIR_NAMES.has(name.toLowerCase())
314
+ ) {
315
+ collectors.assetDirs.add('public/' + name)
316
+ }
317
+ if (entry.kind === 'file') {
318
+ collectors.assetDirs.add('public')
319
+ }
320
+ }
321
+ } catch {
322
+ // No public dir
323
+ }
324
+
325
+ const framework = detectFramework(deps, devDeps)
326
+ const cssStrategy = detectCssStrategy(
327
+ deps,
328
+ devDeps,
329
+ collectors.hasCssModules,
330
+ collectors.cssFiles,
331
+ )
332
+
333
+ return {
334
+ projectName,
335
+ componentCount: Object.keys(collectors.componentFileMap).length,
336
+ componentFileMap: collectors.componentFileMap,
337
+ framework,
338
+ cssStrategy,
339
+ cssFiles: collectors.cssFiles,
340
+ srcDirs,
341
+ assetDirs: Array.from(collectors.assetDirs),
342
+ routes: collectors.routes,
343
+ }
344
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Match a selected DOM element to a component file from the scanned file map.
3
+ *
4
+ * Pure function — only returns paths that exist in the provided file map.
5
+ * Never fabricates paths.
6
+ */
7
+
8
+ interface ElementSignals {
9
+ attributes: Record<string, string>
10
+ id: string | null
11
+ className: string | null
12
+ selectorPath: string
13
+ tagName: string
14
+ }
15
+
16
+ function kebabToPascal(str: string): string {
17
+ return str
18
+ .split('-')
19
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
20
+ .join('')
21
+ }
22
+
23
+ function lookup(name: string, map: Record<string, string>): string | null {
24
+ if (map[name]) return map[name]
25
+ // Try without common suffixes
26
+ for (const suffix of ['Component', 'View', 'Page', 'Section', 'Widget']) {
27
+ if (map[name + suffix]) return map[name + suffix]
28
+ }
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * Extract potential component names from a CSS class string.
34
+ * Looks for PascalCase class names or kebab-case names that convert to PascalCase.
35
+ */
36
+ function extractClassCandidates(className: string): string[] {
37
+ const candidates: string[] = []
38
+ const classes = className.split(/\s+/).filter(Boolean)
39
+
40
+ for (const cls of classes) {
41
+ // Skip Tailwind utility classes (contain brackets, colons, slashes, or start with lowercase single segment)
42
+ if (cls.includes('[') || cls.includes(':') || cls.includes('/')) continue
43
+ if (cls.startsWith('-')) continue
44
+
45
+ // Already PascalCase
46
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(cls)) {
47
+ candidates.push(cls)
48
+ continue
49
+ }
50
+
51
+ // Kebab-case that looks like a component name (at least 2 segments, no numbers-only segments)
52
+ if (cls.includes('-') && !cls.startsWith('_')) {
53
+ const parts = cls.split('-')
54
+ if (
55
+ parts.length >= 2 &&
56
+ parts.every((p) => p.length > 0 && !/^\d+$/.test(p))
57
+ ) {
58
+ const pascal = kebabToPascal(cls)
59
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(pascal)) {
60
+ candidates.push(pascal)
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return candidates
67
+ }
68
+
69
+ /**
70
+ * Extract ancestor tag/class hints from the selectorPath.
71
+ * selectorPath format: "body > div.container > main > section.hero > h1"
72
+ */
73
+ function extractAncestorCandidates(selectorPath: string): string[] {
74
+ const candidates: string[] = []
75
+ const segments = selectorPath.split('>').map((s) => s.trim())
76
+
77
+ // Walk from innermost to outermost (skip the element itself)
78
+ for (let i = segments.length - 2; i >= 0; i--) {
79
+ const seg = segments[i]
80
+
81
+ // Extract class names from segment like "div.ClassName" or "section.hero-section"
82
+ const classMatch = seg.match(/\.([a-zA-Z][\w-]*)/g)
83
+ if (classMatch) {
84
+ for (const cls of classMatch) {
85
+ const name = cls.slice(1) // remove leading dot
86
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(name)) {
87
+ candidates.push(name)
88
+ } else if (name.includes('-')) {
89
+ const pascal = kebabToPascal(name)
90
+ if (/^[A-Z][a-zA-Z0-9]+$/.test(pascal)) {
91
+ candidates.push(pascal)
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // Extract from data attributes in the segment (rare in selectorPath, but possible)
98
+ const dataMatch = seg.match(/\[data-component="([^"]+)"\]/)
99
+ if (dataMatch) {
100
+ candidates.push(dataMatch[1])
101
+ }
102
+ }
103
+
104
+ return candidates
105
+ }
106
+
107
+ export function matchElementToComponent(
108
+ signals: ElementSignals,
109
+ fileMap: Record<string, string>,
110
+ ): string | null {
111
+ // Strategy 1: data-component attribute → exact lookup
112
+ const dataComponent = signals.attributes['data-component']
113
+ if (dataComponent) {
114
+ const result = lookup(dataComponent, fileMap)
115
+ if (result) return result
116
+ }
117
+
118
+ // Strategy 2: data-testid → convert kebab-to-PascalCase → lookup
119
+ const testId = signals.attributes['data-testid']
120
+ if (testId) {
121
+ const pascal = kebabToPascal(testId)
122
+ const result = lookup(pascal, fileMap)
123
+ if (result) return result
124
+ }
125
+
126
+ // Strategy 3: Element id → convert to PascalCase → lookup
127
+ if (signals.id) {
128
+ const pascal = kebabToPascal(signals.id)
129
+ const result = lookup(pascal, fileMap)
130
+ if (result) return result
131
+
132
+ // Try the id as-is if it's already PascalCase
133
+ if (/^[A-Z]/.test(signals.id)) {
134
+ const result2 = lookup(signals.id, fileMap)
135
+ if (result2) return result2
136
+ }
137
+ }
138
+
139
+ // Strategy 4: Walk up selectorPath ancestors
140
+ const ancestorCandidates = extractAncestorCandidates(signals.selectorPath)
141
+ for (const candidate of ancestorCandidates) {
142
+ const result = lookup(candidate, fileMap)
143
+ if (result) return result
144
+ }
145
+
146
+ // Strategy 5: Class names that look PascalCase or convert from kebab-case
147
+ if (signals.className) {
148
+ const classCandidates = extractClassCandidates(signals.className)
149
+ for (const candidate of classCandidates) {
150
+ const result = lookup(candidate, fileMap)
151
+ if (result) return result
152
+ }
153
+ }
154
+
155
+ return null
156
+ }