@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,703 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { SectionHeader } from '@/components/right-panel/design/inputs/SectionHeader'
6
+ import { CompactInput } from '@/components/right-panel/design/inputs/CompactInput'
7
+ import { ExpandIcon } from '@/components/right-panel/design/icons'
8
+ import { useChangeTracker } from '@/hooks/useChangeTracker'
9
+ import { parseCSSValue, formatCSSValue } from '@/lib/utils'
10
+
11
+ const APPEARANCE_PROPERTIES = [
12
+ 'opacity',
13
+ 'borderRadius',
14
+ 'borderTopLeftRadius',
15
+ 'borderTopRightRadius',
16
+ 'borderBottomLeftRadius',
17
+ 'borderBottomRightRadius',
18
+ 'overflow',
19
+ 'overflowX',
20
+ 'overflowY',
21
+ 'cursor',
22
+ 'mixBlendMode',
23
+ 'visibility',
24
+ 'pointerEvents',
25
+ ]
26
+
27
+ // ─── Opacity Slider ───────────────────────────────────────────────
28
+
29
+ function OpacitySlider({
30
+ value,
31
+ onChange,
32
+ }: {
33
+ value: number
34
+ onChange: (v: number) => void
35
+ }) {
36
+ const trackRef = useRef<HTMLDivElement>(null)
37
+ const isDragging = useRef(false)
38
+
39
+ const updateFromPointer = useCallback(
40
+ (clientX: number) => {
41
+ const track = trackRef.current
42
+ if (!track) return
43
+ const rect = track.getBoundingClientRect()
44
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
45
+ onChange(Math.round(ratio * 100))
46
+ },
47
+ [onChange],
48
+ )
49
+
50
+ const handlePointerDown = useCallback(
51
+ (e: React.PointerEvent) => {
52
+ e.preventDefault()
53
+ isDragging.current = true
54
+ trackRef.current?.setPointerCapture(e.pointerId)
55
+ document.body.style.cursor = 'grabbing'
56
+ document.body.style.userSelect = 'none'
57
+ updateFromPointer(e.clientX)
58
+ },
59
+ [updateFromPointer],
60
+ )
61
+
62
+ const handlePointerMove = useCallback(
63
+ (e: React.PointerEvent) => {
64
+ if (!isDragging.current) return
65
+ updateFromPointer(e.clientX)
66
+ },
67
+ [updateFromPointer],
68
+ )
69
+
70
+ const handlePointerUp = useCallback((e: React.PointerEvent) => {
71
+ if (!isDragging.current) return
72
+ isDragging.current = false
73
+ trackRef.current?.releasePointerCapture(e.pointerId)
74
+ document.body.style.cursor = ''
75
+ document.body.style.userSelect = ''
76
+ }, [])
77
+
78
+ return (
79
+ <div
80
+ ref={trackRef}
81
+ onPointerDown={handlePointerDown}
82
+ onPointerMove={handlePointerMove}
83
+ onPointerUp={handlePointerUp}
84
+ className="relative h-1.5 rounded-full cursor-pointer"
85
+ style={{ background: '#3a3a3a' }}
86
+ >
87
+ {/* Fill */}
88
+ <div
89
+ className="absolute h-full rounded-full"
90
+ style={{
91
+ background: 'var(--accent)',
92
+ width: `${value}%`,
93
+ transition: isDragging.current ? 'none' : 'width 0.1s',
94
+ }}
95
+ />
96
+ {/* Thumb */}
97
+ <div
98
+ className="absolute top-1/2 w-3 h-3 rounded-full border-2"
99
+ style={{
100
+ left: `${value}%`,
101
+ transform: 'translate(-50%, -50%)',
102
+ background: '#fff',
103
+ borderColor: 'var(--accent)',
104
+ boxShadow: '0 1px 3px rgba(0,0,0,0.4)',
105
+ }}
106
+ />
107
+ </div>
108
+ )
109
+ }
110
+
111
+ // ─── Spatial Corner Radius Widget ─────────────────────────────────
112
+
113
+ function CornerRadiusWidget({
114
+ tl,
115
+ tr,
116
+ bl,
117
+ br,
118
+ uniform,
119
+ onChange,
120
+ }: {
121
+ tl: string
122
+ tr: string
123
+ bl: string
124
+ br: string
125
+ uniform: string
126
+ onChange: (prop: string, val: string) => void
127
+ }) {
128
+ const [showIndividual, setShowIndividual] = useState(false)
129
+
130
+ const uniformParsed = parseCSSValue(uniform)
131
+ const uniformNum = uniformParsed.number
132
+
133
+ // Clamp preview radius to max 12 for the visual
134
+ const previewR = Math.min(12, uniformNum)
135
+
136
+ return (
137
+ <div className="space-y-2">
138
+ {/* Uniform radius row */}
139
+ <div className="flex items-center gap-2">
140
+ {/* Visual preview box */}
141
+ <div
142
+ className="flex-shrink-0 flex items-center justify-center"
143
+ style={{ width: 28, height: 28 }}
144
+ >
145
+ <div
146
+ style={{
147
+ width: 22,
148
+ height: 22,
149
+ borderRadius: showIndividual
150
+ ? `${Math.min(12, parseCSSValue(tl).number)}px ${Math.min(12, parseCSSValue(tr).number)}px ${Math.min(12, parseCSSValue(br).number)}px ${Math.min(12, parseCSSValue(bl).number)}px`
151
+ : `${previewR}px`,
152
+ border: '1.5px solid var(--text-secondary)',
153
+ transition: 'border-radius 0.15s',
154
+ }}
155
+ />
156
+ </div>
157
+
158
+ <div className="flex-1">
159
+ <CompactInput
160
+ label="R"
161
+ value={uniform}
162
+ property="borderRadius"
163
+ onChange={onChange}
164
+ units={['px', '%', 'em', 'rem']}
165
+ min={0}
166
+ />
167
+ </div>
168
+
169
+ <button
170
+ type="button"
171
+ onClick={() => setShowIndividual(!showIndividual)}
172
+ className="flex items-center justify-center w-6 h-6 rounded transition-colors"
173
+ style={{
174
+ color: showIndividual ? 'var(--accent)' : 'var(--text-muted)',
175
+ background: showIndividual
176
+ ? 'rgba(74,158,255,0.12)'
177
+ : 'transparent',
178
+ }}
179
+ title="Individual corners"
180
+ >
181
+ <ExpandIcon />
182
+ </button>
183
+ </div>
184
+
185
+ {/* Individual corners — spatial layout */}
186
+ {showIndividual && (
187
+ <div
188
+ className="relative rounded-md p-3"
189
+ style={{ background: '#1a1a1a', border: '1px solid var(--border)' }}
190
+ >
191
+ <div className="grid grid-cols-2 gap-x-8 gap-y-1">
192
+ {/* Top row */}
193
+ <div className="flex items-center gap-1">
194
+ <svg
195
+ width={10}
196
+ height={10}
197
+ viewBox="0 0 10 10"
198
+ fill="none"
199
+ style={{ flexShrink: 0 }}
200
+ >
201
+ <path
202
+ d="M1 9V4a3 3 0 0 1 3-3h5"
203
+ stroke="currentColor"
204
+ strokeWidth={1.2}
205
+ strokeLinecap="round"
206
+ style={{ color: 'var(--text-muted)' }}
207
+ />
208
+ </svg>
209
+ <div className="flex-1">
210
+ <CompactInput
211
+ value={tl}
212
+ property="borderTopLeftRadius"
213
+ onChange={onChange}
214
+ units={['px', '%', 'em', 'rem']}
215
+ min={0}
216
+ />
217
+ </div>
218
+ </div>
219
+ <div className="flex items-center gap-1">
220
+ <div className="flex-1">
221
+ <CompactInput
222
+ value={tr}
223
+ property="borderTopRightRadius"
224
+ onChange={onChange}
225
+ units={['px', '%', 'em', 'rem']}
226
+ min={0}
227
+ />
228
+ </div>
229
+ <svg
230
+ width={10}
231
+ height={10}
232
+ viewBox="0 0 10 10"
233
+ fill="none"
234
+ style={{ flexShrink: 0 }}
235
+ >
236
+ <path
237
+ d="M1 1h5a3 3 0 0 1 3 3v5"
238
+ stroke="currentColor"
239
+ strokeWidth={1.2}
240
+ strokeLinecap="round"
241
+ style={{ color: 'var(--text-muted)' }}
242
+ />
243
+ </svg>
244
+ </div>
245
+
246
+ {/* Center visual element */}
247
+ <div className="col-span-2 flex justify-center py-1.5">
248
+ <div
249
+ style={{
250
+ width: 48,
251
+ height: 32,
252
+ borderRadius: `${Math.min(16, parseCSSValue(tl).number)}px ${Math.min(16, parseCSSValue(tr).number)}px ${Math.min(16, parseCSSValue(br).number)}px ${Math.min(16, parseCSSValue(bl).number)}px`,
253
+ background: '#3a3a3a',
254
+ border: '1.5px solid #555',
255
+ transition: 'border-radius 0.15s',
256
+ }}
257
+ />
258
+ </div>
259
+
260
+ {/* Bottom row */}
261
+ <div className="flex items-center gap-1">
262
+ <svg
263
+ width={10}
264
+ height={10}
265
+ viewBox="0 0 10 10"
266
+ fill="none"
267
+ style={{ flexShrink: 0 }}
268
+ >
269
+ <path
270
+ d="M9 1H4a3 3 0 0 0-3 3v5"
271
+ stroke="currentColor"
272
+ strokeWidth={1.2}
273
+ strokeLinecap="round"
274
+ style={{ color: 'var(--text-muted)' }}
275
+ />
276
+ </svg>
277
+ <div className="flex-1">
278
+ <CompactInput
279
+ value={bl}
280
+ property="borderBottomLeftRadius"
281
+ onChange={onChange}
282
+ units={['px', '%', 'em', 'rem']}
283
+ min={0}
284
+ />
285
+ </div>
286
+ </div>
287
+ <div className="flex items-center gap-1">
288
+ <div className="flex-1">
289
+ <CompactInput
290
+ value={br}
291
+ property="borderBottomRightRadius"
292
+ onChange={onChange}
293
+ units={['px', '%', 'em', 'rem']}
294
+ min={0}
295
+ />
296
+ </div>
297
+ <svg
298
+ width={10}
299
+ height={10}
300
+ viewBox="0 0 10 10"
301
+ fill="none"
302
+ style={{ flexShrink: 0 }}
303
+ >
304
+ <path
305
+ d="M1 1h5a3 3 0 0 1 3 3v5"
306
+ stroke="currentColor"
307
+ strokeWidth={1.2}
308
+ strokeLinecap="round"
309
+ style={{ color: 'var(--text-muted)' }}
310
+ transform="rotate(180 5 5)"
311
+ />
312
+ </svg>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ )}
317
+ </div>
318
+ )
319
+ }
320
+
321
+ // ─── Overflow Controls ────────────────────────────────────────────
322
+
323
+ const OVERFLOW_OPTIONS = ['visible', 'hidden', 'scroll', 'auto'] as const
324
+
325
+ function OverflowToggle({
326
+ label,
327
+ value,
328
+ onChange,
329
+ }: {
330
+ label: string
331
+ value: string
332
+ onChange: (v: string) => void
333
+ }) {
334
+ return (
335
+ <div className="flex items-center gap-2">
336
+ <span
337
+ className="text-[11px] w-5 flex-shrink-0 text-center"
338
+ style={{ color: 'var(--text-muted)' }}
339
+ >
340
+ {label}
341
+ </span>
342
+ <div
343
+ className="flex-1 flex rounded overflow-hidden"
344
+ style={{ border: '1px solid var(--border)' }}
345
+ >
346
+ {OVERFLOW_OPTIONS.map((opt) => {
347
+ const isActive = value === opt
348
+ return (
349
+ <button
350
+ key={opt}
351
+ type="button"
352
+ onClick={() => onChange(opt)}
353
+ className="flex-1 h-6 text-[10px] transition-colors"
354
+ style={{
355
+ background: isActive
356
+ ? 'rgba(74,158,255,0.12)'
357
+ : 'var(--bg-tertiary)',
358
+ color: isActive ? 'var(--accent)' : 'var(--text-muted)',
359
+ }}
360
+ >
361
+ {opt === 'visible' ? 'vis' : opt === 'hidden' ? 'hide' : opt}
362
+ </button>
363
+ )
364
+ })}
365
+ </div>
366
+ </div>
367
+ )
368
+ }
369
+
370
+ // ─── Cursor Dropdown ──────────────────────────────────────────────
371
+
372
+ const CURSOR_OPTIONS = [
373
+ 'auto',
374
+ 'default',
375
+ 'pointer',
376
+ 'text',
377
+ 'move',
378
+ 'wait',
379
+ 'help',
380
+ 'crosshair',
381
+ 'not-allowed',
382
+ 'grab',
383
+ 'grabbing',
384
+ 'col-resize',
385
+ 'row-resize',
386
+ 'n-resize',
387
+ 'e-resize',
388
+ 'zoom-in',
389
+ 'zoom-out',
390
+ 'none',
391
+ ]
392
+
393
+ // ─── Select Style ─────────────────────────────────────────────────
394
+
395
+ const selectStyle = {
396
+ background: 'var(--bg-tertiary)',
397
+ border: '1px solid var(--border)',
398
+ color: 'var(--text-primary)',
399
+ } as const
400
+
401
+ // ─── Main AppearanceSection ───────────────────────────────────────
402
+
403
+ export function AppearanceSection() {
404
+ const computedStyles = useEditorStore((state) => state.computedStyles)
405
+ const { applyChange, resetProperty } = useChangeTracker()
406
+ const [moreOpen, setMoreOpen] = useState(false)
407
+
408
+ const hasChanges = useEditorStore((s) => {
409
+ const sp = s.selectorPath
410
+ if (!sp) return false
411
+ return s.styleChanges.some(
412
+ (c) =>
413
+ c.elementSelector === sp && APPEARANCE_PROPERTIES.includes(c.property),
414
+ )
415
+ })
416
+
417
+ const handleResetAll = () => {
418
+ const { selectorPath, styleChanges } = useEditorStore.getState()
419
+ if (!selectorPath) return
420
+ const matching = styleChanges.filter(
421
+ (c) =>
422
+ c.elementSelector === selectorPath &&
423
+ APPEARANCE_PROPERTIES.includes(c.property),
424
+ )
425
+ for (const c of matching) resetProperty(c.property)
426
+ }
427
+
428
+ const handleChange = (property: string, value: string) => {
429
+ applyChange(property, value)
430
+ }
431
+
432
+ // ─── Values ─────────────────────────────────────────────────────
433
+ const rawOpacity = computedStyles.opacity || '1'
434
+ const opacityPercent = Math.round(parseFloat(rawOpacity) * 100)
435
+
436
+ const borderRadius = computedStyles.borderRadius || '0px'
437
+ const borderTopLeftRadius = computedStyles.borderTopLeftRadius || borderRadius
438
+ const borderTopRightRadius =
439
+ computedStyles.borderTopRightRadius || borderRadius
440
+ const borderBottomLeftRadius =
441
+ computedStyles.borderBottomLeftRadius || borderRadius
442
+ const borderBottomRightRadius =
443
+ computedStyles.borderBottomRightRadius || borderRadius
444
+
445
+ const overflowX = computedStyles.overflowX || 'visible'
446
+ const overflowY = computedStyles.overflowY || 'visible'
447
+ const cursor = computedStyles.cursor || 'auto'
448
+ const mixBlendMode = computedStyles.mixBlendMode || 'normal'
449
+ const visibility = computedStyles.visibility || 'visible'
450
+ const pointerEvents = computedStyles.pointerEvents || 'auto'
451
+
452
+ const handleOpacityChange = (_property: string, value: string) => {
453
+ const parsed = parseCSSValue(value)
454
+ const clamped = Math.max(0, Math.min(100, parsed.number))
455
+ applyChange('opacity', String(clamped / 100))
456
+ }
457
+
458
+ const handleOpacitySlider = (pct: number) => {
459
+ applyChange('opacity', String(pct / 100))
460
+ }
461
+
462
+ // Row label width consistent with other sections
463
+ const LW = 'w-[52px]'
464
+
465
+ return (
466
+ <SectionHeader
467
+ title="Appearance"
468
+ defaultOpen={true}
469
+ hasChanges={hasChanges}
470
+ onReset={handleResetAll}
471
+ >
472
+ {/* ── Opacity ─────────────────────────────────────── */}
473
+ <div className="flex items-center gap-2">
474
+ <span
475
+ className={`${LW} flex-shrink-0 text-[11px]`}
476
+ style={{ color: 'var(--accent)' }}
477
+ >
478
+ Opacity
479
+ </span>
480
+ <div className="flex-1 flex items-center gap-2">
481
+ <div className="flex-1 py-1">
482
+ <OpacitySlider
483
+ value={opacityPercent}
484
+ onChange={handleOpacitySlider}
485
+ />
486
+ </div>
487
+ <div style={{ width: 58 }}>
488
+ <CompactInput
489
+ value={formatCSSValue(opacityPercent, '%')}
490
+ property="opacity"
491
+ onChange={handleOpacityChange}
492
+ units={['%']}
493
+ min={0}
494
+ max={100}
495
+ step={1}
496
+ />
497
+ </div>
498
+ </div>
499
+ </div>
500
+
501
+ {/* ── Border Radius ──────────────────────────────── */}
502
+ <CornerRadiusWidget
503
+ tl={borderTopLeftRadius}
504
+ tr={borderTopRightRadius}
505
+ bl={borderBottomLeftRadius}
506
+ br={borderBottomRightRadius}
507
+ uniform={borderRadius}
508
+ onChange={handleChange}
509
+ />
510
+
511
+ {/* ── Overflow ───────────────────────────────────── */}
512
+ <div className="space-y-1">
513
+ <span
514
+ className="text-[11px] block"
515
+ style={{ color: 'var(--text-secondary)' }}
516
+ >
517
+ Overflow
518
+ </span>
519
+ <OverflowToggle
520
+ label="X"
521
+ value={overflowX}
522
+ onChange={(v) => handleChange('overflowX', v)}
523
+ />
524
+ <OverflowToggle
525
+ label="Y"
526
+ value={overflowY}
527
+ onChange={(v) => handleChange('overflowY', v)}
528
+ />
529
+ </div>
530
+
531
+ {/* ── Cursor ─────────────────────────────────────── */}
532
+ <div className="flex items-center gap-2">
533
+ <span
534
+ className={`${LW} flex-shrink-0 text-[11px]`}
535
+ style={{ color: 'var(--text-secondary)' }}
536
+ >
537
+ Cursor
538
+ </span>
539
+ <select
540
+ value={cursor}
541
+ onChange={(e) => handleChange('cursor', e.target.value)}
542
+ className="flex-1 h-7 rounded text-[11px] px-2 cursor-pointer outline-none"
543
+ style={selectStyle}
544
+ >
545
+ {CURSOR_OPTIONS.map((opt) => (
546
+ <option key={opt} value={opt}>
547
+ {opt}
548
+ </option>
549
+ ))}
550
+ </select>
551
+ </div>
552
+
553
+ {/* ── More toggle ────────────────────────────────── */}
554
+ <div className="flex items-center gap-2">
555
+ <button
556
+ type="button"
557
+ onClick={() => setMoreOpen(!moreOpen)}
558
+ className="flex items-center gap-1 text-[10px] py-0.5 transition-colors"
559
+ style={{
560
+ color: 'var(--text-muted)',
561
+ background: 'none',
562
+ border: 'none',
563
+ cursor: 'pointer',
564
+ }}
565
+ >
566
+ <span
567
+ style={{
568
+ display: 'inline-block',
569
+ fontSize: 8,
570
+ transform: moreOpen ? 'rotate(90deg)' : 'rotate(0deg)',
571
+ transition: 'transform 0.15s',
572
+ }}
573
+ >
574
+
575
+ </span>
576
+ More options
577
+ </button>
578
+ </div>
579
+
580
+ {moreOpen && (
581
+ <div
582
+ className="space-y-2 pt-1"
583
+ style={{ borderTop: '1px solid var(--border)' }}
584
+ >
585
+ {/* Mix Blend Mode */}
586
+ <div className="flex items-center gap-2">
587
+ <span
588
+ className={`${LW} flex-shrink-0 text-[11px]`}
589
+ style={{ color: 'var(--text-secondary)' }}
590
+ >
591
+ Blend
592
+ </span>
593
+ <select
594
+ value={mixBlendMode}
595
+ onChange={(e) => handleChange('mixBlendMode', e.target.value)}
596
+ className="flex-1 h-7 rounded text-[11px] px-2 cursor-pointer outline-none"
597
+ style={selectStyle}
598
+ >
599
+ {[
600
+ 'normal',
601
+ 'multiply',
602
+ 'screen',
603
+ 'overlay',
604
+ 'darken',
605
+ 'lighten',
606
+ 'color-dodge',
607
+ 'color-burn',
608
+ 'hard-light',
609
+ 'soft-light',
610
+ 'difference',
611
+ 'exclusion',
612
+ 'hue',
613
+ 'saturation',
614
+ 'color',
615
+ 'luminosity',
616
+ ].map((m) => (
617
+ <option key={m} value={m}>
618
+ {m}
619
+ </option>
620
+ ))}
621
+ </select>
622
+ </div>
623
+
624
+ {/* Visibility */}
625
+ <div className="flex items-center gap-2">
626
+ <span
627
+ className={`${LW} flex-shrink-0 text-[11px]`}
628
+ style={{ color: 'var(--text-secondary)' }}
629
+ >
630
+ Visible
631
+ </span>
632
+ <div
633
+ className="inline-flex rounded"
634
+ style={{
635
+ background: 'var(--bg-tertiary)',
636
+ border: '1px solid var(--border)',
637
+ }}
638
+ >
639
+ {(['visible', 'hidden', 'collapse'] as const).map((opt) => {
640
+ const isActive = visibility === opt
641
+ return (
642
+ <button
643
+ key={opt}
644
+ type="button"
645
+ onClick={() => handleChange('visibility', opt)}
646
+ className="flex items-center justify-center px-3 text-[11px] transition-colors"
647
+ style={{
648
+ height: 24,
649
+ background: isActive
650
+ ? 'rgba(74,158,255,0.12)'
651
+ : 'transparent',
652
+ color: isActive ? 'var(--accent)' : 'var(--text-muted)',
653
+ }}
654
+ >
655
+ {opt}
656
+ </button>
657
+ )
658
+ })}
659
+ </div>
660
+ </div>
661
+
662
+ {/* Pointer Events */}
663
+ <div className="flex items-center gap-2">
664
+ <span
665
+ className={`${LW} flex-shrink-0 text-[11px]`}
666
+ style={{ color: 'var(--text-secondary)' }}
667
+ >
668
+ Pointer
669
+ </span>
670
+ <div
671
+ className="inline-flex rounded"
672
+ style={{
673
+ background: 'var(--bg-tertiary)',
674
+ border: '1px solid var(--border)',
675
+ }}
676
+ >
677
+ {(['auto', 'none'] as const).map((opt) => {
678
+ const isActive = pointerEvents === opt
679
+ return (
680
+ <button
681
+ key={opt}
682
+ type="button"
683
+ onClick={() => handleChange('pointerEvents', opt)}
684
+ className="flex items-center justify-center px-4 text-[11px] transition-colors"
685
+ style={{
686
+ height: 24,
687
+ background: isActive
688
+ ? 'rgba(74,158,255,0.12)'
689
+ : 'transparent',
690
+ color: isActive ? 'var(--accent)' : 'var(--text-muted)',
691
+ }}
692
+ >
693
+ {opt}
694
+ </button>
695
+ )
696
+ })}
697
+ </div>
698
+ </div>
699
+ </div>
700
+ )}
701
+ </SectionHeader>
702
+ )
703
+ }