@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,76 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { performUndo, performRedo } from './useChangeTracker'
6
+
7
+ /**
8
+ * Global keyboard shortcuts for the editor.
9
+ *
10
+ * - Escape: Deselect current element
11
+ * - Cmd+Z / Ctrl+Z: Undo last style change
12
+ * - Cmd+Shift+Z / Ctrl+Shift+Z: Redo last undone change
13
+ * - Cmd+[ / Ctrl+[: Toggle left panel
14
+ * - Cmd+] / Ctrl+]: Toggle right panel
15
+ */
16
+ export function useKeyboardShortcuts() {
17
+ const clearSelection = useEditorStore((s) => s.clearSelection)
18
+ const toggleLeftPanel = useEditorStore((s) => s.toggleLeftPanel)
19
+ const toggleRightPanel = useEditorStore((s) => s.toggleRightPanel)
20
+
21
+ useEffect(() => {
22
+ const handler = (e: KeyboardEvent) => {
23
+ // Cmd+Z / Ctrl+Z — undo (works even when focused on inputs)
24
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
25
+ e.preventDefault()
26
+ performUndo()
27
+ return
28
+ }
29
+
30
+ // Cmd+Shift+Z / Ctrl+Shift+Z — redo (works even when focused on inputs)
31
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
32
+ e.preventDefault()
33
+ performRedo()
34
+ return
35
+ }
36
+
37
+ // Don't intercept other shortcuts when typing in inputs
38
+ const target = e.target as HTMLElement
39
+ if (
40
+ target.tagName === 'INPUT' ||
41
+ target.tagName === 'TEXTAREA' ||
42
+ target.tagName === 'SELECT' ||
43
+ target.isContentEditable
44
+ ) {
45
+ // Escape should still work to blur inputs
46
+ if (e.key === 'Escape') {
47
+ target.blur()
48
+ }
49
+ return
50
+ }
51
+
52
+ // Escape — deselect element
53
+ if (e.key === 'Escape') {
54
+ clearSelection()
55
+ return
56
+ }
57
+
58
+ // Cmd+[ or Ctrl+[ — toggle left panel
59
+ if ((e.metaKey || e.ctrlKey) && e.key === '[') {
60
+ e.preventDefault()
61
+ toggleLeftPanel()
62
+ return
63
+ }
64
+
65
+ // Cmd+] or Ctrl+] — toggle right panel
66
+ if ((e.metaKey || e.ctrlKey) && e.key === ']') {
67
+ e.preventDefault()
68
+ toggleRightPanel()
69
+ return
70
+ }
71
+ }
72
+
73
+ window.addEventListener('keydown', handler)
74
+ return () => window.removeEventListener('keydown', handler)
75
+ }, [clearSelection, toggleLeftPanel, toggleRightPanel])
76
+ }
@@ -0,0 +1,589 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import type {
6
+ InspectorToEditorMessage,
7
+ EditorToInspectorMessage,
8
+ } from '@/types/messages'
9
+ import { generateId } from '@/lib/utils'
10
+ import { buildTailwindClassMap } from '@/lib/tailwindClassParser'
11
+ import { getApiBase } from '@/lib/apiBase'
12
+
13
+ function isAllowedOrigin(origin: string): boolean {
14
+ try {
15
+ const url = new URL(origin)
16
+ // Always allow localhost/127.0.0.1 (primary use case)
17
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1')
18
+ return true
19
+ // Allow the editor's own origin (same-origin messages, e.g. proxy mode)
20
+ if (typeof window !== 'undefined' && origin === window.location.origin)
21
+ return true
22
+ // Also allow if origin matches the current target URL (for live site editing)
23
+ const targetUrl = useEditorStore.getState().targetUrl
24
+ if (targetUrl) {
25
+ const targetOrigin = new URL(targetUrl).origin
26
+ if (origin === targetOrigin) return true
27
+ }
28
+ return false
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ // Module-level shared iframe ref — ensures all callers of usePostMessage()
35
+ // share the same ref. PreviewFrame assigns it to the DOM element, and other
36
+ // components (DragModeToggle, etc.) can send messages through it.
37
+ const sharedIframeRef: React.MutableRefObject<HTMLIFrameElement | null> = {
38
+ current: null,
39
+ }
40
+
41
+ // Singleton message listener — registered once to prevent duplicate handlers
42
+ // when multiple components call usePostMessage(). Uses useEditorStore.getState()
43
+ // so it always reads fresh state without stale closures.
44
+ let listenerRegistered = false
45
+ let heartbeatResolve: (() => void) | null = null
46
+ let componentRescanTimer: ReturnType<typeof setTimeout> | null = null
47
+
48
+ /**
49
+ * Returns true when the editor is running on localhost (proxy mode).
50
+ * When false, the editor is deployed remotely and must use direct iframe loading.
51
+ */
52
+ export function isEditorOnLocalhost(): boolean {
53
+ const h = window.location.hostname
54
+ return h === 'localhost' || h === '127.0.0.1'
55
+ }
56
+
57
+ export function sendViaIframe(message: EditorToInspectorMessage) {
58
+ const iframe = sharedIframeRef.current
59
+ if (!iframe?.contentWindow) return
60
+ // Detect the iframe's actual origin by trying same-origin access.
61
+ // This correctly handles all modes without relying on store state
62
+ // (which may not yet reflect the iframe's actual URL):
63
+ // - Proxy mode (same-origin): contentWindow.location.origin succeeds
64
+ // - Preview/direct mode (cross-origin): throws, fall back to target origin or '*'
65
+ // - Bridge mode (cross-origin): throws, fall back to bridge or target origin
66
+ // Note: postMessage silently drops messages on origin mismatch (no throw),
67
+ // so we must determine the correct origin upfront.
68
+ let targetOrigin: string
69
+ try {
70
+ // Same-origin: can read iframe's location directly
71
+ targetOrigin = iframe.contentWindow.location.origin
72
+ } catch {
73
+ // Cross-origin: fall back based on configuration
74
+ const store = useEditorStore.getState()
75
+ const bridgeUrl = store.bridgeUrl
76
+ if (bridgeUrl) {
77
+ try {
78
+ targetOrigin = new URL(bridgeUrl).origin
79
+ } catch {
80
+ targetOrigin = '*'
81
+ }
82
+ } else if (store.targetUrl) {
83
+ try {
84
+ targetOrigin = new URL(store.targetUrl).origin
85
+ } catch {
86
+ targetOrigin = '*'
87
+ }
88
+ } else {
89
+ targetOrigin = '*'
90
+ }
91
+ }
92
+ iframe.contentWindow.postMessage(message, targetOrigin)
93
+ }
94
+
95
+ function handleMessage(event: MessageEvent) {
96
+ if (!isAllowedOrigin(event.origin)) return
97
+ const msg = event.data as InspectorToEditorMessage
98
+ if (!msg || !msg.type) return
99
+
100
+ const store = useEditorStore.getState()
101
+
102
+ switch (msg.type) {
103
+ case 'INSPECTOR_READY': {
104
+ store.setConnectionStatus('connected')
105
+ store.clearConsole()
106
+ // Re-sync selection mode — the fresh inspector defaults to selectionModeEnabled=true,
107
+ // but if the editor is in preview mode (or selection is toggled off), we need to
108
+ // tell the inspector immediately so clicks pass through for navigation.
109
+ const effectiveSelection = store.viewMode ? false : store.selectionMode
110
+ sendViaIframe({
111
+ type: 'SET_SELECTION_MODE',
112
+ payload: { enabled: effectiveSelection },
113
+ })
114
+ sendViaIframe({ type: 'REQUEST_DOM_TREE' })
115
+ sendViaIframe({ type: 'REQUEST_PAGE_LINKS' })
116
+ sendViaIframe({ type: 'REQUEST_CSS_VARIABLES' })
117
+ setTimeout(function () {
118
+ sendViaIframe({ type: 'REQUEST_COMPONENTS', payload: {} })
119
+ }, 500)
120
+
121
+ // Re-apply persisted changes to the iframe after a fresh load/refresh.
122
+ // The store already has them (loaded via useChangeTracker), but the
123
+ // iframe DOM is fresh — so we need to replay every PREVIEW_CHANGE.
124
+ const persisted = useEditorStore.getState().styleChanges
125
+ if (persisted.length > 0) {
126
+ for (const change of persisted) {
127
+ if (change.property === '__element_deleted__') {
128
+ // Re-hide deleted elements by sending DELETE_ELEMENT (without re-tracking,
129
+ // the inspector will send ELEMENT_DELETED but we deduplicate below)
130
+ sendViaIframe({
131
+ type: 'DELETE_ELEMENT',
132
+ payload: { selectorPath: change.elementSelector },
133
+ })
134
+ continue
135
+ }
136
+ if (
137
+ change.property === '__text_content__' ||
138
+ change.property === '__component_creation__' ||
139
+ change.property === '__element_inserted__' ||
140
+ change.property === '__element_moved__'
141
+ )
142
+ continue
143
+ sendViaIframe({
144
+ type: 'PREVIEW_CHANGE',
145
+ payload: {
146
+ selectorPath: change.elementSelector,
147
+ property: change.property,
148
+ value: change.newValue,
149
+ },
150
+ })
151
+ }
152
+ }
153
+ break
154
+ }
155
+
156
+ case 'ELEMENT_SELECTED': {
157
+ // Check for tracked changes on this element BEFORE updating the store.
158
+ // When re-selecting an element, the inspector sends fresh computedStyles
159
+ // that don't reflect previously applied inline changes (the inline styles
160
+ // may have been lost to DOM re-renders). We merge tracked change values
161
+ // into computedStyles so the store gets correct values in a single update,
162
+ // and re-apply the inline styles to the iframe DOM.
163
+ const trackedChanges = store.styleChanges.filter(
164
+ (c) => c.elementSelector === msg.payload.selectorPath,
165
+ )
166
+ if (trackedChanges.length > 0) {
167
+ const merged = { ...msg.payload.computedStyles }
168
+ for (const change of trackedChanges) {
169
+ merged[change.property] = change.newValue
170
+ sendViaIframe({
171
+ type: 'PREVIEW_CHANGE',
172
+ payload: {
173
+ selectorPath: msg.payload.selectorPath,
174
+ property: change.property,
175
+ value: change.newValue,
176
+ },
177
+ })
178
+ }
179
+ msg.payload.computedStyles = merged
180
+ }
181
+ store.selectElement(msg.payload)
182
+ store.expandToNode(msg.payload.selectorPath)
183
+ store.setCSSVariableUsages(msg.payload.cssVariableUsages || {})
184
+ // Build Tailwind class → CSS property → variable mapping
185
+ const twMap = buildTailwindClassMap(
186
+ msg.payload.className,
187
+ store.cssVariableDefinitions,
188
+ )
189
+ store.setTailwindClassMap(twMap)
190
+ store.setActiveRightTab('design')
191
+ break
192
+ }
193
+
194
+ case 'CSS_VARIABLES': {
195
+ store.setCSSVariableDefinitions(
196
+ msg.payload.definitions,
197
+ msg.payload.isExplicit,
198
+ msg.payload.scopes,
199
+ )
200
+ const varCount = Object.keys(msg.payload.definitions).length
201
+ const csProjectRoot = useEditorStore.getState().projectRoot
202
+
203
+ // If the inspector found no variables, try scanning the project folder
204
+ if (varCount === 0 && csProjectRoot) {
205
+ fetch(`${getApiBase()}/api/project-scan/css-variables`, {
206
+ method: 'POST',
207
+ headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ projectRoot: csProjectRoot }),
209
+ })
210
+ .then((res) => (res.ok ? res.json() : null))
211
+ .then((data) => {
212
+ if (data?.definitions && Object.keys(data.definitions).length > 0) {
213
+ useEditorStore
214
+ .getState()
215
+ .setCSSVariableDefinitions(data.definitions, false)
216
+ }
217
+ })
218
+ .catch(() => {
219
+ /* ignore scan failures */
220
+ })
221
+ }
222
+
223
+ // Also try Tailwind config parser to supplement with theme colors
224
+ if (csProjectRoot) {
225
+ fetch(`${getApiBase()}/api/project-scan/tailwind-config`, {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ projectRoot: csProjectRoot }),
229
+ })
230
+ .then((res) => (res.ok ? res.json() : null))
231
+ .then((data) => {
232
+ if (data?.definitions && Object.keys(data.definitions).length > 0) {
233
+ // Merge with existing definitions (don't overwrite runtime-scanned vars)
234
+ const current = useEditorStore.getState().cssVariableDefinitions
235
+ const merged = { ...data.definitions, ...current }
236
+ useEditorStore.getState().setCSSVariableDefinitions(merged, false)
237
+ }
238
+ })
239
+ .catch(() => {
240
+ /* ignore tailwind config scan failures */
241
+ })
242
+ }
243
+ break
244
+ }
245
+
246
+ case 'ELEMENT_HOVERED':
247
+ store.setHighlightedNodeId(msg.payload.selectorPath)
248
+ break
249
+
250
+ case 'DOM_TREE':
251
+ store.setRootNode(msg.payload.tree)
252
+ break
253
+
254
+ case 'DOM_UPDATED':
255
+ store.setRootNode(msg.payload.tree)
256
+ if (msg.payload.removedSelectors.length > 0) {
257
+ const currentSelector = store.selectorPath
258
+ if (
259
+ currentSelector &&
260
+ msg.payload.removedSelectors.includes(currentSelector)
261
+ ) {
262
+ store.clearSelection()
263
+ }
264
+ }
265
+ // Debounced component rescan on DOM changes (2s to avoid
266
+ // excessive scanning during rapid DOM mutations)
267
+ if (componentRescanTimer) clearTimeout(componentRescanTimer)
268
+ componentRescanTimer = setTimeout(function () {
269
+ componentRescanTimer = null
270
+ sendViaIframe({ type: 'REQUEST_COMPONENTS', payload: {} })
271
+ }, 2000)
272
+ break
273
+
274
+ case 'HEARTBEAT_RESPONSE':
275
+ if (heartbeatResolve) {
276
+ heartbeatResolve()
277
+ heartbeatResolve = null
278
+ }
279
+ break
280
+
281
+ case 'PAGE_LINKS':
282
+ store.setPageLinks(msg.payload.links)
283
+ break
284
+
285
+ case 'COMPONENTS_DETECTED':
286
+ store.setDetectedComponents(msg.payload.components)
287
+ break
288
+
289
+ case 'VARIANT_APPLIED':
290
+ if (msg.payload.selectorPath === store.selectorPath) {
291
+ store.updateComputedStyles(msg.payload.computedStyles)
292
+ store.setCSSVariableUsages(msg.payload.cssVariableUsages)
293
+ }
294
+ break
295
+
296
+ case 'PAGE_NAVIGATE':
297
+ store.setCurrentPagePath(msg.payload.path)
298
+ store.clearSelection()
299
+ store.clearComponents()
300
+ break
301
+
302
+ case 'CONSOLE_MESSAGE':
303
+ store.addConsoleEntry(msg.payload)
304
+ break
305
+
306
+ case 'RECURSIVE_EMBED_DETECTED': {
307
+ // The iframe loaded pAInt's own page instead of the target.
308
+ // This happens when the navigation blocker failed to intercept a
309
+ // programmatic navigation after history.replaceState. Reload the iframe
310
+ // with the correct proxy URL to recover.
311
+ console.warn(
312
+ '[pAInt] Recursive embed detected — reloading iframe through proxy',
313
+ )
314
+ const iframe = sharedIframeRef.current
315
+ const recoverTarget = store.targetUrl
316
+ if (iframe && recoverTarget) {
317
+ const encoded = encodeURIComponent(recoverTarget)
318
+ const pagePath =
319
+ store.currentPagePath === '/' ? '' : store.currentPagePath
320
+ iframe.src = `/api/proxy${pagePath}?x-dev-editor-target=${encoded}`
321
+ }
322
+ break
323
+ }
324
+
325
+ case 'TEXT_CHANGED': {
326
+ const { selectorPath: textSelector, originalText, newText } = msg.payload
327
+ const textProperty = '__text_content__'
328
+
329
+ // Check if a text change already exists for this element (dedup)
330
+ const existingText = store.styleChanges.find(
331
+ (c) =>
332
+ c.elementSelector === textSelector && c.property === textProperty,
333
+ )
334
+
335
+ // Push undo action
336
+ store.pushUndo({
337
+ elementSelector: textSelector,
338
+ property: textProperty,
339
+ beforeValue: existingText ? existingText.newValue : originalText,
340
+ afterValue: newText,
341
+ breakpoint: store.activeBreakpoint,
342
+ wasNewChange: !existingText,
343
+ changeScope: store.changeScope,
344
+ })
345
+
346
+ // Save element snapshot
347
+ const textEl = store.selectorPath === textSelector ? store : null
348
+ store.saveElementSnapshot({
349
+ selectorPath: textSelector,
350
+ tagName: textEl?.tagName || 'unknown',
351
+ className: textEl?.className ?? null,
352
+ elementId: textEl?.elementId ?? null,
353
+ attributes: textEl?.attributes || {},
354
+ innerText: newText,
355
+ computedStyles: textEl?.computedStyles
356
+ ? { ...textEl.computedStyles }
357
+ : {},
358
+ pagePath: store.currentPagePath,
359
+ changeScope: store.changeScope,
360
+ sourceInfo: textEl?.sourceInfo ?? null,
361
+ })
362
+
363
+ // Add style change with sentinel property
364
+ store.addStyleChange({
365
+ id: generateId(),
366
+ elementSelector: textSelector,
367
+ property: textProperty,
368
+ originalValue: originalText,
369
+ newValue: newText,
370
+ breakpoint: store.activeBreakpoint,
371
+ timestamp: Date.now(),
372
+ changeScope: store.changeScope,
373
+ })
374
+ break
375
+ }
376
+
377
+ case 'ELEMENT_INSERTED': {
378
+ const {
379
+ selectorPath: insSelector,
380
+ parentSelectorPath: insParent,
381
+ tagName: insTag,
382
+ insertionIndex: insIndex,
383
+ placeholderText: insText,
384
+ defaultStyles: insDefaultStyles,
385
+ } = msg.payload
386
+ const insProperty = '__element_inserted__'
387
+
388
+ // Push undo action
389
+ store.pushUndo({
390
+ elementSelector: insSelector,
391
+ property: insProperty,
392
+ beforeValue: '',
393
+ afterValue: 'inserted',
394
+ breakpoint: store.activeBreakpoint,
395
+ wasNewChange: true,
396
+ changeScope: store.changeScope,
397
+ })
398
+
399
+ // Save element snapshot with default styles as computedStyles
400
+ store.saveElementSnapshot({
401
+ selectorPath: insSelector,
402
+ tagName: insTag,
403
+ className: null,
404
+ elementId: null,
405
+ attributes: {},
406
+ innerText: insText || null,
407
+ computedStyles: insDefaultStyles ? { ...insDefaultStyles } : {},
408
+ pagePath: store.currentPagePath,
409
+ changeScope: store.changeScope,
410
+ sourceInfo: null,
411
+ })
412
+
413
+ // Track the insertion
414
+ store.addStyleChange({
415
+ id: generateId(),
416
+ elementSelector: insSelector,
417
+ property: insProperty,
418
+ originalValue: `parent:${insParent}|index:${insIndex}`,
419
+ newValue: 'inserted',
420
+ breakpoint: store.activeBreakpoint,
421
+ timestamp: Date.now(),
422
+ changeScope: store.changeScope,
423
+ })
424
+
425
+ // Record each default style as an individual style change
426
+ if (insDefaultStyles) {
427
+ const insTimestamp = Date.now()
428
+ for (const [prop, val] of Object.entries(insDefaultStyles)) {
429
+ store.addStyleChange({
430
+ id: generateId(),
431
+ elementSelector: insSelector,
432
+ property: prop,
433
+ originalValue: '',
434
+ newValue: val,
435
+ breakpoint: store.activeBreakpoint,
436
+ timestamp: insTimestamp,
437
+ changeScope: store.changeScope,
438
+ })
439
+ }
440
+ }
441
+ break
442
+ }
443
+
444
+ case 'ELEMENT_MOVED': {
445
+ const {
446
+ selectorPath: mvOldSelector,
447
+ newSelectorPath: mvNewSelector,
448
+ oldParentSelectorPath: mvOldParent,
449
+ newParentSelectorPath: mvNewParent,
450
+ oldIndex: mvOldIndex,
451
+ newIndex: mvNewIndex,
452
+ } = msg.payload
453
+ const mvProperty = '__element_moved__'
454
+
455
+ // Push undo action
456
+ store.pushUndo({
457
+ elementSelector: mvNewSelector,
458
+ property: mvProperty,
459
+ beforeValue: `parent:${mvOldParent}|index:${mvOldIndex}|selector:${mvOldSelector}`,
460
+ afterValue: `parent:${mvNewParent}|index:${mvNewIndex}`,
461
+ breakpoint: store.activeBreakpoint,
462
+ wasNewChange: true,
463
+ changeScope: store.changeScope,
464
+ })
465
+
466
+ // Track the move
467
+ store.addStyleChange({
468
+ id: generateId(),
469
+ elementSelector: mvNewSelector,
470
+ property: mvProperty,
471
+ originalValue: `parent:${mvOldParent}|index:${mvOldIndex}|selector:${mvOldSelector}`,
472
+ newValue: `parent:${mvNewParent}|index:${mvNewIndex}`,
473
+ breakpoint: store.activeBreakpoint,
474
+ timestamp: Date.now(),
475
+ changeScope: store.changeScope,
476
+ })
477
+
478
+ // Update selected element path if it changed — re-select via inspector
479
+ if (
480
+ store.selectorPath === mvOldSelector &&
481
+ mvOldSelector !== mvNewSelector
482
+ ) {
483
+ sendViaIframe({
484
+ type: 'SELECT_ELEMENT',
485
+ payload: { selectorPath: mvNewSelector },
486
+ })
487
+ }
488
+
489
+ // Request updated DOM tree
490
+ sendViaIframe({ type: 'REQUEST_DOM_TREE' })
491
+ break
492
+ }
493
+
494
+ case 'ELEMENT_DELETED': {
495
+ const {
496
+ selectorPath: delSelector,
497
+ originalDisplay,
498
+ tagName: delTag,
499
+ className: delClass,
500
+ elementId: delId,
501
+ innerText: delText,
502
+ attributes: delAttrs,
503
+ computedStyles: delStyles,
504
+ } = msg.payload
505
+ const delProperty = '__element_deleted__'
506
+
507
+ // Skip if already tracked (e.g., replayed on reconnect)
508
+ const existingDelete = store.styleChanges.find(
509
+ (c) => c.elementSelector === delSelector && c.property === delProperty,
510
+ )
511
+ if (existingDelete) break
512
+
513
+ // Push undo action
514
+ store.pushUndo({
515
+ elementSelector: delSelector,
516
+ property: delProperty,
517
+ beforeValue: originalDisplay,
518
+ afterValue: 'deleted',
519
+ breakpoint: store.activeBreakpoint,
520
+ wasNewChange: true,
521
+ changeScope: store.changeScope,
522
+ })
523
+
524
+ // Save element snapshot
525
+ store.saveElementSnapshot({
526
+ selectorPath: delSelector,
527
+ tagName: delTag || 'unknown',
528
+ className: delClass ?? null,
529
+ elementId: delId ?? null,
530
+ attributes: delAttrs || {},
531
+ innerText: delText,
532
+ computedStyles: delStyles ? { ...delStyles } : {},
533
+ pagePath: store.currentPagePath,
534
+ changeScope: store.changeScope,
535
+ sourceInfo: null,
536
+ })
537
+
538
+ // Track the deletion
539
+ store.addStyleChange({
540
+ id: generateId(),
541
+ elementSelector: delSelector,
542
+ property: delProperty,
543
+ originalValue: originalDisplay,
544
+ newValue: 'deleted',
545
+ breakpoint: store.activeBreakpoint,
546
+ timestamp: Date.now(),
547
+ changeScope: store.changeScope,
548
+ })
549
+
550
+ // Clear selection since the element is now hidden
551
+ store.clearSelection()
552
+ break
553
+ }
554
+ }
555
+ }
556
+
557
+ function ensureListener() {
558
+ if (listenerRegistered) return
559
+ listenerRegistered = true
560
+ window.addEventListener('message', handleMessage)
561
+ }
562
+
563
+ export function usePostMessage() {
564
+ const iframeRef = sharedIframeRef
565
+
566
+ // Register the singleton listener on first client-side mount
567
+ useEffect(() => {
568
+ ensureListener()
569
+ }, [])
570
+
571
+ const sendToInspector = useCallback((message: EditorToInspectorMessage) => {
572
+ sendViaIframe(message)
573
+ }, [])
574
+
575
+ const sendHeartbeat = useCallback((): Promise<boolean> => {
576
+ return new Promise((resolve) => {
577
+ heartbeatResolve = () => resolve(true)
578
+ sendToInspector({ type: 'HEARTBEAT' })
579
+ setTimeout(() => {
580
+ if (heartbeatResolve) {
581
+ heartbeatResolve = null
582
+ resolve(false)
583
+ }
584
+ }, 3000)
585
+ })
586
+ }, [sendToInspector])
587
+
588
+ return { iframeRef, sendToInspector, sendHeartbeat }
589
+ }