@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,865 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react'
4
+ import { useEditorStore } from '@/store'
5
+ import { SectionHeader } from '@/components/right-panel/design/inputs/SectionHeader'
6
+ import {
7
+ StaticIcon,
8
+ RelativeIcon,
9
+ AbsoluteIcon,
10
+ FixedIcon,
11
+ StickyIcon,
12
+ } from '@/components/right-panel/design/icons'
13
+ import { useChangeTracker } from '@/hooks/useChangeTracker'
14
+ import { parseCSSValue, formatCSSValue } from '@/lib/utils'
15
+
16
+ // ─── Position option data ──────────────────────────────────────────
17
+
18
+ const POSITION_OPTIONS = [
19
+ {
20
+ value: 'static',
21
+ label: 'Static',
22
+ icon: <StaticIcon />,
23
+ desc: 'Static is the default position and displays an element based on styles in the Layout section.',
24
+ },
25
+ {
26
+ value: 'relative',
27
+ label: 'Relative',
28
+ icon: <RelativeIcon />,
29
+ desc: 'Relative positions the element relative to its normal position in the document flow.',
30
+ },
31
+ {
32
+ value: 'absolute',
33
+ label: 'Absolute',
34
+ icon: <AbsoluteIcon />,
35
+ desc: 'Absolute positions the element relative to its nearest positioned ancestor.',
36
+ },
37
+ {
38
+ value: 'fixed',
39
+ label: 'Fixed',
40
+ icon: <FixedIcon />,
41
+ desc: 'Fixed positions the element relative to the browser viewport. It stays in place when scrolling.',
42
+ },
43
+ {
44
+ value: 'sticky',
45
+ label: 'Sticky',
46
+ icon: <StickyIcon />,
47
+ desc: 'Sticky toggles between relative and fixed based on the scroll position.',
48
+ },
49
+ ]
50
+
51
+ const FLOAT_OPTIONS = [
52
+ { value: 'none', label: 'None' },
53
+ { value: 'left', label: 'Left' },
54
+ { value: 'right', label: 'Right' },
55
+ ]
56
+
57
+ const CLEAR_OPTIONS = [
58
+ { value: 'none', label: 'None' },
59
+ { value: 'left', label: 'Left' },
60
+ { value: 'right', label: 'Right' },
61
+ { value: 'both', label: 'Both' },
62
+ ]
63
+
64
+ // ─── Position Dropdown ─────────────────────────────────────────────
65
+
66
+ function PositionDropdown({
67
+ value,
68
+ onChange,
69
+ }: {
70
+ value: string
71
+ onChange: (v: string) => void
72
+ }) {
73
+ const [open, setOpen] = useState(false)
74
+ const [hoveredDesc, setHoveredDesc] = useState<string | null>(null)
75
+ const ref = useRef<HTMLDivElement>(null)
76
+
77
+ useEffect(() => {
78
+ const handler = (e: MouseEvent) => {
79
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
80
+ }
81
+ document.addEventListener('mousedown', handler)
82
+ return () => document.removeEventListener('mousedown', handler)
83
+ }, [])
84
+
85
+ const current =
86
+ POSITION_OPTIONS.find((o) => o.value === value) || POSITION_OPTIONS[0]
87
+ const displayDesc = hoveredDesc ?? current.desc
88
+
89
+ return (
90
+ <div ref={ref} className="relative">
91
+ <button
92
+ type="button"
93
+ onClick={() => setOpen(!open)}
94
+ className="flex items-center w-full h-7 rounded px-2 text-[11px] transition-colors"
95
+ style={{
96
+ background: 'var(--bg-tertiary)',
97
+ border: '1px solid var(--border)',
98
+ color: 'var(--text-primary)',
99
+ }}
100
+ >
101
+ <span
102
+ className="flex items-center justify-center w-4 h-4 mr-1.5"
103
+ style={{ color: 'var(--text-secondary)' }}
104
+ >
105
+ {current.icon}
106
+ </span>
107
+ <span className="flex-1 text-left">{current.label}</span>
108
+ <svg
109
+ width={8}
110
+ height={8}
111
+ viewBox="0 0 8 8"
112
+ fill="none"
113
+ style={{ color: 'var(--text-muted)', flexShrink: 0 }}
114
+ >
115
+ <path
116
+ d="M2 3l2 2 2-2"
117
+ stroke="currentColor"
118
+ strokeWidth={1.2}
119
+ strokeLinecap="round"
120
+ strokeLinejoin="round"
121
+ />
122
+ </svg>
123
+ </button>
124
+
125
+ {open && (
126
+ <div
127
+ className="absolute z-50 left-0 right-0 mt-1 rounded-md overflow-hidden shadow-lg"
128
+ style={{
129
+ background: '#252525',
130
+ border: '1px solid var(--border)',
131
+ }}
132
+ >
133
+ {POSITION_OPTIONS.map((opt) => {
134
+ const isActive = opt.value === value
135
+ return (
136
+ <button
137
+ key={opt.value}
138
+ type="button"
139
+ onClick={() => {
140
+ onChange(opt.value)
141
+ setOpen(false)
142
+ }}
143
+ onMouseEnter={() => setHoveredDesc(opt.desc)}
144
+ onMouseLeave={() => setHoveredDesc(null)}
145
+ className="flex items-center w-full px-2.5 py-1.5 text-[11px] transition-colors"
146
+ style={{
147
+ color: isActive
148
+ ? 'var(--text-primary)'
149
+ : 'var(--text-secondary)',
150
+ background: isActive
151
+ ? 'rgba(74, 158, 255, 0.08)'
152
+ : 'transparent',
153
+ }}
154
+ >
155
+ <span
156
+ className="w-4 text-center mr-2"
157
+ style={{
158
+ fontSize: 10,
159
+ color: isActive ? 'var(--accent)' : 'transparent',
160
+ }}
161
+ >
162
+ {isActive ? '✓' : ''}
163
+ </span>
164
+ <span
165
+ className="flex items-center justify-center w-4 h-4 mr-2"
166
+ style={{ color: 'var(--text-secondary)' }}
167
+ >
168
+ {opt.icon}
169
+ </span>
170
+ <span>{opt.label}</span>
171
+ </button>
172
+ )
173
+ })}
174
+ {/* Description area */}
175
+ <div
176
+ className="px-3 py-2 text-[10px] leading-relaxed"
177
+ style={{
178
+ borderTop: '1px solid var(--border)',
179
+ color: 'var(--text-muted)',
180
+ background: '#1e1e1e',
181
+ }}
182
+ >
183
+ {displayDesc}
184
+ </div>
185
+ </div>
186
+ )}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ // ─── Preset values for position offsets ─────────────────────────────
192
+
193
+ const OFFSET_PRESETS = [0, 5, 10, 15, 25, 50, 75, 100]
194
+
195
+ // ─── Offset Input (compact, for spatial layout) ────────────────────
196
+
197
+ function OffsetInput({
198
+ value,
199
+ property,
200
+ onChange,
201
+ placeholder = 'Auto',
202
+ }: {
203
+ value: string
204
+ property: string
205
+ onChange: (prop: string, val: string) => void
206
+ placeholder?: string
207
+ }) {
208
+ const isAuto = !value || value === 'auto'
209
+ const parsed = parseCSSValue(value || '0px')
210
+ const [localVal, setLocalVal] = useState(isAuto ? '' : String(parsed.number))
211
+ const [focused, setFocused] = useState(false)
212
+ const [showPresets, setShowPresets] = useState(false)
213
+ const inputRef = useRef<HTMLInputElement>(null)
214
+ const containerRef = useRef<HTMLDivElement>(null)
215
+ const popoverRef = useRef<HTMLDivElement>(null)
216
+ const isDragging = useRef(false)
217
+ const dragStartX = useRef(0)
218
+ const dragStartValue = useRef(0)
219
+
220
+ const hasChange = useEditorStore((s) => {
221
+ const sp = s.selectorPath
222
+ return sp
223
+ ? s.styleChanges.some(
224
+ (c) => c.elementSelector === sp && c.property === property,
225
+ )
226
+ : false
227
+ })
228
+
229
+ useEffect(() => {
230
+ const a = !value || value === 'auto'
231
+ if (a) {
232
+ setLocalVal('')
233
+ } else {
234
+ const p = parseCSSValue(value)
235
+ setLocalVal(String(p.number))
236
+ }
237
+ }, [value])
238
+
239
+ // Close presets on outside click
240
+ useEffect(() => {
241
+ if (!showPresets) return
242
+ const handle = (e: MouseEvent) => {
243
+ if (
244
+ containerRef.current &&
245
+ !containerRef.current.contains(e.target as Node) &&
246
+ popoverRef.current &&
247
+ !popoverRef.current.contains(e.target as Node)
248
+ ) {
249
+ setShowPresets(false)
250
+ }
251
+ }
252
+ document.addEventListener('mousedown', handle)
253
+ return () => document.removeEventListener('mousedown', handle)
254
+ }, [showPresets])
255
+
256
+ const commit = useCallback(
257
+ (num: string) => {
258
+ if (!num || num.trim() === '') {
259
+ onChange(property, 'auto')
260
+ } else {
261
+ const n = parseFloat(num)
262
+ if (!isNaN(n)) onChange(property, formatCSSValue(n, 'px'))
263
+ }
264
+ },
265
+ [onChange, property],
266
+ )
267
+
268
+ const handleKeyDown = useCallback(
269
+ (e: React.KeyboardEvent) => {
270
+ if (e.key === 'Enter') {
271
+ commit(localVal)
272
+ inputRef.current?.blur()
273
+ setShowPresets(false)
274
+ } else if (e.key === 'Escape') {
275
+ setShowPresets(false)
276
+ inputRef.current?.blur()
277
+ } else if (e.key === 'ArrowUp') {
278
+ e.preventDefault()
279
+ const inc = e.shiftKey ? 10 : 1
280
+ const next = parseFloat(localVal || '0') + inc
281
+ setLocalVal(String(next))
282
+ commit(String(next))
283
+ } else if (e.key === 'ArrowDown') {
284
+ e.preventDefault()
285
+ const dec = e.shiftKey ? 10 : 1
286
+ const next = parseFloat(localVal || '0') - dec
287
+ setLocalVal(String(next))
288
+ commit(String(next))
289
+ }
290
+ },
291
+ [localVal, commit],
292
+ )
293
+
294
+ const selectPreset = useCallback(
295
+ (val: number | 'auto') => {
296
+ if (val === 'auto') {
297
+ setLocalVal('')
298
+ onChange(property, 'auto')
299
+ } else {
300
+ setLocalVal(String(val))
301
+ onChange(property, formatCSSValue(val, 'px'))
302
+ }
303
+ setShowPresets(false)
304
+ },
305
+ [onChange, property],
306
+ )
307
+
308
+ // Drag-to-scrub (2px step per pixel of movement)
309
+ const handlePointerDown = useCallback(
310
+ (e: React.PointerEvent) => {
311
+ if ((e.target as HTMLElement).tagName === 'INPUT') return
312
+ e.preventDefault()
313
+ isDragging.current = true
314
+ dragStartX.current = e.clientX
315
+ dragStartValue.current = parseFloat(localVal || '0')
316
+ containerRef.current?.setPointerCapture(e.pointerId)
317
+ document.body.style.cursor = 'ew-resize'
318
+ document.body.style.userSelect = 'none'
319
+ },
320
+ [localVal],
321
+ )
322
+
323
+ const handlePointerMove = useCallback(
324
+ (e: React.PointerEvent) => {
325
+ if (!isDragging.current) return
326
+ const delta = e.clientX - dragStartX.current
327
+ const next = Math.round(dragStartValue.current + delta * 2)
328
+ setLocalVal(String(next))
329
+ onChange(property, formatCSSValue(next, 'px'))
330
+ },
331
+ [onChange, property],
332
+ )
333
+
334
+ const handlePointerUp = useCallback((e: React.PointerEvent) => {
335
+ if (!isDragging.current) return
336
+ isDragging.current = false
337
+ containerRef.current?.releasePointerCapture(e.pointerId)
338
+ document.body.style.cursor = ''
339
+ document.body.style.userSelect = ''
340
+ }, [])
341
+
342
+ return (
343
+ <div className="relative">
344
+ <div
345
+ ref={containerRef}
346
+ onPointerDown={handlePointerDown}
347
+ onPointerMove={handlePointerMove}
348
+ onPointerUp={handlePointerUp}
349
+ className="flex items-center justify-center h-6 rounded px-1"
350
+ style={{
351
+ background: focused || showPresets ? '#333' : 'transparent',
352
+ cursor: focused ? 'text' : 'ew-resize',
353
+ minWidth: 42,
354
+ }}
355
+ >
356
+ <input
357
+ ref={inputRef}
358
+ type="text"
359
+ inputMode="numeric"
360
+ value={localVal}
361
+ placeholder={placeholder}
362
+ onChange={(e) => setLocalVal(e.target.value)}
363
+ onFocus={() => {
364
+ setFocused(true)
365
+ setShowPresets(true)
366
+ }}
367
+ onBlur={() => {
368
+ setFocused(false)
369
+ commit(localVal)
370
+ }}
371
+ onKeyDown={handleKeyDown}
372
+ className="w-full text-center text-[11px] bg-transparent border-none outline-none"
373
+ style={{
374
+ color: hasChange
375
+ ? 'var(--accent)'
376
+ : isAuto && !focused
377
+ ? 'var(--text-muted)'
378
+ : 'var(--text-primary)',
379
+ cursor: focused ? 'text' : 'ew-resize',
380
+ }}
381
+ />
382
+ </div>
383
+
384
+ {/* Preset popover */}
385
+ {showPresets && (
386
+ <div
387
+ ref={popoverRef}
388
+ className="absolute z-50 rounded-md shadow-lg"
389
+ style={{
390
+ background: '#252525',
391
+ border: '1px solid var(--border)',
392
+ padding: '6px',
393
+ width: '140px',
394
+ left: '50%',
395
+ transform: 'translateX(-50%)',
396
+ top: '100%',
397
+ marginTop: '4px',
398
+ }}
399
+ >
400
+ {/* Auto button */}
401
+ <button
402
+ type="button"
403
+ onMouseDown={(e) => {
404
+ e.preventDefault()
405
+ selectPreset('auto')
406
+ }}
407
+ className="w-full h-6 rounded text-[10px] mb-1 transition-colors"
408
+ style={{
409
+ background: isAuto
410
+ ? 'rgba(74,158,255,0.12)'
411
+ : 'var(--bg-tertiary)',
412
+ color: isAuto ? 'var(--accent)' : 'var(--text-secondary)',
413
+ border: isAuto
414
+ ? '1px solid rgba(74,158,255,0.3)'
415
+ : '1px solid var(--border)',
416
+ cursor: 'pointer',
417
+ }}
418
+ >
419
+ Auto
420
+ </button>
421
+
422
+ {/* Slider track visual */}
423
+ <div
424
+ className="relative h-1.5 rounded-full mx-1 mb-2 mt-1"
425
+ style={{ background: '#3a3a3a' }}
426
+ >
427
+ <div
428
+ className="absolute h-full rounded-full"
429
+ style={{
430
+ background: 'var(--accent)',
431
+ width: isAuto
432
+ ? '0%'
433
+ : `${Math.min(100, (parsed.number / 100) * 100)}%`,
434
+ transition: 'width 0.15s',
435
+ }}
436
+ />
437
+ {/* Thumb */}
438
+ {!isAuto && (
439
+ <div
440
+ className="absolute top-1/2 w-2.5 h-2.5 rounded-full border-2 border-white"
441
+ style={{
442
+ left: `${Math.min(100, (parsed.number / 100) * 100)}%`,
443
+ transform: 'translate(-50%, -50%)',
444
+ background: 'var(--accent)',
445
+ boxShadow: '0 0 3px rgba(0,0,0,0.4)',
446
+ }}
447
+ />
448
+ )}
449
+ </div>
450
+
451
+ {/* Preset grid */}
452
+ <div className="grid grid-cols-4 gap-1">
453
+ {OFFSET_PRESETS.map((val) => {
454
+ const isActive = !isAuto && parsed.number === val
455
+ return (
456
+ <button
457
+ key={val}
458
+ type="button"
459
+ onMouseDown={(e) => {
460
+ e.preventDefault()
461
+ selectPreset(val)
462
+ }}
463
+ className="h-5 rounded text-[9px] tabular-nums transition-colors"
464
+ style={{
465
+ background: isActive
466
+ ? 'rgba(74,158,255,0.12)'
467
+ : 'var(--bg-tertiary)',
468
+ color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
469
+ border: isActive
470
+ ? '1px solid rgba(74,158,255,0.3)'
471
+ : '1px solid transparent',
472
+ cursor: 'pointer',
473
+ }}
474
+ >
475
+ {val}
476
+ </button>
477
+ )
478
+ })}
479
+ </div>
480
+ </div>
481
+ )}
482
+ </div>
483
+ )
484
+ }
485
+
486
+ // ─── Spatial Offset Widget (visual-editor-style) ─────────────────────────
487
+
488
+ function SpatialOffsetWidget({
489
+ top,
490
+ right,
491
+ bottom,
492
+ left,
493
+ onChange,
494
+ }: {
495
+ top: string
496
+ right: string
497
+ bottom: string
498
+ left: string
499
+ onChange: (prop: string, val: string) => void
500
+ }) {
501
+ return (
502
+ <div
503
+ className="relative flex flex-col items-center"
504
+ style={{ padding: '2px 0' }}
505
+ >
506
+ {/* Top */}
507
+ <div className="flex justify-center mb-0.5">
508
+ <OffsetInput value={top} property="top" onChange={onChange} />
509
+ </div>
510
+
511
+ {/* Middle row: Left — Element — Right */}
512
+ <div className="flex items-center w-full">
513
+ <div className="flex-1 flex justify-end pr-1">
514
+ <OffsetInput value={left} property="left" onChange={onChange} />
515
+ </div>
516
+
517
+ {/* Center element visual */}
518
+ <div
519
+ className="flex-shrink-0 rounded"
520
+ style={{
521
+ width: 56,
522
+ height: 36,
523
+ background: '#3a3a3a',
524
+ border: '1.5px solid #555',
525
+ }}
526
+ />
527
+
528
+ <div className="flex-1 flex justify-start pl-1">
529
+ <OffsetInput value={right} property="right" onChange={onChange} />
530
+ </div>
531
+ </div>
532
+
533
+ {/* Bottom */}
534
+ <div className="flex justify-center mt-0.5">
535
+ <OffsetInput value={bottom} property="bottom" onChange={onChange} />
536
+ </div>
537
+ </div>
538
+ )
539
+ }
540
+
541
+ // ─── z-Index Input ─────────────────────────────────────────────────
542
+
543
+ function ZIndexInput({
544
+ value,
545
+ onChange,
546
+ }: {
547
+ value: string
548
+ onChange: (prop: string, val: string) => void
549
+ }) {
550
+ const isAuto = !value || value === 'auto' || value === '0'
551
+ const [localVal, setLocalVal] = useState(isAuto ? '' : value)
552
+ const [isAutoMode, setIsAutoMode] = useState(isAuto)
553
+ const inputRef = useRef<HTMLInputElement>(null)
554
+
555
+ useEffect(() => {
556
+ const a = !value || value === 'auto'
557
+ setIsAutoMode(a)
558
+ setLocalVal(a ? '' : value)
559
+ }, [value])
560
+
561
+ const commit = useCallback(
562
+ (v: string) => {
563
+ if (!v || v.trim() === '') {
564
+ onChange('zIndex', 'auto')
565
+ setIsAutoMode(true)
566
+ } else {
567
+ const n = parseInt(v, 10)
568
+ if (!isNaN(n)) {
569
+ onChange('zIndex', String(n))
570
+ setIsAutoMode(false)
571
+ }
572
+ }
573
+ },
574
+ [onChange],
575
+ )
576
+
577
+ return (
578
+ <div className="flex items-center gap-2">
579
+ <span
580
+ className="text-[11px] flex-shrink-0"
581
+ style={{ color: 'var(--text-secondary)' }}
582
+ >
583
+ z-Index
584
+ </span>
585
+ <div
586
+ className="flex items-center h-6 rounded flex-1 overflow-hidden"
587
+ style={{
588
+ background: 'var(--bg-tertiary)',
589
+ border: '1px solid var(--border)',
590
+ }}
591
+ >
592
+ <input
593
+ ref={inputRef}
594
+ type="text"
595
+ inputMode="numeric"
596
+ value={isAutoMode ? '' : localVal}
597
+ placeholder="Auto"
598
+ onChange={(e) => {
599
+ setLocalVal(e.target.value)
600
+ setIsAutoMode(false)
601
+ }}
602
+ onBlur={() => commit(localVal)}
603
+ onKeyDown={(e) => {
604
+ if (e.key === 'Enter') {
605
+ commit(localVal)
606
+ inputRef.current?.blur()
607
+ } else if (e.key === 'ArrowUp') {
608
+ e.preventDefault()
609
+ const next = parseInt(localVal || '0', 10) + (e.shiftKey ? 10 : 1)
610
+ setLocalVal(String(next))
611
+ setIsAutoMode(false)
612
+ commit(String(next))
613
+ } else if (e.key === 'ArrowDown') {
614
+ e.preventDefault()
615
+ const next = parseInt(localVal || '0', 10) - (e.shiftKey ? 10 : 1)
616
+ setLocalVal(String(next))
617
+ setIsAutoMode(false)
618
+ commit(String(next))
619
+ }
620
+ }}
621
+ className="flex-1 min-w-0 h-full px-2 text-[11px] bg-transparent border-none outline-none"
622
+ style={{
623
+ color: isAutoMode ? 'var(--text-muted)' : 'var(--text-primary)',
624
+ }}
625
+ />
626
+ <button
627
+ type="button"
628
+ onClick={() => {
629
+ if (isAutoMode) {
630
+ setIsAutoMode(false)
631
+ setLocalVal('0')
632
+ onChange('zIndex', '0')
633
+ inputRef.current?.focus()
634
+ } else {
635
+ setIsAutoMode(true)
636
+ setLocalVal('')
637
+ onChange('zIndex', 'auto')
638
+ }
639
+ }}
640
+ className="flex-shrink-0 h-full px-2 text-[10px] transition-colors bg-transparent border-none outline-none cursor-pointer"
641
+ style={{
642
+ color: isAutoMode ? 'var(--accent)' : 'var(--text-muted)',
643
+ borderLeft: '1px solid var(--border)',
644
+ }}
645
+ >
646
+ Auto
647
+ </button>
648
+ </div>
649
+ </div>
650
+ )
651
+ }
652
+
653
+ // ─── Float & Clear Section ─────────────────────────────────────────
654
+
655
+ function FloatClearSection({
656
+ float: floatVal,
657
+ clear: clearVal,
658
+ onChange,
659
+ }: {
660
+ float: string
661
+ clear: string
662
+ onChange: (prop: string, val: string) => void
663
+ }) {
664
+ const [open, setOpen] = useState(false)
665
+
666
+ return (
667
+ <div>
668
+ <button
669
+ type="button"
670
+ onClick={() => setOpen(!open)}
671
+ className="flex items-center gap-1 w-full text-[10px] py-1 transition-colors"
672
+ style={{ color: 'var(--text-muted)' }}
673
+ >
674
+ <span
675
+ style={{
676
+ display: 'inline-block',
677
+ fontSize: 8,
678
+ transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
679
+ transition: 'transform 0.15s',
680
+ }}
681
+ >
682
+
683
+ </span>
684
+ Float and clear
685
+ </button>
686
+
687
+ {open && (
688
+ <div className="space-y-2 pt-1 pb-1">
689
+ <div className="flex items-center gap-2">
690
+ <span
691
+ className="text-[11px] w-10 flex-shrink-0"
692
+ style={{ color: 'var(--text-secondary)' }}
693
+ >
694
+ Float
695
+ </span>
696
+ <div
697
+ className="flex-1 flex rounded overflow-hidden"
698
+ style={{ border: '1px solid var(--border)' }}
699
+ >
700
+ {FLOAT_OPTIONS.map((opt) => (
701
+ <button
702
+ key={opt.value}
703
+ type="button"
704
+ onClick={() => onChange('float', opt.value)}
705
+ className="flex-1 h-6 text-[10px] transition-colors"
706
+ style={{
707
+ background:
708
+ opt.value === floatVal ? '#3a3a3a' : 'var(--bg-tertiary)',
709
+ color:
710
+ opt.value === floatVal
711
+ ? 'var(--text-primary)'
712
+ : 'var(--text-muted)',
713
+ }}
714
+ >
715
+ {opt.label}
716
+ </button>
717
+ ))}
718
+ </div>
719
+ </div>
720
+ <div className="flex items-center gap-2">
721
+ <span
722
+ className="text-[11px] w-10 flex-shrink-0"
723
+ style={{ color: 'var(--text-secondary)' }}
724
+ >
725
+ Clear
726
+ </span>
727
+ <div
728
+ className="flex-1 flex rounded overflow-hidden"
729
+ style={{ border: '1px solid var(--border)' }}
730
+ >
731
+ {CLEAR_OPTIONS.map((opt) => (
732
+ <button
733
+ key={opt.value}
734
+ type="button"
735
+ onClick={() => onChange('clear', opt.value)}
736
+ className="flex-1 h-6 text-[10px] transition-colors"
737
+ style={{
738
+ background:
739
+ opt.value === clearVal ? '#3a3a3a' : 'var(--bg-tertiary)',
740
+ color:
741
+ opt.value === clearVal
742
+ ? 'var(--text-primary)'
743
+ : 'var(--text-muted)',
744
+ }}
745
+ >
746
+ {opt.label}
747
+ </button>
748
+ ))}
749
+ </div>
750
+ </div>
751
+ </div>
752
+ )}
753
+ </div>
754
+ )
755
+ }
756
+
757
+ // ─── Main PositionSection ──────────────────────────────────────────
758
+
759
+ const POSITION_PROPERTIES = [
760
+ 'position',
761
+ 'top',
762
+ 'right',
763
+ 'bottom',
764
+ 'left',
765
+ 'zIndex',
766
+ 'float',
767
+ 'clear',
768
+ ]
769
+
770
+ export function PositionSection() {
771
+ const computedStyles = useEditorStore((state) => state.computedStyles)
772
+ const { applyChange, resetProperty } = useChangeTracker()
773
+
774
+ const hasChanges = useEditorStore((s) => {
775
+ const sp = s.selectorPath
776
+ if (!sp) return false
777
+ return s.styleChanges.some(
778
+ (c) =>
779
+ c.elementSelector === sp && POSITION_PROPERTIES.includes(c.property),
780
+ )
781
+ })
782
+
783
+ const handleResetAll = useCallback(() => {
784
+ const { selectorPath, styleChanges } = useEditorStore.getState()
785
+ if (!selectorPath) return
786
+ const matching = styleChanges.filter(
787
+ (c) =>
788
+ c.elementSelector === selectorPath &&
789
+ POSITION_PROPERTIES.includes(c.property),
790
+ )
791
+ for (const c of matching) resetProperty(c.property)
792
+ }, [resetProperty])
793
+
794
+ const handleChange = (property: string, value: string) => {
795
+ applyChange(property, value)
796
+ }
797
+
798
+ const position = computedStyles.position || 'static'
799
+ const isPositioned = position !== 'static'
800
+
801
+ return (
802
+ <SectionHeader
803
+ title="Position"
804
+ defaultOpen={true}
805
+ hasChanges={hasChanges}
806
+ onReset={handleResetAll}
807
+ >
808
+ {/* Position label + dropdown */}
809
+ <div className="flex items-center gap-2">
810
+ <span
811
+ className="text-[11px] flex-shrink-0"
812
+ style={{
813
+ color: isPositioned ? 'var(--accent)' : 'var(--text-secondary)',
814
+ }}
815
+ >
816
+ Position
817
+ </span>
818
+ <div className="flex-1">
819
+ <PositionDropdown
820
+ value={position}
821
+ onChange={(v) => handleChange('position', v)}
822
+ />
823
+ </div>
824
+ </div>
825
+
826
+ {isPositioned && (
827
+ <>
828
+ {/* Spatial offset widget */}
829
+ <div
830
+ className="mt-1 rounded-md px-2 py-2"
831
+ style={{ background: '#1a1a1a', border: '1px solid var(--border)' }}
832
+ >
833
+ <SpatialOffsetWidget
834
+ top={computedStyles.top || 'auto'}
835
+ right={computedStyles.right || 'auto'}
836
+ bottom={computedStyles.bottom || 'auto'}
837
+ left={computedStyles.left || 'auto'}
838
+ onChange={handleChange}
839
+ />
840
+ </div>
841
+
842
+ {/* z-Index */}
843
+ <div className="mt-2">
844
+ <ZIndexInput
845
+ value={computedStyles.zIndex || 'auto'}
846
+ onChange={handleChange}
847
+ />
848
+ </div>
849
+ </>
850
+ )}
851
+
852
+ {/* Float & Clear */}
853
+ <div
854
+ className="mt-1"
855
+ style={{ borderTop: '1px solid var(--border)', paddingTop: 6 }}
856
+ >
857
+ <FloatClearSection
858
+ float={computedStyles.float || 'none'}
859
+ clear={computedStyles.clear || 'none'}
860
+ onChange={handleChange}
861
+ />
862
+ </div>
863
+ </SectionHeader>
864
+ )
865
+ }