@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
package/src/proxy.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+
3
+ const PROXY_HEADER = 'x-dev-editor-target'
4
+
5
+ /**
6
+ * Middleware that intercepts asset requests originating from the proxied
7
+ * iframe and rewrites them to go through the proxy API route.
8
+ *
9
+ * Matches /_next/ paths (static assets, images) and common asset paths
10
+ * (fonts, images, icons, media) that target apps may serve. This ensures
11
+ * icon font files, images, and other resources load correctly through
12
+ * the proxy even when CSS url() references aren't rewritten.
13
+ *
14
+ * IMPORTANT: We do NOT match page-level paths (e.g. /works, /about).
15
+ * Those are handled by the proxy's HTML URL rewriting. Matching page
16
+ * paths pollutes Next.js HMR route tree and causes reload loops.
17
+ * The referer/fetch-dest check below ensures only iframe-originated
18
+ * requests are proxied.
19
+ */
20
+
21
+ // Asset file extensions that should be proxied from the iframe
22
+ const ASSET_EXT_RE =
23
+ /\.(woff2?|ttf|eot|otf|svg|png|jpe?g|gif|webp|avif|ico|mp4|webm|css|js|json|map)(\?|$)/i
24
+
25
+ export function proxy(request: NextRequest) {
26
+ const targetUrl = request.cookies.get(PROXY_HEADER)?.value
27
+ if (!targetUrl) return NextResponse.next()
28
+
29
+ const { pathname } = request.nextUrl
30
+
31
+ // Never intercept our own editor API routes
32
+ if (pathname.startsWith('/api/')) return NextResponse.next()
33
+
34
+ // Check if this request originates from the proxied iframe.
35
+ // After the navigation blocker calls history.replaceState, the referer
36
+ // no longer contains /api/proxy. For dynamically loaded chunks, the
37
+ // navigation blocker adds ?_devproxy=1 to /_next/ URLs as a proxy marker.
38
+ // NOTE: Do NOT use "_dp" — Next.js uses ?_dp=1 internally for CSS preloading.
39
+ const referer = request.headers.get('referer') || ''
40
+ const isFromProxy = referer.includes('/api/proxy')
41
+ const fetchDest = request.headers.get('sec-fetch-dest') || ''
42
+ const hasDynamicProxyMarker = request.nextUrl.searchParams.has('_devproxy')
43
+
44
+ if (!isFromProxy && fetchDest !== 'iframe' && !hasDynamicProxyMarker) {
45
+ return NextResponse.next()
46
+ }
47
+
48
+ // Only proxy requests that look like assets (have a file extension).
49
+ // This prevents page-level paths from being proxied.
50
+ if (!pathname.startsWith('/_next/') && !ASSET_EXT_RE.test(pathname)) {
51
+ return NextResponse.next()
52
+ }
53
+
54
+ // Rewrite to proxy route (rewrite is transparent — doesn't change the
55
+ // client-visible URL and doesn't confuse HMR since these are asset paths,
56
+ // not page routes).
57
+ const proxyUrl = new URL(`/api/proxy${pathname}`, request.url)
58
+ proxyUrl.searchParams.set(PROXY_HEADER, targetUrl)
59
+
60
+ // Preserve original query params (strip internal markers)
61
+ request.nextUrl.searchParams.forEach((value, key) => {
62
+ if (key !== PROXY_HEADER && key !== '_devproxy') {
63
+ proxyUrl.searchParams.set(key, value)
64
+ }
65
+ })
66
+
67
+ return NextResponse.rewrite(proxyUrl)
68
+ }
69
+
70
+ export const config = {
71
+ matcher: [
72
+ // /_next/ asset paths (static files, images, fonts)
73
+ '/_next/static/:path*',
74
+ '/_next/image',
75
+ // Common asset directories that target apps may serve
76
+ // (fonts, icons, images, media, static files)
77
+ '/fonts/:path*',
78
+ '/webfonts/:path*',
79
+ '/assets/:path*',
80
+ '/images/:path*',
81
+ '/icons/:path*',
82
+ '/media/:path*',
83
+ '/static/:path*',
84
+ '/public/:path*',
85
+ '/avatars/:path*',
86
+ '/uploads/:path*',
87
+ '/files/:path*',
88
+ '/content/:path*',
89
+ '/img/:path*',
90
+ '/pics/:path*',
91
+ ],
92
+ }
@@ -0,0 +1,104 @@
1
+ import * as pty from 'node-pty'
2
+
3
+ const TERMINAL_PORT = Number(process.env.TERMINAL_PORT) || 4001
4
+
5
+ interface WsData {
6
+ pty: pty.IPty
7
+ }
8
+
9
+ Bun.serve<WsData>({
10
+ port: TERMINAL_PORT,
11
+
12
+ fetch(req, server) {
13
+ const url = new URL(req.url)
14
+
15
+ if (url.pathname === '/ws') {
16
+ const ok = server.upgrade(req)
17
+ if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
18
+ return undefined
19
+ }
20
+
21
+ if (url.pathname === '/health') {
22
+ return new Response('ok', {
23
+ headers: { 'Access-Control-Allow-Origin': '*' },
24
+ })
25
+ }
26
+
27
+ // CORS preflight
28
+ if (req.method === 'OPTIONS') {
29
+ return new Response(null, {
30
+ status: 204,
31
+ headers: {
32
+ 'Access-Control-Allow-Origin': '*',
33
+ 'Access-Control-Allow-Methods': 'GET',
34
+ 'Access-Control-Allow-Headers': '*',
35
+ },
36
+ })
37
+ }
38
+
39
+ return new Response('Not found', { status: 404 })
40
+ },
41
+
42
+ websocket: {
43
+ open(ws) {
44
+ const shell = process.env.SHELL || '/bin/bash'
45
+ const term = pty.spawn(shell, [], {
46
+ name: 'xterm-256color',
47
+ cols: 80,
48
+ rows: 24,
49
+ cwd: process.env.HOME || '/',
50
+ env: process.env as Record<string, string>,
51
+ })
52
+
53
+ ws.data = { pty: term }
54
+
55
+ term.onData((data: string) => {
56
+ try {
57
+ ws.send(data)
58
+ } catch {
59
+ // WebSocket already closed
60
+ }
61
+ })
62
+
63
+ term.onExit(() => {
64
+ try {
65
+ ws.close()
66
+ } catch {
67
+ // Already closed
68
+ }
69
+ })
70
+ },
71
+
72
+ message(ws, message) {
73
+ const { pty: term } = ws.data
74
+ if (typeof message === 'string') {
75
+ // Resize messages prefixed with \x01
76
+ if (message.startsWith('\x01')) {
77
+ try {
78
+ const { cols, rows } = JSON.parse(message.slice(1))
79
+ if (cols && rows) {
80
+ term.resize(cols, rows)
81
+ }
82
+ } catch {
83
+ // Invalid resize message, ignore
84
+ }
85
+ return
86
+ }
87
+ term.write(message)
88
+ } else {
89
+ // Binary data
90
+ term.write(Buffer.from(message).toString())
91
+ }
92
+ },
93
+
94
+ close(ws) {
95
+ if (ws.data?.pty) {
96
+ ws.data.pty.kill()
97
+ }
98
+ },
99
+ },
100
+ })
101
+
102
+ console.log(
103
+ `Terminal WebSocket server running on ws://localhost:${TERMINAL_PORT}/ws`,
104
+ )
@@ -0,0 +1,288 @@
1
+ import type { StateCreator } from 'zustand'
2
+ import type {
3
+ StyleChange,
4
+ ElementSnapshot,
5
+ UndoRedoAction,
6
+ } from '@/types/changelog'
7
+ import { LOCAL_STORAGE_KEYS } from '@/lib/constants'
8
+ import { generateId } from '@/lib/utils'
9
+
10
+ const MAX_UNDO_STACK = 50
11
+
12
+ export interface ChangeSlice {
13
+ styleChanges: StyleChange[]
14
+ elementSnapshots: Record<string, ElementSnapshot>
15
+ undoStack: UndoRedoAction[]
16
+ redoStack: UndoRedoAction[]
17
+
18
+ addStyleChange: (change: StyleChange) => void
19
+ removeStyleChange: (id: string) => void
20
+ clearAllChanges: () => void
21
+ getChangeCount: () => number
22
+ saveElementSnapshot: (snapshot: ElementSnapshot) => void
23
+ updateAllSnapshotsScope: (scope: 'all' | 'breakpoint-only') => void
24
+ persistChanges: (urlKey: string) => void
25
+ loadPersistedChanges: (urlKey: string) => void
26
+ pushUndo: (action: UndoRedoAction) => void
27
+ popUndo: () => UndoRedoAction | null
28
+ popRedo: () => UndoRedoAction | null
29
+ }
30
+
31
+ export const createChangeSlice: StateCreator<
32
+ ChangeSlice,
33
+ [],
34
+ [],
35
+ ChangeSlice
36
+ > = (set, get) => ({
37
+ styleChanges: [],
38
+ elementSnapshots: {},
39
+ undoStack: [],
40
+ redoStack: [],
41
+
42
+ addStyleChange: (change) => {
43
+ set((state) => {
44
+ const existing = state.styleChanges.findIndex(
45
+ (c) =>
46
+ c.elementSelector === change.elementSelector &&
47
+ c.property === change.property,
48
+ )
49
+ if (existing >= 0) {
50
+ const trueOriginal = state.styleChanges[existing].originalValue
51
+ // Auto-remove: value returned to original — no net change
52
+ if (change.newValue === trueOriginal) {
53
+ const updated = state.styleChanges.filter((_, i) => i !== existing)
54
+ const stillHasChanges = updated.some(
55
+ (c) => c.elementSelector === change.elementSelector,
56
+ )
57
+ if (!stillHasChanges) {
58
+ const { [change.elementSelector]: _, ...rest } =
59
+ state.elementSnapshots
60
+ return { styleChanges: updated, elementSnapshots: rest }
61
+ }
62
+ return { styleChanges: updated }
63
+ }
64
+ const updated = [...state.styleChanges]
65
+ updated[existing] = { ...change, originalValue: trueOriginal }
66
+ return { styleChanges: updated }
67
+ }
68
+ return { styleChanges: [...state.styleChanges, change] }
69
+ })
70
+ },
71
+
72
+ removeStyleChange: (id) => {
73
+ set((state) => {
74
+ const removed = state.styleChanges.find((c) => c.id === id)
75
+ const updated = state.styleChanges.filter((c) => c.id !== id)
76
+ // Clean up snapshot if no more changes for that element
77
+ if (removed) {
78
+ const stillHasChanges = updated.some(
79
+ (c) => c.elementSelector === removed.elementSelector,
80
+ )
81
+ if (!stillHasChanges) {
82
+ const { [removed.elementSelector]: _, ...rest } =
83
+ state.elementSnapshots
84
+ return { styleChanges: updated, elementSnapshots: rest }
85
+ }
86
+ }
87
+ return { styleChanges: updated }
88
+ })
89
+ },
90
+
91
+ clearAllChanges: () => {
92
+ set({
93
+ styleChanges: [],
94
+ elementSnapshots: {},
95
+ undoStack: [],
96
+ redoStack: [],
97
+ })
98
+ },
99
+
100
+ getChangeCount: () => {
101
+ return get().styleChanges.length
102
+ },
103
+
104
+ saveElementSnapshot: (snapshot) => {
105
+ set((state) => {
106
+ // Only save if we don't already have a snapshot for this selector
107
+ if (state.elementSnapshots[snapshot.selectorPath]) return state
108
+ return {
109
+ elementSnapshots: {
110
+ ...state.elementSnapshots,
111
+ [snapshot.selectorPath]: snapshot,
112
+ },
113
+ }
114
+ })
115
+ },
116
+
117
+ updateAllSnapshotsScope: (scope) => {
118
+ set((state) => {
119
+ const updated: Record<string, ElementSnapshot> = {}
120
+ for (const [key, snap] of Object.entries(state.elementSnapshots)) {
121
+ updated[key] = { ...snap, changeScope: scope }
122
+ }
123
+ return { elementSnapshots: updated }
124
+ })
125
+ },
126
+
127
+ persistChanges: (urlKey) => {
128
+ const { styleChanges, elementSnapshots } = get()
129
+ try {
130
+ localStorage.setItem(
131
+ LOCAL_STORAGE_KEYS.CHANGES_PREFIX + urlKey,
132
+ JSON.stringify({ style: styleChanges, snapshots: elementSnapshots }),
133
+ )
134
+ } catch {}
135
+ },
136
+
137
+ loadPersistedChanges: (urlKey) => {
138
+ try {
139
+ const stored = localStorage.getItem(
140
+ LOCAL_STORAGE_KEYS.CHANGES_PREFIX + urlKey,
141
+ )
142
+ if (stored) {
143
+ const parsed = JSON.parse(stored)
144
+ set({
145
+ styleChanges: parsed.style || [],
146
+ elementSnapshots: parsed.snapshots || {},
147
+ })
148
+ }
149
+ } catch {}
150
+ },
151
+
152
+ pushUndo: (action) => {
153
+ set((state) => ({
154
+ undoStack: [...state.undoStack.slice(-(MAX_UNDO_STACK - 1)), action],
155
+ redoStack: [],
156
+ }))
157
+ },
158
+
159
+ popUndo: () => {
160
+ const { undoStack } = get()
161
+ if (undoStack.length === 0) return null
162
+ const action = undoStack[undoStack.length - 1]
163
+
164
+ set((state) => {
165
+ const newUndo = state.undoStack.slice(0, -1)
166
+ const newRedo = [...state.redoStack, action]
167
+
168
+ let newChanges = state.styleChanges
169
+ let newSnapshots = state.elementSnapshots
170
+
171
+ if (action.wasNewChange) {
172
+ newChanges = newChanges.filter(
173
+ (c) =>
174
+ !(
175
+ c.elementSelector === action.elementSelector &&
176
+ c.property === action.property
177
+ ),
178
+ )
179
+ const stillHas = newChanges.some(
180
+ (c) => c.elementSelector === action.elementSelector,
181
+ )
182
+ if (!stillHas) {
183
+ const { [action.elementSelector]: _, ...rest } = newSnapshots
184
+ newSnapshots = rest
185
+ }
186
+ } else {
187
+ const exists = newChanges.some(
188
+ (c) =>
189
+ c.elementSelector === action.elementSelector &&
190
+ c.property === action.property,
191
+ )
192
+ if (exists) {
193
+ newChanges = newChanges.map((c) =>
194
+ c.elementSelector === action.elementSelector &&
195
+ c.property === action.property
196
+ ? { ...c, newValue: action.beforeValue }
197
+ : c,
198
+ )
199
+ } else {
200
+ // Change was auto-removed (value matched original) — re-create it
201
+ newChanges = [
202
+ ...newChanges,
203
+ {
204
+ id: generateId(),
205
+ elementSelector: action.elementSelector,
206
+ property: action.property,
207
+ originalValue: action.afterValue,
208
+ newValue: action.beforeValue,
209
+ breakpoint: action.breakpoint,
210
+ timestamp: Date.now(),
211
+ changeScope: action.changeScope,
212
+ },
213
+ ]
214
+ }
215
+ }
216
+
217
+ return {
218
+ undoStack: newUndo,
219
+ redoStack: newRedo,
220
+ styleChanges: newChanges,
221
+ elementSnapshots: newSnapshots,
222
+ }
223
+ })
224
+
225
+ return action
226
+ },
227
+
228
+ popRedo: () => {
229
+ const { redoStack } = get()
230
+ if (redoStack.length === 0) return null
231
+ const action = redoStack[redoStack.length - 1]
232
+
233
+ set((state) => {
234
+ const newRedo = state.redoStack.slice(0, -1)
235
+ const newUndo = [...state.undoStack, action]
236
+
237
+ let newChanges = state.styleChanges
238
+ const idx = newChanges.findIndex(
239
+ (c) =>
240
+ c.elementSelector === action.elementSelector &&
241
+ c.property === action.property,
242
+ )
243
+
244
+ let newSnapshots = state.elementSnapshots
245
+
246
+ if (idx >= 0) {
247
+ const trueOriginal = newChanges[idx].originalValue
248
+ // Auto-remove: redo restores value to original — no net change
249
+ if (action.afterValue === trueOriginal) {
250
+ newChanges = newChanges.filter((_, i) => i !== idx)
251
+ const stillHas = newChanges.some(
252
+ (c) => c.elementSelector === action.elementSelector,
253
+ )
254
+ if (!stillHas) {
255
+ const { [action.elementSelector]: _, ...rest } = newSnapshots
256
+ newSnapshots = rest
257
+ }
258
+ } else {
259
+ newChanges = [...newChanges]
260
+ newChanges[idx] = { ...newChanges[idx], newValue: action.afterValue }
261
+ }
262
+ } else {
263
+ newChanges = [
264
+ ...newChanges,
265
+ {
266
+ id: generateId(),
267
+ elementSelector: action.elementSelector,
268
+ property: action.property,
269
+ originalValue: action.beforeValue,
270
+ newValue: action.afterValue,
271
+ breakpoint: action.breakpoint,
272
+ timestamp: Date.now(),
273
+ changeScope: action.changeScope,
274
+ },
275
+ ]
276
+ }
277
+
278
+ return {
279
+ undoStack: newUndo,
280
+ redoStack: newRedo,
281
+ styleChanges: newChanges,
282
+ elementSnapshots: newSnapshots,
283
+ }
284
+ })
285
+
286
+ return action
287
+ },
288
+ })
@@ -0,0 +1,222 @@
1
+ import type { StateCreator } from 'zustand'
2
+ import type {
3
+ ClaudeStatus,
4
+ ClaudeError,
5
+ ParsedDiff,
6
+ ProjectScanResult,
7
+ ClaudeScanResponse,
8
+ } from '@/types/claude'
9
+ import { LOCAL_STORAGE_KEYS } from '@/lib/constants'
10
+
11
+ export interface ClaudeSlice {
12
+ claudeStatus: ClaudeStatus
13
+ projectRoot: string | null
14
+ portRoots: Record<string, string>
15
+ projectScans: Record<string, ProjectScanResult>
16
+ cliAvailable: boolean | null
17
+ sessionId: string | null
18
+ parsedDiffs: ParsedDiff[]
19
+ claudeError: ClaudeError | null
20
+
21
+ // Project scan state
22
+ componentFileMap: Record<string, string> | null
23
+ scanStatus: 'idle' | 'scanning' | 'complete' | 'error'
24
+ scanError: string | null
25
+ scannedProjectName: string | null
26
+
27
+ // AI scan state (smart prompt generation)
28
+ aiScanStatus: 'idle' | 'scanning' | 'complete' | 'error'
29
+ aiScanResult: ClaudeScanResponse | null
30
+ aiScanError: string | null
31
+
32
+ // Client-side directory handle (File System Access API, non-serializable)
33
+ directoryHandle: FileSystemDirectoryHandle | null
34
+
35
+ setClaudeStatus: (status: ClaudeStatus) => void
36
+ setProjectRoot: (url: string, path: string | null) => void
37
+ getProjectRootForUrl: (url: string | null) => string | null
38
+ setProjectScan: (url: string, scan: ProjectScanResult) => void
39
+ getProjectScanForUrl: (url: string | null) => ProjectScanResult | null
40
+ setCliAvailable: (available: boolean) => void
41
+ setSessionId: (id: string | null) => void
42
+ setParsedDiffs: (diffs: ParsedDiff[]) => void
43
+ setClaudeError: (error: ClaudeError | null) => void
44
+ resetClaude: () => void
45
+ loadPersistedClaude: () => void
46
+
47
+ // Project scan actions
48
+ setComponentFileMap: (map: Record<string, string> | null) => void
49
+ setScanStatus: (status: 'idle' | 'scanning' | 'complete' | 'error') => void
50
+ setScanError: (error: string | null) => void
51
+ setScannedProjectName: (name: string | null) => void
52
+ clearScan: () => void
53
+
54
+ // AI scan actions
55
+ setAiScanStatus: (status: 'idle' | 'scanning' | 'complete' | 'error') => void
56
+ setAiScanResult: (result: ClaudeScanResponse | null) => void
57
+ setAiScanError: (error: string | null) => void
58
+ resetAiScan: () => void
59
+
60
+ // Client-side directory handle actions
61
+ setDirectoryHandle: (handle: FileSystemDirectoryHandle | null) => void
62
+ }
63
+
64
+ function persistPortRoots(portRoots: Record<string, string>) {
65
+ try {
66
+ localStorage.setItem(
67
+ LOCAL_STORAGE_KEYS.CLAUDE_PORT_ROOTS,
68
+ JSON.stringify(portRoots),
69
+ )
70
+ } catch {}
71
+ }
72
+
73
+ function persistProjectScans(scans: Record<string, ProjectScanResult>) {
74
+ try {
75
+ localStorage.setItem(
76
+ LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_SCANS,
77
+ JSON.stringify(scans),
78
+ )
79
+ } catch {}
80
+ }
81
+
82
+ export const createClaudeSlice: StateCreator<
83
+ ClaudeSlice,
84
+ [],
85
+ [],
86
+ ClaudeSlice
87
+ > = (set, get) => ({
88
+ claudeStatus: 'idle',
89
+ projectRoot: null,
90
+ portRoots: {},
91
+ projectScans: {},
92
+ cliAvailable: null,
93
+ sessionId: null,
94
+ parsedDiffs: [],
95
+ claudeError: null,
96
+
97
+ componentFileMap: null,
98
+ scanStatus: 'idle',
99
+ scanError: null,
100
+ scannedProjectName: null,
101
+
102
+ aiScanStatus: 'idle',
103
+ aiScanResult: null,
104
+ aiScanError: null,
105
+
106
+ directoryHandle: null,
107
+
108
+ setClaudeStatus: (status) => set({ claudeStatus: status }),
109
+
110
+ setProjectRoot: (url, path) => {
111
+ const portRoots = { ...get().portRoots }
112
+ if (path) {
113
+ portRoots[url] = path
114
+ } else {
115
+ delete portRoots[url]
116
+ }
117
+ set({ portRoots, projectRoot: path })
118
+ persistPortRoots(portRoots)
119
+ },
120
+
121
+ getProjectRootForUrl: (url) => {
122
+ if (!url) return null
123
+ return get().portRoots[url] ?? null
124
+ },
125
+
126
+ setProjectScan: (url, scan) => {
127
+ const projectScans = { ...get().projectScans, [url]: scan }
128
+ set({ projectScans })
129
+ persistProjectScans(projectScans)
130
+ },
131
+
132
+ getProjectScanForUrl: (url) => {
133
+ if (!url) return null
134
+ return get().projectScans[url] ?? null
135
+ },
136
+
137
+ setCliAvailable: (available) => {
138
+ set({ cliAvailable: available })
139
+ try {
140
+ localStorage.setItem(
141
+ LOCAL_STORAGE_KEYS.CLAUDE_CLI_AVAILABLE,
142
+ JSON.stringify(available),
143
+ )
144
+ } catch {}
145
+ },
146
+
147
+ setSessionId: (id) => set({ sessionId: id }),
148
+ setParsedDiffs: (diffs) => set({ parsedDiffs: diffs }),
149
+ setClaudeError: (error) => set({ claudeError: error }),
150
+
151
+ resetClaude: () => {
152
+ set({
153
+ claudeStatus: 'idle',
154
+ sessionId: null,
155
+ parsedDiffs: [],
156
+ claudeError: null,
157
+ aiScanStatus: 'idle',
158
+ aiScanResult: null,
159
+ aiScanError: null,
160
+ })
161
+ },
162
+
163
+ setComponentFileMap: (map) => set({ componentFileMap: map }),
164
+ setScanStatus: (status) => set({ scanStatus: status }),
165
+ setScanError: (error) => set({ scanError: error }),
166
+ setScannedProjectName: (name) => set({ scannedProjectName: name }),
167
+ clearScan: () =>
168
+ set({
169
+ componentFileMap: null,
170
+ scanStatus: 'idle',
171
+ scanError: null,
172
+ scannedProjectName: null,
173
+ }),
174
+
175
+ setAiScanStatus: (status) => set({ aiScanStatus: status }),
176
+ setAiScanResult: (result) => set({ aiScanResult: result }),
177
+ setAiScanError: (error) => set({ aiScanError: error }),
178
+ resetAiScan: () =>
179
+ set({
180
+ aiScanStatus: 'idle',
181
+ aiScanResult: null,
182
+ aiScanError: null,
183
+ }),
184
+
185
+ setDirectoryHandle: (handle) => set({ directoryHandle: handle }),
186
+
187
+ loadPersistedClaude: () => {
188
+ try {
189
+ // Load port roots map
190
+ const portRootsJson = localStorage.getItem(
191
+ LOCAL_STORAGE_KEYS.CLAUDE_PORT_ROOTS,
192
+ )
193
+ if (portRootsJson) {
194
+ const portRoots = JSON.parse(portRootsJson) as Record<string, string>
195
+ set({ portRoots })
196
+ }
197
+
198
+ // Migrate old single-key value: just remove it (no way to know which port it was for)
199
+ const oldRoot = localStorage.getItem(
200
+ LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_ROOT,
201
+ )
202
+ if (oldRoot) {
203
+ localStorage.removeItem(LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_ROOT)
204
+ }
205
+
206
+ // Load project scans
207
+ const scansJson = localStorage.getItem(
208
+ LOCAL_STORAGE_KEYS.CLAUDE_PROJECT_SCANS,
209
+ )
210
+ if (scansJson) {
211
+ const projectScans = JSON.parse(scansJson) as Record<
212
+ string,
213
+ ProjectScanResult
214
+ >
215
+ set({ projectScans })
216
+ }
217
+
218
+ const cli = localStorage.getItem(LOCAL_STORAGE_KEYS.CLAUDE_CLI_AVAILABLE)
219
+ if (cli) set({ cliAvailable: JSON.parse(cli) })
220
+ } catch {}
221
+ },
222
+ })