@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,329 @@
1
+ /**
2
+ * Bridge proxy handler.
3
+ *
4
+ * Simplified version of /api/proxy/[[...path]]/route.ts.
5
+ * Since the bridge IS the iframe origin, relative URLs naturally
6
+ * route back through it — eliminates most URL rewriting.
7
+ */
8
+
9
+ const PROXY_HEADER = 'x-dev-editor-target'
10
+
11
+ // In-memory target URL (set on first request with the query param)
12
+ let storedTargetUrl: string | null = null
13
+
14
+ const SECURITY_HEADERS_TO_STRIP = new Set([
15
+ 'content-security-policy',
16
+ 'content-security-policy-report-only',
17
+ 'x-frame-options',
18
+ 'cross-origin-embedder-policy',
19
+ 'cross-origin-opener-policy',
20
+ 'cross-origin-resource-policy',
21
+ ])
22
+
23
+ const HMR_PATTERNS = [
24
+ '.hot-update.',
25
+ 'webpack-hmr',
26
+ 'turbopack-hmr',
27
+ '__webpack_hmr',
28
+ '_next/webpack-hmr',
29
+ ]
30
+
31
+ const SCRIPT_STRIP_RE =
32
+ /<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*>[\s\S]*?<\/script\s*>/gi
33
+ const SCRIPT_SELF_CLOSING_RE =
34
+ /<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*\/\s*>/gi
35
+
36
+ function isLocalhostUrl(url: string): boolean {
37
+ try {
38
+ const parsed = new URL(url)
39
+ return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ function getTargetUrl(req: Request, url: URL): string | null {
46
+ // Check query param first
47
+ const fromQuery = url.searchParams.get(PROXY_HEADER)
48
+ if (fromQuery && isLocalhostUrl(fromQuery)) {
49
+ storedTargetUrl = fromQuery
50
+ return fromQuery
51
+ }
52
+
53
+ // Check header
54
+ const fromHeader = req.headers.get(PROXY_HEADER)
55
+ if (fromHeader && isLocalhostUrl(fromHeader)) {
56
+ storedTargetUrl = fromHeader
57
+ return fromHeader
58
+ }
59
+
60
+ // Fall back to stored target
61
+ return storedTargetUrl
62
+ }
63
+
64
+ function isHtmlResponse(contentType: string | null): boolean {
65
+ if (!contentType) return false
66
+ return contentType.includes('text/html')
67
+ }
68
+
69
+ function isCssResponse(contentType: string | null): boolean {
70
+ if (!contentType) return false
71
+ return contentType.includes('text/css')
72
+ }
73
+
74
+ function buildNavigationBlocker(): string {
75
+ return `<script>
76
+ (function() {
77
+ // HMR mock — prevent target app's HMR from connecting
78
+ var _origWS = window.WebSocket;
79
+ window.WebSocket = function(url) {
80
+ var ws = { readyState: 3, send: function(){}, close: function(){},
81
+ addEventListener: function(){}, removeEventListener: function(){},
82
+ onopen: null, onclose: null, onerror: null, onmessage: null,
83
+ CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 };
84
+ setTimeout(function() { if (ws.onerror) ws.onerror(new Event('error')); }, 50);
85
+ return ws;
86
+ };
87
+ window.WebSocket.CONNECTING = 0;
88
+ window.WebSocket.OPEN = 1;
89
+ window.WebSocket.CLOSING = 2;
90
+ window.WebSocket.CLOSED = 3;
91
+
92
+ // Mock EventSource (Turbopack HMR)
93
+ var _origES = window.EventSource;
94
+ window.EventSource = function() {
95
+ var es = { readyState: 2, close: function(){},
96
+ addEventListener: function(){}, removeEventListener: function(){},
97
+ onopen: null, onerror: null, onmessage: null,
98
+ CONNECTING: 0, OPEN: 1, CLOSED: 2 };
99
+ setTimeout(function() { if (es.onerror) es.onerror(new Event('error')); }, 50);
100
+ return es;
101
+ };
102
+ window.EventSource.CONNECTING = 0;
103
+ window.EventSource.OPEN = 1;
104
+ window.EventSource.CLOSED = 2;
105
+
106
+ // Suppress unhandled rejection errors from HMR
107
+ window.addEventListener('unhandledrejection', function(e) {
108
+ var msg = e.reason && (e.reason.message || String(e.reason));
109
+ if (msg && (/hmr|hot.update|webpack|turbopack/i.test(msg))) {
110
+ e.preventDefault();
111
+ }
112
+ });
113
+
114
+ // Prevent navigation loops — block location changes
115
+ var _reloadCount = 0;
116
+ var _origReload = window.location.reload;
117
+ window.location.reload = function() {
118
+ _reloadCount++;
119
+ if (_reloadCount > 2) return;
120
+ _origReload.call(window.location);
121
+ };
122
+ })();
123
+ </script>`
124
+ }
125
+
126
+ function injectIntoHtml(html: string, inspectorScript: string | null): string {
127
+ // Strip all script tags (except ld+json)
128
+ let result = html.replace(SCRIPT_STRIP_RE, '')
129
+ result = result.replace(SCRIPT_SELF_CLOSING_RE, '')
130
+
131
+ const navBlocker = buildNavigationBlocker()
132
+
133
+ // Inspector injection
134
+ const inspectorTag = inspectorScript
135
+ ? `<script>${inspectorScript}</script>`
136
+ : '<script src="/dev-editor-inspector.js"></script>'
137
+
138
+ // Inject navigation blocker in <head>, inspector before </body>
139
+ if (result.includes('</head>')) {
140
+ result = result.replace('</head>', navBlocker + '</head>')
141
+ } else {
142
+ result = navBlocker + result
143
+ }
144
+
145
+ if (result.includes('</body>')) {
146
+ result = result.replace('</body>', inspectorTag + '</body>')
147
+ } else {
148
+ result = result + inspectorTag
149
+ }
150
+
151
+ return result
152
+ }
153
+
154
+ function stripSecurityHeaders(headers: Headers): Headers {
155
+ const cleaned = new Headers()
156
+ headers.forEach((value, key) => {
157
+ if (!SECURITY_HEADERS_TO_STRIP.has(key.toLowerCase())) {
158
+ cleaned.append(key, value)
159
+ }
160
+ })
161
+ return cleaned
162
+ }
163
+
164
+ function rewriteCssUrls(css: string, targetOrigin: string): string {
165
+ // Rewrite absolute URLs pointing to the target to be relative (go through bridge)
166
+ return css.replace(
167
+ new RegExp(
168
+ `url\\(\\s*['"]?${targetOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/[^'")\\s]*)['"]?\\s*\\)`,
169
+ 'g',
170
+ ),
171
+ 'url($1)',
172
+ )
173
+ }
174
+
175
+ export async function handleProxy(
176
+ req: Request,
177
+ url: URL,
178
+ cors: Record<string, string>,
179
+ inspectorScript: string | null,
180
+ ): Promise<Response> {
181
+ const targetUrl = getTargetUrl(req, url)
182
+
183
+ if (!targetUrl) {
184
+ return Response.json(
185
+ {
186
+ error:
187
+ 'No target URL. Add ?x-dev-editor-target=http://localhost:PORT to your first request.',
188
+ },
189
+ { status: 400, headers: cors },
190
+ )
191
+ }
192
+
193
+ // Short-circuit HMR requests
194
+ const pathname = url.pathname
195
+ if (HMR_PATTERNS.some((p) => pathname.includes(p))) {
196
+ if (pathname.endsWith('.json')) {
197
+ return new Response('{}', {
198
+ headers: { ...cors, 'Content-Type': 'application/json' },
199
+ })
200
+ }
201
+ if (pathname.endsWith('.js')) {
202
+ return new Response('', {
203
+ headers: { ...cors, 'Content-Type': 'application/javascript' },
204
+ })
205
+ }
206
+ return new Response(null, { status: 204, headers: cors })
207
+ }
208
+
209
+ // Build the target fetch URL
210
+ const targetOrigin = new URL(targetUrl).origin
211
+ const fetchUrl = `${targetOrigin}${pathname}${url.search ? url.search.replace(new RegExp(`[?&]${PROXY_HEADER}=[^&]*`), '') : ''}`
212
+
213
+ // Strip the proxy header from the search params
214
+ const cleanSearch = url.search
215
+ .replace(new RegExp(`[?&]${PROXY_HEADER}=[^&]*`), '')
216
+ .replace(/^\?$/, '')
217
+
218
+ const finalFetchUrl = `${targetOrigin}${pathname}${cleanSearch}`
219
+
220
+ try {
221
+ const targetRes = await fetch(finalFetchUrl, {
222
+ method: req.method,
223
+ headers: {
224
+ Accept: req.headers.get('accept') || '*/*',
225
+ 'Accept-Encoding': 'identity',
226
+ },
227
+ redirect: 'manual',
228
+ })
229
+
230
+ // Handle redirects — rewrite Location to stay within bridge
231
+ if (targetRes.status >= 300 && targetRes.status < 400) {
232
+ const location = targetRes.headers.get('location')
233
+ if (location) {
234
+ let rewrittenLocation = location
235
+ if (location.startsWith(targetOrigin)) {
236
+ rewrittenLocation = location.slice(targetOrigin.length)
237
+ }
238
+ const redirectHeaders = new Headers(cors)
239
+ redirectHeaders.set('Location', rewrittenLocation)
240
+ // Preserve cookies
241
+ targetRes.headers.forEach((value, key) => {
242
+ if (key.toLowerCase() === 'set-cookie') {
243
+ redirectHeaders.append('Set-Cookie', value)
244
+ }
245
+ })
246
+ return new Response(null, {
247
+ status: targetRes.status,
248
+ headers: redirectHeaders,
249
+ })
250
+ }
251
+ }
252
+
253
+ const contentType = targetRes.headers.get('content-type')
254
+ const responseHeaders = stripSecurityHeaders(targetRes.headers)
255
+
256
+ // Add CORS headers
257
+ for (const [key, value] of Object.entries(cors)) {
258
+ responseHeaders.set(key, value)
259
+ }
260
+
261
+ // HTML response — strip scripts, inject inspector
262
+ if (isHtmlResponse(contentType)) {
263
+ const html = await targetRes.text()
264
+ const modified = injectIntoHtml(html, inspectorScript)
265
+
266
+ responseHeaders.set('Content-Type', 'text/html; charset=utf-8')
267
+ responseHeaders.delete('content-length')
268
+ responseHeaders.set(
269
+ 'Cache-Control',
270
+ 'no-cache, no-store, must-revalidate',
271
+ )
272
+
273
+ return new Response(modified, {
274
+ status: targetRes.status,
275
+ headers: responseHeaders,
276
+ })
277
+ }
278
+
279
+ // CSS response — rewrite absolute target URLs
280
+ if (isCssResponse(contentType)) {
281
+ const css = await targetRes.text()
282
+ const modified = rewriteCssUrls(css, targetOrigin)
283
+
284
+ responseHeaders.delete('content-length')
285
+ responseHeaders.set('Cache-Control', 'public, max-age=3600')
286
+
287
+ return new Response(modified, {
288
+ status: targetRes.status,
289
+ headers: responseHeaders,
290
+ })
291
+ }
292
+
293
+ // Font response — long cache
294
+ if (
295
+ contentType &&
296
+ (contentType.includes('font') ||
297
+ pathname.match(/\.(woff2?|ttf|otf|eot)$/i))
298
+ ) {
299
+ responseHeaders.set(
300
+ 'Cache-Control',
301
+ 'public, max-age=31536000, immutable',
302
+ )
303
+ }
304
+
305
+ // Image response — always revalidate so updated assets on the target
306
+ // are reflected immediately instead of being served from browser cache.
307
+ if (
308
+ (contentType && contentType.includes('image/')) ||
309
+ pathname.match(/\.(png|jpe?g|gif|svg|ico|webp|avif)(\?|$)/i)
310
+ ) {
311
+ responseHeaders.set(
312
+ 'Cache-Control',
313
+ 'no-cache, no-store, must-revalidate',
314
+ )
315
+ }
316
+
317
+ // All other responses — passthrough
318
+ return new Response(targetRes.body, {
319
+ status: targetRes.status,
320
+ headers: responseHeaders,
321
+ })
322
+ } catch (err) {
323
+ const message = err instanceof Error ? err.message : 'Unknown error'
324
+ return Response.json(
325
+ { error: `Failed to proxy request: ${message}` },
326
+ { status: 502, headers: cors },
327
+ )
328
+ }
329
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * pAInt Bridge Server
3
+ *
4
+ * A lightweight Bun HTTP server that runs on the user's machine.
5
+ * When pAInt is deployed on Vercel, it connects to this bridge
6
+ * to proxy localhost pages, scan project directories, and run Claude CLI.
7
+ *
8
+ * Usage:
9
+ * bun run src/bridge/server.ts
10
+ * # or
11
+ * bun run bridge
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { handleProxy } from './proxy-handler'
17
+ import { handleAPI } from './api-handlers'
18
+
19
+ const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 4002
20
+
21
+ const ALLOWED_ORIGIN_PATTERNS = [
22
+ 'https://dev-editor-flow.vercel.app',
23
+ /^http:\/\/localhost(:\d+)?$/,
24
+ /^http:\/\/127\.0\.0\.1(:\d+)?$/,
25
+ ]
26
+
27
+ function isAllowedOrigin(origin: string): boolean {
28
+ for (const pattern of ALLOWED_ORIGIN_PATTERNS) {
29
+ if (typeof pattern === 'string') {
30
+ if (origin === pattern) return true
31
+ } else {
32
+ if (pattern.test(origin)) return true
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ function corsHeaders(requestOrigin: string | null): Record<string, string> {
39
+ const origin =
40
+ requestOrigin && isAllowedOrigin(requestOrigin)
41
+ ? requestOrigin
42
+ : 'https://dev-editor-flow.vercel.app'
43
+ return {
44
+ 'Access-Control-Allow-Origin': origin,
45
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
46
+ 'Access-Control-Allow-Headers': 'Content-Type, x-dev-editor-target, Accept',
47
+ 'Access-Control-Allow-Credentials': 'true',
48
+ 'Access-Control-Max-Age': '86400',
49
+ }
50
+ }
51
+
52
+ // Cache the inspector script at startup
53
+ let inspectorScript: string | null = null
54
+ const inspectorPath = join(
55
+ import.meta.dir,
56
+ '../../public/dev-editor-inspector.js',
57
+ )
58
+ if (existsSync(inspectorPath)) {
59
+ inspectorScript = readFileSync(inspectorPath, 'utf-8')
60
+ }
61
+
62
+ Bun.serve({
63
+ port: BRIDGE_PORT,
64
+
65
+ async fetch(req) {
66
+ const url = new URL(req.url)
67
+ const origin = req.headers.get('origin')
68
+ const cors = corsHeaders(origin)
69
+
70
+ // CORS preflight
71
+ if (req.method === 'OPTIONS') {
72
+ return new Response(null, { status: 204, headers: cors })
73
+ }
74
+
75
+ // Health check (used for auto-discovery from Vercel editor)
76
+ if (url.pathname === '/health') {
77
+ return Response.json(
78
+ { status: 'ok', version: '1.0.0', bridge: true },
79
+ { headers: cors },
80
+ )
81
+ }
82
+
83
+ // Serve the inspector script
84
+ if (url.pathname === '/dev-editor-inspector.js') {
85
+ if (!inspectorScript) {
86
+ return new Response('Inspector script not found', {
87
+ status: 404,
88
+ headers: cors,
89
+ })
90
+ }
91
+ return new Response(inspectorScript, {
92
+ headers: {
93
+ ...cors,
94
+ 'Content-Type': 'application/javascript',
95
+ 'Cache-Control': 'public, max-age=3600',
96
+ },
97
+ })
98
+ }
99
+
100
+ // API routes
101
+ if (url.pathname.startsWith('/api/')) {
102
+ return handleAPI(req, url, cors)
103
+ }
104
+
105
+ // Everything else: proxy to target localhost
106
+ return handleProxy(req, url, cors, inspectorScript)
107
+ },
108
+ })
109
+
110
+ console.log(`\n pAInt Bridge running on http://localhost:${BRIDGE_PORT}`)
111
+ console.log(
112
+ ` Connect from: https://dev-editor-flow.vercel.app?bridge=localhost:${BRIDGE_PORT}\n`,
113
+ )
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { usePostMessage } from '@/hooks/usePostMessage'
6
+ import {
7
+ BREAKPOINTS,
8
+ DEVICE_PRESETS,
9
+ BREAKPOINT_CATEGORY_MAP,
10
+ } from '@/lib/constants'
11
+ import type { Breakpoint } from '@/types/changelog'
12
+
13
+ export function BreakpointTabs() {
14
+ const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
15
+ const previewWidth = useEditorStore((s) => s.previewWidth)
16
+ const setActiveBreakpoint = useEditorStore((s) => s.setActiveBreakpoint)
17
+ const setPreviewWidth = useEditorStore((s) => s.setPreviewWidth)
18
+ const { sendToInspector } = usePostMessage()
19
+
20
+ const breakpoints = Object.entries(BREAKPOINTS) as [
21
+ Breakpoint,
22
+ { label: string; width: number },
23
+ ][]
24
+
25
+ // Find matched device for the active breakpoint category
26
+ const activeCategory = BREAKPOINT_CATEGORY_MAP[activeBreakpoint]
27
+ const matchedDevice = DEVICE_PRESETS.find(
28
+ (d) => d.category === activeCategory && d.width === previewWidth,
29
+ )
30
+
31
+ const handleBreakpointChange = useCallback(
32
+ (bp: Breakpoint) => {
33
+ setActiveBreakpoint(bp)
34
+ setPreviewWidth(BREAKPOINTS[bp].width)
35
+ sendToInspector({
36
+ type: 'SET_BREAKPOINT',
37
+ payload: { width: BREAKPOINTS[bp].width },
38
+ })
39
+ },
40
+ [setActiveBreakpoint, setPreviewWidth, sendToInspector],
41
+ )
42
+
43
+ return (
44
+ <div className="flex items-center gap-1">
45
+ {breakpoints.map(([key, { label, width }]) => (
46
+ <button
47
+ key={key}
48
+ onClick={() => handleBreakpointChange(key)}
49
+ className="px-2 py-0.5 text-[11px] rounded transition-colors"
50
+ style={{
51
+ background:
52
+ activeBreakpoint === key ? 'var(--accent-bg)' : 'transparent',
53
+ color:
54
+ activeBreakpoint === key ? 'var(--accent)' : 'var(--text-muted)',
55
+ }}
56
+ title={`${label} (${width}px)`}
57
+ >
58
+ {label}
59
+ </button>
60
+ ))}
61
+ {/* Show matched device name + width for the active breakpoint */}
62
+ <span
63
+ className="ml-1 text-[10px] truncate max-w-[160px]"
64
+ style={{ color: 'var(--text-muted)' }}
65
+ >
66
+ {matchedDevice
67
+ ? `${matchedDevice.name} · ${matchedDevice.width}px`
68
+ : `${previewWidth}px`}
69
+ </span>
70
+ </div>
71
+ )
72
+ }