@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,90 @@
1
+ 'use client'
2
+
3
+ import { Suspense, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
6
+ import { usePostMessage } from '@/hooks/usePostMessage'
7
+ import { useBridge } from '@/hooks/useBridge'
8
+ import { TopBar } from './TopBar'
9
+ import { LeftPanel } from './left-panel/LeftPanel'
10
+ import { RightPanel } from './right-panel/RightPanel'
11
+ import { PreviewFrame } from './PreviewFrame'
12
+ import { ConnectModal } from './ConnectModal'
13
+ import { ErrorBoundary } from './common/ErrorBoundary'
14
+ import { ToastContainer } from './common/ToastContainer'
15
+
16
+ function PanelLoading() {
17
+ return (
18
+ <div
19
+ className="flex items-center justify-center h-full"
20
+ style={{ background: 'var(--bg-secondary)' }}
21
+ >
22
+ <div className="text-xs" style={{ color: 'var(--text-muted)' }}>
23
+ Loading...
24
+ </div>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ export function Editor() {
30
+ const targetUrl = useEditorStore((s) => s.targetUrl)
31
+ const connectionStatus = useEditorStore((s) => s.connectionStatus)
32
+ const rightPanelOpen = useEditorStore((s) => s.rightPanelOpen)
33
+ const leftPanelWidth = useEditorStore((s) => s.leftPanelWidth)
34
+ const rightPanelWidth = useEditorStore((s) => s.rightPanelWidth)
35
+ const { sendToInspector } = usePostMessage()
36
+
37
+ useKeyboardShortcuts()
38
+ useBridge()
39
+
40
+ // Hide iframe hover overlay when interacting with any panel outside the canvas
41
+ const hideHover = useCallback(() => {
42
+ sendToInspector({ type: 'HIDE_HOVER' })
43
+ }, [sendToInspector])
44
+
45
+ return (
46
+ <div
47
+ className="flex flex-col h-screen"
48
+ style={{ background: 'var(--bg-primary)' }}
49
+ >
50
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
51
+ <div onMouseDown={hideHover} onMouseEnter={hideHover}>
52
+ <TopBar />
53
+ </div>
54
+ <div className="flex flex-1 overflow-hidden">
55
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
56
+ <div onMouseDown={hideHover} onMouseEnter={hideHover} className="flex">
57
+ <ErrorBoundary panelName="Layers panel">
58
+ <Suspense fallback={<PanelLoading />}>
59
+ <LeftPanel width={leftPanelWidth} />
60
+ </Suspense>
61
+ </ErrorBoundary>
62
+ </div>
63
+ <div className="flex-1 min-w-0 relative">
64
+ <ErrorBoundary panelName="Preview">
65
+ <PreviewFrame />
66
+ </ErrorBoundary>
67
+ </div>
68
+ {rightPanelOpen && (
69
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
70
+ <div
71
+ onMouseDown={hideHover}
72
+ onMouseEnter={hideHover}
73
+ className="flex"
74
+ >
75
+ <ErrorBoundary panelName="Design panel">
76
+ <Suspense fallback={<PanelLoading />}>
77
+ <RightPanel width={rightPanelWidth} />
78
+ </Suspense>
79
+ </ErrorBoundary>
80
+ </div>
81
+ )}
82
+ </div>
83
+ <ToastContainer />
84
+ {(!targetUrl ||
85
+ ['confirming', 'scanning', 'connecting'].includes(
86
+ connectionStatus,
87
+ )) && <ConnectModal />}
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,208 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { sendViaIframe } from '@/hooks/usePostMessage'
6
+
7
+ export function PageSelector() {
8
+ const [isOpen, setIsOpen] = useState(false)
9
+ const dropdownRef = useRef<HTMLDivElement>(null)
10
+ const pageLinks = useEditorStore((s) => s.pageLinks)
11
+ const currentPagePath = useEditorStore((s) => s.currentPagePath)
12
+ const setCurrentPagePath = useEditorStore((s) => s.setCurrentPagePath)
13
+ const targetUrl = useEditorStore((s) => s.targetUrl)
14
+ const setConnectionStatus = useEditorStore((s) => s.setConnectionStatus)
15
+
16
+ // Close dropdown on outside click
17
+ useEffect(() => {
18
+ if (!isOpen) return
19
+ const handleClick = (e: MouseEvent) => {
20
+ if (
21
+ dropdownRef.current &&
22
+ !dropdownRef.current.contains(e.target as Node)
23
+ ) {
24
+ setIsOpen(false)
25
+ }
26
+ }
27
+ document.addEventListener('mousedown', handleClick)
28
+ return () => document.removeEventListener('mousedown', handleClick)
29
+ }, [isOpen])
30
+
31
+ const handleNavigate = useCallback(
32
+ (path: string) => {
33
+ if (!targetUrl) return
34
+ setCurrentPagePath(path)
35
+ sendViaIframe({ type: 'NAVIGATE_TO', payload: { path } })
36
+ setIsOpen(false)
37
+ },
38
+ [targetUrl, setCurrentPagePath],
39
+ )
40
+
41
+ const handleRefresh = useCallback(() => {
42
+ setConnectionStatus('connecting')
43
+ }, [setConnectionStatus])
44
+
45
+ // Build page list: always include "/" plus discovered links
46
+ const allPages = useRef<Array<{ href: string; text: string }>>([])
47
+ const seen = new Set<string>()
48
+ const pages: Array<{ href: string; text: string }> = []
49
+
50
+ // Always add root
51
+ pages.push({ href: '/', text: 'Home' })
52
+ seen.add('/')
53
+
54
+ // Add discovered links (deduplicated)
55
+ for (const link of pageLinks) {
56
+ if (!seen.has(link.href)) {
57
+ seen.add(link.href)
58
+ pages.push(link)
59
+ }
60
+ }
61
+ allPages.current = pages
62
+
63
+ return (
64
+ <div ref={dropdownRef} className="relative flex items-center gap-1">
65
+ {/* Page dropdown */}
66
+ <button
67
+ onClick={() => setIsOpen(!isOpen)}
68
+ className="flex items-center gap-1.5 px-2 py-1 text-[11px] rounded transition-colors"
69
+ style={{
70
+ color: 'var(--text-secondary)',
71
+ background: isOpen ? 'var(--bg-hover)' : 'transparent',
72
+ }}
73
+ title="Navigate to another page"
74
+ >
75
+ <span className="truncate max-w-[140px] font-medium">
76
+ {currentPagePath}
77
+ </span>
78
+ <svg
79
+ width="8"
80
+ height="8"
81
+ viewBox="0 0 8 8"
82
+ fill="currentColor"
83
+ style={{
84
+ transform: isOpen ? 'rotate(180deg)' : 'none',
85
+ transition: 'transform 0.15s',
86
+ }}
87
+ >
88
+ <path
89
+ d="M1 2.5L4 5.5L7 2.5"
90
+ stroke="currentColor"
91
+ strokeWidth="1.5"
92
+ fill="none"
93
+ strokeLinecap="round"
94
+ strokeLinejoin="round"
95
+ />
96
+ </svg>
97
+ </button>
98
+
99
+ {/* Refresh button */}
100
+ <button
101
+ onClick={handleRefresh}
102
+ className="flex items-center justify-center w-6 h-6 rounded transition-colors hover:bg-[var(--bg-hover)]"
103
+ style={{ color: 'var(--text-muted)' }}
104
+ title="Refresh page"
105
+ >
106
+ <svg
107
+ width="12"
108
+ height="12"
109
+ viewBox="0 0 16 16"
110
+ fill="none"
111
+ stroke="currentColor"
112
+ strokeWidth="1.5"
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ >
116
+ <path d="M1 1v5h5" />
117
+ <path d="M3.51 10a6 6 0 1 0 .49-5.5L1 6" />
118
+ </svg>
119
+ </button>
120
+
121
+ {/* Dropdown */}
122
+ {isOpen && (
123
+ <div
124
+ className="absolute top-full left-0 mt-1 w-60 max-h-72 overflow-y-auto rounded shadow-lg z-50"
125
+ style={{
126
+ background: 'var(--bg-secondary)',
127
+ border: '1px solid var(--border)',
128
+ }}
129
+ >
130
+ {/* Header */}
131
+ <div
132
+ className="px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider"
133
+ style={{
134
+ color: 'var(--text-muted)',
135
+ borderBottom: '1px solid var(--border)',
136
+ }}
137
+ >
138
+ Pages ({pages.length})
139
+ </div>
140
+
141
+ {pages.map((page, i) => {
142
+ const isActive = page.href === currentPagePath
143
+ return (
144
+ <button
145
+ key={page.href}
146
+ onClick={() => handleNavigate(page.href)}
147
+ className="w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2"
148
+ style={{
149
+ color: isActive ? 'var(--accent)' : 'var(--text-primary)',
150
+ background: isActive
151
+ ? 'rgba(74, 158, 255, 0.08)'
152
+ : 'transparent',
153
+ borderBottom:
154
+ i < pages.length - 1 ? '1px solid var(--border)' : 'none',
155
+ }}
156
+ title={page.href}
157
+ >
158
+ {/* Page icon */}
159
+ <svg
160
+ width="12"
161
+ height="12"
162
+ viewBox="0 0 16 16"
163
+ fill="none"
164
+ stroke={isActive ? 'var(--accent)' : 'var(--text-muted)'}
165
+ strokeWidth="1.5"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ className="flex-shrink-0"
169
+ >
170
+ <rect x="2" y="2" width="12" height="12" rx="1.5" />
171
+ <path d="M2 5.5h12" />
172
+ </svg>
173
+ <div className="truncate flex-1">
174
+ <div className="truncate font-medium">
175
+ {page.text || page.href}
176
+ </div>
177
+ {page.text && page.href !== page.text && (
178
+ <div
179
+ className="truncate"
180
+ style={{ color: 'var(--text-muted)', fontSize: '10px' }}
181
+ >
182
+ {page.href}
183
+ </div>
184
+ )}
185
+ </div>
186
+ {isActive && (
187
+ <div
188
+ className="w-1.5 h-1.5 rounded-full flex-shrink-0"
189
+ style={{ background: 'var(--accent)' }}
190
+ />
191
+ )}
192
+ </button>
193
+ )
194
+ })}
195
+
196
+ {pages.length <= 1 && (
197
+ <div
198
+ className="px-3 py-2 text-[10px] text-center"
199
+ style={{ color: 'var(--text-muted)' }}
200
+ >
201
+ No additional pages found
202
+ </div>
203
+ )}
204
+ </div>
205
+ )}
206
+ </div>
207
+ )
208
+ }
@@ -0,0 +1,299 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useCallback, useState } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { usePostMessage, isEditorOnLocalhost } from '@/hooks/usePostMessage'
6
+ import {
7
+ PREVIEW_WIDTH_MIN,
8
+ PREVIEW_WIDTH_MAX,
9
+ PROXY_HEADER,
10
+ } from '@/lib/constants'
11
+ import { ResponsiveToolbar } from './ResponsiveToolbar'
12
+
13
+ /**
14
+ * Build the proxy URL for the iframe. Routes through /api/proxy/
15
+ * so the proxy can inject the inspector script and strip security
16
+ * headers (COEP, CSP, X-Frame-Options) that would block the editor.
17
+ * Used only when the editor runs on localhost.
18
+ */
19
+ function buildProxyUrl(targetUrl: string, pagePath: string): string {
20
+ const path = pagePath === '/' ? '' : pagePath
21
+ const encoded = encodeURIComponent(targetUrl)
22
+ return `/api/proxy${path}?${PROXY_HEADER}=${encoded}`
23
+ }
24
+
25
+ /**
26
+ * Build the bridge proxy URL for the iframe. Routes through the bridge server
27
+ * running on the user's machine. The bridge injects the inspector script
28
+ * and strips security headers, just like the local proxy.
29
+ * Used when the editor is deployed remotely and a bridge is connected.
30
+ */
31
+ function buildBridgeUrl(
32
+ bridgeUrl: string,
33
+ targetUrl: string,
34
+ pagePath: string,
35
+ ): string {
36
+ const path = pagePath === '/' ? '' : pagePath
37
+ const encoded = encodeURIComponent(targetUrl)
38
+ return `${bridgeUrl}${path}?${PROXY_HEADER}=${encoded}`
39
+ }
40
+
41
+ /**
42
+ * Build the direct URL for the iframe. Loads the target page directly
43
+ * without the proxy. Used when the editor is deployed remotely (e.g. Vercel)
44
+ * and can't proxy to the user's localhost.
45
+ * Requires the user to manually add the inspector script tag to their project.
46
+ */
47
+ function buildDirectUrl(targetUrl: string, pagePath: string): string {
48
+ const path = pagePath === '/' ? '' : pagePath
49
+ return `${targetUrl}${path}`
50
+ }
51
+
52
+ /**
53
+ * Build the appropriate iframe URL based on whether the editor is local,
54
+ * has a bridge connection, or is remote without bridge.
55
+ */
56
+ function buildIframeUrl(targetUrl: string, pagePath: string): string {
57
+ if (isEditorOnLocalhost()) {
58
+ return buildProxyUrl(targetUrl, pagePath)
59
+ }
60
+ // Check for bridge connection
61
+ const bridgeUrl = useEditorStore.getState().bridgeUrl
62
+ if (bridgeUrl) {
63
+ return buildBridgeUrl(bridgeUrl, targetUrl, pagePath)
64
+ }
65
+ return buildDirectUrl(targetUrl, pagePath)
66
+ }
67
+
68
+ export function PreviewFrame() {
69
+ const targetUrl = useEditorStore((s) => s.targetUrl)
70
+ const connectionStatus = useEditorStore((s) => s.connectionStatus)
71
+ const previewWidth = useEditorStore((s) => s.previewWidth)
72
+ const setPreviewWidth = useEditorStore((s) => s.setPreviewWidth)
73
+ const currentPagePath = useEditorStore((s) => s.currentPagePath)
74
+ const setConnectionStatus = useEditorStore((s) => s.setConnectionStatus)
75
+ const viewMode = useEditorStore((s) => s.viewMode)
76
+ const { iframeRef, sendToInspector } = usePostMessage()
77
+ const containerRef = useRef<HTMLDivElement>(null)
78
+ const lastSrcRef = useRef<string | null>(null)
79
+ const [isDragging, setIsDragging] = useState(false)
80
+
81
+ // Handle initial connection — load through proxy
82
+ useEffect(() => {
83
+ if (!targetUrl || connectionStatus !== 'connecting') return
84
+
85
+ const iframe = iframeRef.current
86
+ if (!iframe) return
87
+
88
+ const newSrc = buildIframeUrl(targetUrl, currentPagePath)
89
+
90
+ if (lastSrcRef.current !== newSrc) {
91
+ lastSrcRef.current = newSrc
92
+ iframe.src = newSrc
93
+ }
94
+
95
+ const handleError = () => {
96
+ setConnectionStatus('disconnected')
97
+ }
98
+
99
+ iframe.addEventListener('error', handleError)
100
+
101
+ return () => {
102
+ iframe.removeEventListener('error', handleError)
103
+ }
104
+ }, [
105
+ targetUrl,
106
+ connectionStatus,
107
+ currentPagePath,
108
+ iframeRef,
109
+ setConnectionStatus,
110
+ ])
111
+
112
+ // Handle page navigation when already connected
113
+ useEffect(() => {
114
+ if (!targetUrl || connectionStatus !== 'connected') return
115
+
116
+ const iframe = iframeRef.current
117
+ if (!iframe) return
118
+
119
+ const newSrc = buildIframeUrl(targetUrl, currentPagePath)
120
+
121
+ if (lastSrcRef.current !== newSrc) {
122
+ lastSrcRef.current = newSrc
123
+ iframe.src = newSrc
124
+ }
125
+ }, [targetUrl, connectionStatus, currentPagePath, iframeRef])
126
+
127
+ // Preview mode — stay on proxy URL but disable inspector overlays.
128
+ // Previously this switched to the direct URL for full JS interactivity,
129
+ // but that breaks when the target page embeds the external inspector script
130
+ // (e.g. from Vercel): the external inspector starts with selection ON and
131
+ // can't receive SET_SELECTION_MODE:false due to cross-origin postMessage
132
+ // restrictions. Keeping the proxy URL ensures the only inspector running
133
+ // is the proxy-injected one (same-origin, fully controllable).
134
+ // The proxy already preserves enough scripts for most apps (Expo/RN Web
135
+ // bundles load fine through the proxy).
136
+ useEffect(() => {
137
+ if (!targetUrl || connectionStatus !== 'connected') return
138
+
139
+ // Selection mode is managed by TopBar via sendToInspector.
140
+ // When exiting preview, TopBar re-enables selection and the proxy
141
+ // iframe is still loaded — no reload needed.
142
+ }, [viewMode, targetUrl, connectionStatus, currentPagePath, iframeRef])
143
+
144
+ // Drag resize logic — symmetric from center
145
+ const dragStateRef = useRef<{
146
+ startX: number
147
+ startWidth: number
148
+ side: 'left' | 'right'
149
+ } | null>(null)
150
+
151
+ const handleDragStart = useCallback(
152
+ (e: React.MouseEvent, side: 'left' | 'right') => {
153
+ e.preventDefault()
154
+ dragStateRef.current = {
155
+ startX: e.clientX,
156
+ startWidth: previewWidth,
157
+ side,
158
+ }
159
+ setIsDragging(true)
160
+ },
161
+ [previewWidth],
162
+ )
163
+
164
+ useEffect(() => {
165
+ if (!isDragging) return
166
+
167
+ const handleMouseMove = (e: MouseEvent) => {
168
+ const state = dragStateRef.current
169
+ if (!state) return
170
+ const delta = e.clientX - state.startX
171
+ // Symmetric: dragging right handle right = wider, left handle left = wider
172
+ const direction = state.side === 'right' ? 1 : -1
173
+ const newWidth = Math.round(state.startWidth + delta * direction * 2)
174
+ const clamped = Math.min(
175
+ Math.max(newWidth, PREVIEW_WIDTH_MIN),
176
+ PREVIEW_WIDTH_MAX,
177
+ )
178
+ setPreviewWidth(clamped)
179
+ sendToInspector({ type: 'SET_BREAKPOINT', payload: { width: clamped } })
180
+ }
181
+
182
+ const handleMouseUp = () => {
183
+ dragStateRef.current = null
184
+ setIsDragging(false)
185
+ }
186
+
187
+ document.addEventListener('mousemove', handleMouseMove)
188
+ document.addEventListener('mouseup', handleMouseUp)
189
+ return () => {
190
+ document.removeEventListener('mousemove', handleMouseMove)
191
+ document.removeEventListener('mouseup', handleMouseUp)
192
+ }
193
+ }, [isDragging, setPreviewWidth, sendToInspector])
194
+
195
+ // Check if preview fills container (no handles needed)
196
+ const containerWidth = containerRef.current?.clientWidth ?? 0
197
+ const isFullWidth = previewWidth >= containerWidth && containerWidth > 0
198
+
199
+ if (!targetUrl) {
200
+ return (
201
+ <div
202
+ className="flex items-center justify-center h-full"
203
+ style={{ background: 'var(--bg-primary)' }}
204
+ >
205
+ <div className="text-center">
206
+ <div
207
+ className="text-lg font-medium mb-2"
208
+ style={{ color: 'var(--text-secondary)' }}
209
+ >
210
+ No project connected
211
+ </div>
212
+ <div className="text-sm" style={{ color: 'var(--text-muted)' }}>
213
+ Enter a localhost URL above to get started
214
+ </div>
215
+ </div>
216
+ </div>
217
+ )
218
+ }
219
+
220
+ const showHandles = !isFullWidth && connectionStatus === 'connected'
221
+
222
+ return (
223
+ <div
224
+ className="flex flex-col h-full"
225
+ style={{ background: 'var(--bg-primary)' }}
226
+ >
227
+ {connectionStatus === 'connected' && <ResponsiveToolbar />}
228
+
229
+ <div
230
+ ref={containerRef}
231
+ className="flex items-start justify-center flex-1 overflow-auto relative"
232
+ style={{ padding: '0' }}
233
+ >
234
+ {/* Left drag handle */}
235
+ {showHandles && (
236
+ <div
237
+ onMouseDown={(e) => handleDragStart(e, 'left')}
238
+ className="absolute top-0 bottom-0 z-10 flex items-center justify-center"
239
+ style={{
240
+ width: 6,
241
+ left: `calc(50% - ${previewWidth / 2}px - 6px)`,
242
+ cursor: 'col-resize',
243
+ }}
244
+ >
245
+ <div
246
+ className="w-1 rounded-full transition-colors"
247
+ style={{
248
+ height: 40,
249
+ background: isDragging ? 'var(--accent)' : 'var(--border)',
250
+ }}
251
+ />
252
+ </div>
253
+ )}
254
+
255
+ <div
256
+ className="h-full mx-auto"
257
+ style={{
258
+ width: isFullWidth ? '100%' : previewWidth,
259
+ maxWidth: '100%',
260
+ transition: isDragging ? 'none' : 'width 0.2s ease',
261
+ }}
262
+ >
263
+ <iframe
264
+ ref={iframeRef}
265
+ className="w-full h-full border-0"
266
+ style={{
267
+ background: '#fff',
268
+ pointerEvents: isDragging ? 'none' : 'auto',
269
+ borderLeft: !isFullWidth ? '1px solid var(--border)' : 'none',
270
+ borderRight: !isFullWidth ? '1px solid var(--border)' : 'none',
271
+ }}
272
+ title="Preview"
273
+ />
274
+ </div>
275
+
276
+ {/* Right drag handle */}
277
+ {showHandles && (
278
+ <div
279
+ onMouseDown={(e) => handleDragStart(e, 'right')}
280
+ className="absolute top-0 bottom-0 z-10 flex items-center justify-center"
281
+ style={{
282
+ width: 6,
283
+ right: `calc(50% - ${previewWidth / 2}px - 6px)`,
284
+ cursor: 'col-resize',
285
+ }}
286
+ >
287
+ <div
288
+ className="w-1 rounded-full transition-colors"
289
+ style={{
290
+ height: 40,
291
+ background: isDragging ? 'var(--accent)' : 'var(--border)',
292
+ }}
293
+ />
294
+ </div>
295
+ )}
296
+ </div>
297
+ </div>
298
+ )
299
+ }
@@ -0,0 +1,91 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+
6
+ export function ProjectFolderBanner() {
7
+ const connectionStatus = useEditorStore((s) => s.connectionStatus)
8
+ const targetUrl = useEditorStore((s) => s.targetUrl)
9
+ const portRoots = useEditorStore((s) => s.portRoots)
10
+ const scanStatus = useEditorStore((s) => s.scanStatus)
11
+ const scanError = useEditorStore((s) => s.scanError)
12
+ const scannedProjectName = useEditorStore((s) => s.scannedProjectName)
13
+ const componentFileMap = useEditorStore((s) => s.componentFileMap)
14
+
15
+ const [dismissed, setDismissed] = useState(false)
16
+
17
+ const hasProjectRoot = targetUrl ? !!portRoots[targetUrl] : false
18
+
19
+ // Don't show if not connected or dismissed
20
+ if (connectionStatus !== 'connected' || dismissed) return null
21
+
22
+ // Show scan results feedback if project root is set
23
+ if (hasProjectRoot && scanStatus === 'complete' && componentFileMap) {
24
+ const count = Object.keys(componentFileMap).length
25
+ return (
26
+ <div
27
+ className="flex items-center justify-between px-3 py-1.5 text-xs"
28
+ style={{
29
+ background:
30
+ count > 0 ? 'rgba(74, 222, 128, 0.08)' : 'rgba(251, 191, 36, 0.08)',
31
+ borderBottom: '1px solid var(--border)',
32
+ color: count > 0 ? 'var(--success)' : 'var(--warning)',
33
+ }}
34
+ >
35
+ <span>
36
+ {count > 0
37
+ ? `Found ${count} components in ${scannedProjectName || 'project'}`
38
+ : `No component files found — check project folder`}
39
+ </span>
40
+ <button
41
+ onClick={() => setDismissed(true)}
42
+ className="ml-2 px-1.5 py-0.5 rounded text-[10px] hover:bg-[var(--bg-hover)] transition-colors"
43
+ style={{ color: 'var(--text-muted)' }}
44
+ >
45
+ Dismiss
46
+ </button>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ // Show scan error
52
+ if (hasProjectRoot && scanStatus === 'error') {
53
+ return (
54
+ <div
55
+ className="flex items-center justify-between px-3 py-1.5 text-xs"
56
+ style={{
57
+ background: 'rgba(248, 113, 113, 0.08)',
58
+ borderBottom: '1px solid var(--border)',
59
+ color: 'var(--error)',
60
+ }}
61
+ >
62
+ <span>{scanError || 'Scan failed'}</span>
63
+ <button
64
+ onClick={() => setDismissed(true)}
65
+ className="ml-2 px-1.5 py-0.5 rounded text-[10px] hover:bg-[var(--bg-hover)] transition-colors"
66
+ style={{ color: 'var(--text-muted)' }}
67
+ >
68
+ Dismiss
69
+ </button>
70
+ </div>
71
+ )
72
+ }
73
+
74
+ // Show scanning indicator
75
+ if (hasProjectRoot && scanStatus === 'scanning') {
76
+ return (
77
+ <div
78
+ className="flex items-center px-3 py-1.5 text-xs"
79
+ style={{
80
+ background: 'rgba(74, 158, 255, 0.06)',
81
+ borderBottom: '1px solid var(--border)',
82
+ color: 'var(--text-secondary)',
83
+ }}
84
+ >
85
+ Scanning project for components...
86
+ </div>
87
+ )
88
+ }
89
+
90
+ return null
91
+ }