@aprovan/bobbin 0.1.0-dev.03aaf5b

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 (45) hide show
  1. package/.turbo/turbo-build.log +16 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +402 -0
  4. package/dist/index.js +3704 -0
  5. package/package.json +30 -0
  6. package/src/Bobbin.tsx +89 -0
  7. package/src/components/EditPanel/EditPanel.tsx +376 -0
  8. package/src/components/EditPanel/controls/ColorPicker.tsx +138 -0
  9. package/src/components/EditPanel/controls/QuickSelectDropdown.tsx +142 -0
  10. package/src/components/EditPanel/controls/SliderInput.tsx +94 -0
  11. package/src/components/EditPanel/controls/SpacingControl.tsx +285 -0
  12. package/src/components/EditPanel/controls/ToggleGroup.tsx +37 -0
  13. package/src/components/EditPanel/controls/TokenDropdown.tsx +33 -0
  14. package/src/components/EditPanel/sections/AnnotationSection.tsx +136 -0
  15. package/src/components/EditPanel/sections/BackgroundSection.tsx +79 -0
  16. package/src/components/EditPanel/sections/EffectsSection.tsx +85 -0
  17. package/src/components/EditPanel/sections/LayoutSection.tsx +224 -0
  18. package/src/components/EditPanel/sections/SectionWrapper.tsx +57 -0
  19. package/src/components/EditPanel/sections/SizeSection.tsx +166 -0
  20. package/src/components/EditPanel/sections/SpacingSection.tsx +69 -0
  21. package/src/components/EditPanel/sections/TypographySection.tsx +148 -0
  22. package/src/components/Inspector/Inspector.tsx +221 -0
  23. package/src/components/Overlay/ControlHandles.tsx +572 -0
  24. package/src/components/Overlay/MarginPaddingOverlay.tsx +229 -0
  25. package/src/components/Overlay/SelectionOverlay.tsx +73 -0
  26. package/src/components/Pill/Pill.tsx +155 -0
  27. package/src/components/ThemeToggle/ThemeToggle.tsx +72 -0
  28. package/src/core/changeSerializer.ts +139 -0
  29. package/src/core/useBobbin.ts +399 -0
  30. package/src/core/useChangeTracker.ts +186 -0
  31. package/src/core/useClipboard.ts +21 -0
  32. package/src/core/useElementSelection.ts +146 -0
  33. package/src/index.ts +46 -0
  34. package/src/tokens/borders.ts +19 -0
  35. package/src/tokens/colors.ts +150 -0
  36. package/src/tokens/index.ts +37 -0
  37. package/src/tokens/shadows.ts +10 -0
  38. package/src/tokens/spacing.ts +37 -0
  39. package/src/tokens/typography.ts +51 -0
  40. package/src/types.ts +157 -0
  41. package/src/utils/animation.ts +40 -0
  42. package/src/utils/dom.ts +36 -0
  43. package/src/utils/selectors.ts +76 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +10 -0
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@aprovan/bobbin",
3
+ "version": "0.1.0-dev.03aaf5b",
4
+ "description": "Visual DOM editor",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./styles.css": "./dist/styles.css"
14
+ },
15
+ "peerDependencies": {
16
+ "react": "^18.0.0 || ^19.0.0",
17
+ "react-dom": "^18.0.0 || ^19.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/react": "^18.3.27",
21
+ "@types/react-dom": "^18.3.7",
22
+ "tsup": "^8.3.5",
23
+ "typescript": "^5.7.3"
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch",
28
+ "typecheck": "tsc --noEmit"
29
+ }
30
+ }
package/src/Bobbin.tsx ADDED
@@ -0,0 +1,89 @@
1
+ import { createPortal } from 'react-dom';
2
+ import type { BobbinProps } from './types';
3
+ import { useBobbin } from './core/useBobbin';
4
+ import { Pill } from './components/Pill/Pill';
5
+ import { SelectionOverlay } from './components/Overlay/SelectionOverlay';
6
+ import { ControlHandles } from './components/Overlay/ControlHandles';
7
+ import { MarginPaddingOverlay } from './components/Overlay/MarginPaddingOverlay';
8
+ import { EditPanel } from './components/EditPanel/EditPanel';
9
+ import { Inspector } from './components/Inspector/Inspector';
10
+
11
+ export interface BobbinComponentProps extends BobbinProps {
12
+ /** Show inspector panel */
13
+ showInspector?: boolean;
14
+ }
15
+
16
+ export function Bobbin(props: BobbinComponentProps) {
17
+ const { showInspector = false, ...bobbinProps } = props;
18
+
19
+ const bobbin = useBobbin(bobbinProps);
20
+ const { zIndex = 9999, pillContainer } = bobbinProps;
21
+
22
+ return createPortal(
23
+ <div data-bobbin="root">
24
+ {/* Floating pill */}
25
+ <Pill
26
+ state={bobbin}
27
+ actions={bobbin}
28
+ position={bobbinProps.position}
29
+ container={pillContainer ?? bobbinProps.container}
30
+ zIndex={zIndex}
31
+ />
32
+
33
+ {/* Selection overlays */}
34
+ {bobbin.isActive && (
35
+ <SelectionOverlay
36
+ hoveredElement={bobbin.hoveredElement}
37
+ selectedElement={bobbin.selectedElement}
38
+ zIndex={zIndex - 10}
39
+ />
40
+ )}
41
+
42
+ {/* Control handles */}
43
+ {bobbin.selectedElement && (
44
+ <ControlHandles
45
+ selectedElement={bobbin.selectedElement}
46
+ actions={bobbin}
47
+ clipboard={bobbin.clipboard}
48
+ zIndex={zIndex}
49
+ />
50
+ )}
51
+
52
+ {/* Margin/padding overlay */}
53
+ {bobbin.selectedElement && bobbin.showMarginPadding && (
54
+ <MarginPaddingOverlay
55
+ selectedElement={bobbin.selectedElement}
56
+ zIndex={zIndex - 5}
57
+ />
58
+ )}
59
+
60
+ {/* Edit panel */}
61
+ {bobbin.selectedElement && bobbin.activePanel === 'style' && (
62
+ <EditPanel
63
+ selectedElement={bobbin.selectedElement}
64
+ actions={bobbin}
65
+ tokens={bobbin.tokens}
66
+ onClose={bobbin.clearSelection}
67
+ showMarginPadding={bobbin.showMarginPadding}
68
+ zIndex={zIndex}
69
+ theme={bobbin.theme}
70
+ onThemeToggle={bobbin.toggleTheme}
71
+ changes={bobbin.changes}
72
+ annotations={bobbin.annotations}
73
+ onReset={bobbin.resetChanges}
74
+ />
75
+ )}
76
+
77
+ {/* Inspector */}
78
+ {bobbin.selectedElement && (showInspector || bobbin.activePanel === 'inspector') && (
79
+ <Inspector
80
+ selectedElement={bobbin.selectedElement}
81
+ onSelectElement={(el) => bobbin.selectElement(el)}
82
+ onClose={() => bobbin.setActivePanel(null)}
83
+ zIndex={zIndex}
84
+ />
85
+ )}
86
+ </div>,
87
+ document.body
88
+ );
89
+ }
@@ -0,0 +1,376 @@
1
+ import { useState, useMemo } from 'react';
2
+ import type { SelectedElement, BobbinActions, DesignTokens, Change, StyleChange, Annotation } from '../../types';
3
+ import { LayoutSection } from './sections/LayoutSection';
4
+ import { SpacingSection } from './sections/SpacingSection';
5
+ import { SizeSection } from './sections/SizeSection';
6
+ import { TypographySection } from './sections/TypographySection';
7
+ import { BackgroundSection } from './sections/BackgroundSection';
8
+ import { EffectsSection } from './sections/EffectsSection';
9
+ import { AnnotationSection } from './sections/AnnotationSection';
10
+
11
+ // Theme icons
12
+ const SunIcon = () => (
13
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
14
+ <circle cx="12" cy="12" r="5" />
15
+ <line x1="12" y1="1" x2="12" y2="3" />
16
+ <line x1="12" y1="21" x2="12" y2="23" />
17
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
18
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
19
+ <line x1="1" y1="12" x2="3" y2="12" />
20
+ <line x1="21" y1="12" x2="23" y2="12" />
21
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
22
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
23
+ </svg>
24
+ );
25
+
26
+ const MoonIcon = () => (
27
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
28
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
29
+ </svg>
30
+ );
31
+
32
+ const MonitorIcon = () => (
33
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
34
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
35
+ <line x1="8" y1="21" x2="16" y2="21" />
36
+ <line x1="12" y1="17" x2="12" y2="21" />
37
+ </svg>
38
+ );
39
+
40
+ // Spacing visualization icon
41
+ const SpacingIcon = () => (
42
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
43
+ <rect x="3" y="3" width="18" height="18" rx="2" />
44
+ <rect x="7" y="7" width="10" height="10" rx="1" />
45
+ </svg>
46
+ );
47
+
48
+ // Reset icon
49
+ const ResetIcon = () => (
50
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
51
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
52
+ <path d="M3 3v5h5" />
53
+ </svg>
54
+ );
55
+
56
+ interface EditPanelProps {
57
+ selectedElement: SelectedElement;
58
+ actions: BobbinActions;
59
+ tokens: DesignTokens;
60
+ onClose: () => void;
61
+ showMarginPadding: boolean;
62
+ zIndex?: number;
63
+ theme: 'light' | 'dark' | 'system';
64
+ onThemeToggle: () => void;
65
+ changes: Change[];
66
+ annotations: Annotation[];
67
+ onReset: () => void;
68
+ }
69
+
70
+ type Section = 'layout' | 'spacing' | 'size' | 'typography' | 'background' | 'effects' | 'annotation';
71
+
72
+ // Map CSS properties to sections
73
+ const propertySectionMap: Record<string, Section> = {
74
+ 'display': 'layout',
75
+ 'flex-direction': 'layout',
76
+ 'justify-content': 'layout',
77
+ 'align-items': 'layout',
78
+ 'flex-wrap': 'layout',
79
+ 'gap': 'layout',
80
+ 'margin-top': 'spacing',
81
+ 'margin-right': 'spacing',
82
+ 'margin-bottom': 'spacing',
83
+ 'margin-left': 'spacing',
84
+ 'padding-top': 'spacing',
85
+ 'padding-right': 'spacing',
86
+ 'padding-bottom': 'spacing',
87
+ 'padding-left': 'spacing',
88
+ 'width': 'size',
89
+ 'height': 'size',
90
+ 'min-width': 'size',
91
+ 'min-height': 'size',
92
+ 'max-width': 'size',
93
+ 'max-height': 'size',
94
+ 'font-size': 'typography',
95
+ 'font-weight': 'typography',
96
+ 'font-family': 'typography',
97
+ 'line-height': 'typography',
98
+ 'letter-spacing': 'typography',
99
+ 'text-align': 'typography',
100
+ 'color': 'typography',
101
+ 'background-color': 'background',
102
+ 'background': 'background',
103
+ 'border-radius': 'effects',
104
+ 'box-shadow': 'effects',
105
+ 'border': 'effects',
106
+ 'border-width': 'effects',
107
+ 'opacity': 'effects',
108
+ };
109
+
110
+ export function EditPanel({
111
+ selectedElement,
112
+ actions,
113
+ tokens,
114
+ onClose,
115
+ showMarginPadding,
116
+ zIndex = 9999,
117
+ theme,
118
+ onThemeToggle,
119
+ changes,
120
+ annotations,
121
+ onReset,
122
+ }: EditPanelProps) {
123
+ const [expandedSections, setExpandedSections] = useState<Set<Section>>(
124
+ new Set(['annotation', 'layout', 'spacing', 'typography'])
125
+ );
126
+
127
+ // Calculate which sections have changes for the current element
128
+ const changedSections = useMemo(() => {
129
+ const sections = new Set<Section>();
130
+ const elementChanges = changes.filter(
131
+ (c) => c.target.path === selectedElement.path && c.type === 'style'
132
+ ) as StyleChange[];
133
+
134
+ for (const change of elementChanges) {
135
+ const property = change.after.property;
136
+ const section = propertySectionMap[property];
137
+ if (section) {
138
+ sections.add(section);
139
+ }
140
+ }
141
+ return sections;
142
+ }, [changes, selectedElement.path]);
143
+
144
+ // Get changed properties for spacing section highlighting
145
+ const changedSpacingProps = useMemo(() => {
146
+ const props: Record<string, boolean> = {};
147
+ const elementChanges = changes.filter(
148
+ (c) => c.target.path === selectedElement.path && c.type === 'style'
149
+ ) as StyleChange[];
150
+
151
+ for (const change of elementChanges) {
152
+ const prop = change.after.property;
153
+ if (prop.startsWith('margin-') || prop.startsWith('padding-')) {
154
+ props[prop] = true;
155
+ }
156
+ }
157
+ return props;
158
+ }, [changes, selectedElement.path]);
159
+
160
+ const toggleSection = (section: Section) => {
161
+ setExpandedSections(prev => {
162
+ const next = new Set(prev);
163
+ if (next.has(section)) {
164
+ next.delete(section);
165
+ } else {
166
+ next.add(section);
167
+ }
168
+ return next;
169
+ });
170
+ };
171
+
172
+ const computedStyle = window.getComputedStyle(selectedElement.element);
173
+
174
+ const themeIcons = {
175
+ light: <SunIcon />,
176
+ dark: <MoonIcon />,
177
+ system: <MonitorIcon />,
178
+ };
179
+
180
+ const hasAnyChanges = changes.some((c) => c.target.path === selectedElement.path);
181
+
182
+ // ShadCN-style: white background, subtle borders, clean typography
183
+ return (
184
+ <div
185
+ data-bobbin="edit-panel"
186
+ style={{
187
+ position: 'fixed',
188
+ top: '16px',
189
+ right: '16px',
190
+ width: '280px',
191
+ maxHeight: 'calc(100vh - 32px)',
192
+ backgroundColor: '#fafafa',
193
+ borderRadius: '8px',
194
+ border: '1px solid #e4e4e7',
195
+ boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
196
+ zIndex,
197
+ overflow: 'hidden',
198
+ display: 'flex',
199
+ flexDirection: 'column',
200
+ color: '#18181b',
201
+ fontFamily: 'system-ui, -apple-system, sans-serif',
202
+ fontSize: '13px',
203
+ }}
204
+ >
205
+ {/* Header */}
206
+ <div
207
+ style={{
208
+ padding: '10px 12px',
209
+ borderBottom: '1px solid #e4e4e7',
210
+ display: 'flex',
211
+ alignItems: 'center',
212
+ justifyContent: 'space-between',
213
+ backgroundColor: '#ffffff',
214
+ }}
215
+ >
216
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
217
+ <span style={{ fontWeight: 500, fontSize: '12px' }}>{selectedElement.tagName}</span>
218
+ {selectedElement.id && (
219
+ <span style={{ color: '#71717a', fontSize: '11px' }}>#{selectedElement.id}</span>
220
+ )}
221
+ {selectedElement.classList.length > 0 && (
222
+ <span style={{ color: '#a1a1aa', fontSize: '10px' }}>
223
+ .{selectedElement.classList.slice(0, 2).join('.')}
224
+ {selectedElement.classList.length > 2 && '...'}
225
+ </span>
226
+ )}
227
+ </div>
228
+ <div style={{ display: 'flex', gap: '4px' }}>
229
+ {/* Theme toggle */}
230
+ <button
231
+ onClick={onThemeToggle}
232
+ style={{
233
+ padding: '4px 6px',
234
+ borderRadius: '4px',
235
+ border: '1px solid #e4e4e7',
236
+ backgroundColor: '#ffffff',
237
+ cursor: 'pointer',
238
+ fontSize: '12px',
239
+ }}
240
+ title={`Theme: ${theme}`}
241
+ >
242
+ {themeIcons[theme]}
243
+ </button>
244
+ {/* Toggle margin/padding view */}
245
+ <button
246
+ onClick={actions.toggleMarginPadding}
247
+ style={{
248
+ padding: '4px 6px',
249
+ borderRadius: '4px',
250
+ border: '1px solid #e4e4e7',
251
+ backgroundColor: showMarginPadding ? '#18181b' : '#ffffff',
252
+ color: showMarginPadding ? '#fafafa' : '#71717a',
253
+ cursor: 'pointer',
254
+ fontSize: '10px',
255
+ display: 'flex',
256
+ alignItems: 'center',
257
+ justifyContent: 'center',
258
+ }}
259
+ title="Toggle spacing visualization"
260
+ >
261
+ <SpacingIcon />
262
+ </button>
263
+ {/* Reset button */}
264
+ {hasAnyChanges && (
265
+ <button
266
+ onClick={onReset}
267
+ style={{
268
+ padding: '4px 6px',
269
+ borderRadius: '4px',
270
+ border: '1px solid #e4e4e7',
271
+ backgroundColor: '#ffffff',
272
+ color: '#71717a',
273
+ cursor: 'pointer',
274
+ fontSize: '10px',
275
+ display: 'flex',
276
+ alignItems: 'center',
277
+ justifyContent: 'center',
278
+ }}
279
+ title="Reset all changes"
280
+ >
281
+ <ResetIcon />
282
+ </button>
283
+ )}
284
+ {/* Close */}
285
+ <button
286
+ onClick={onClose}
287
+ style={{
288
+ width: '22px',
289
+ height: '22px',
290
+ borderRadius: '4px',
291
+ border: '1px solid #e4e4e7',
292
+ backgroundColor: '#ffffff',
293
+ color: '#71717a',
294
+ cursor: 'pointer',
295
+ display: 'flex',
296
+ alignItems: 'center',
297
+ justifyContent: 'center',
298
+ fontSize: '12px',
299
+ }}
300
+ >
301
+ ×
302
+ </button>
303
+ </div>
304
+ </div>
305
+
306
+ {/* Scrollable content */}
307
+ <div style={{ flex: 1, overflow: 'auto', padding: '8px 0' }}>
308
+ {/* Annotation section moved to top and default open */}
309
+ <AnnotationSection
310
+ expanded={expandedSections.has('annotation')}
311
+ onToggle={() => toggleSection('annotation')}
312
+ onAnnotate={actions.annotate}
313
+ existingAnnotation={
314
+ annotations.find((a) => a.elementPath === selectedElement.path)?.content
315
+ }
316
+ hasChanges={annotations.some((a) => a.elementPath === selectedElement.path)}
317
+ />
318
+
319
+ <LayoutSection
320
+ expanded={expandedSections.has('layout')}
321
+ onToggle={() => toggleSection('layout')}
322
+ computedStyle={computedStyle}
323
+ onApplyStyle={actions.applyStyle}
324
+ tokens={tokens}
325
+ hasChanges={changedSections.has('layout')}
326
+ />
327
+
328
+ <SpacingSection
329
+ expanded={expandedSections.has('spacing')}
330
+ onToggle={() => toggleSection('spacing')}
331
+ computedStyle={computedStyle}
332
+ onApplyStyle={actions.applyStyle}
333
+ tokens={tokens}
334
+ hasChanges={changedSections.has('spacing')}
335
+ changedProps={changedSpacingProps}
336
+ />
337
+
338
+ <SizeSection
339
+ expanded={expandedSections.has('size')}
340
+ onToggle={() => toggleSection('size')}
341
+ computedStyle={computedStyle}
342
+ onApplyStyle={actions.applyStyle}
343
+ tokens={tokens}
344
+ hasChanges={changedSections.has('size')}
345
+ />
346
+
347
+ <TypographySection
348
+ expanded={expandedSections.has('typography')}
349
+ onToggle={() => toggleSection('typography')}
350
+ computedStyle={computedStyle}
351
+ onApplyStyle={actions.applyStyle}
352
+ tokens={tokens}
353
+ hasChanges={changedSections.has('typography')}
354
+ />
355
+
356
+ <BackgroundSection
357
+ expanded={expandedSections.has('background')}
358
+ onToggle={() => toggleSection('background')}
359
+ computedStyle={computedStyle}
360
+ onApplyStyle={actions.applyStyle}
361
+ tokens={tokens}
362
+ hasChanges={changedSections.has('background')}
363
+ />
364
+
365
+ <EffectsSection
366
+ expanded={expandedSections.has('effects')}
367
+ onToggle={() => toggleSection('effects')}
368
+ computedStyle={computedStyle}
369
+ onApplyStyle={actions.applyStyle}
370
+ tokens={tokens}
371
+ hasChanges={changedSections.has('effects')}
372
+ />
373
+ </div>
374
+ </div>
375
+ );
376
+ }
@@ -0,0 +1,138 @@
1
+ import { useState, useMemo } from 'react';
2
+
3
+ interface ColorPickerProps {
4
+ value: string;
5
+ colors: Record<string, Record<string, string>>;
6
+ onChange: (value: string) => void;
7
+ }
8
+
9
+ export function ColorPicker({ value, colors, onChange }: ColorPickerProps) {
10
+ const [isExpanded, setIsExpanded] = useState(false);
11
+
12
+ // Flatten colors for display
13
+ const colorGrid = useMemo(() => {
14
+ const grid: Array<{ name: string; shade: string; value: string }> = [];
15
+ for (const [name, shades] of Object.entries(colors)) {
16
+ if (typeof shades === 'object') {
17
+ for (const [shade, colorValue] of Object.entries(shades)) {
18
+ grid.push({ name, shade, value: colorValue });
19
+ }
20
+ }
21
+ }
22
+ return grid;
23
+ }, [colors]);
24
+
25
+ // Get common shades for compact view
26
+ const commonShades = ['500', '600', '700'];
27
+ const compactColors = useMemo(() => {
28
+ return colorGrid.filter(c => commonShades.includes(c.shade));
29
+ }, [colorGrid]);
30
+
31
+ return (
32
+ <div>
33
+ {/* Current color preview */}
34
+ <div
35
+ style={{
36
+ display: 'flex',
37
+ alignItems: 'center',
38
+ gap: '6px',
39
+ marginBottom: '6px',
40
+ }}
41
+ >
42
+ <div
43
+ style={{
44
+ width: '22px',
45
+ height: '22px',
46
+ borderRadius: '4px',
47
+ backgroundColor: value,
48
+ border: '1px solid #e4e4e7',
49
+ boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.06)',
50
+ }}
51
+ />
52
+ <input
53
+ type="text"
54
+ value={value}
55
+ onChange={(e) => onChange(e.target.value)}
56
+ style={{
57
+ flex: 1,
58
+ backgroundColor: '#ffffff',
59
+ border: '1px solid #e4e4e7',
60
+ borderRadius: '4px',
61
+ padding: '4px 8px',
62
+ color: '#18181b',
63
+ fontSize: '11px',
64
+ fontFamily: 'ui-monospace, monospace',
65
+ }}
66
+ />
67
+ <button
68
+ onClick={() => setIsExpanded(!isExpanded)}
69
+ style={{
70
+ padding: '4px 6px',
71
+ borderRadius: '4px',
72
+ border: '1px solid #e4e4e7',
73
+ backgroundColor: '#ffffff',
74
+ color: '#71717a',
75
+ fontSize: '10px',
76
+ cursor: 'pointer',
77
+ }}
78
+ >
79
+ {isExpanded ? '▲' : '▼'}
80
+ </button>
81
+ </div>
82
+
83
+ {/* Color grid */}
84
+ {isExpanded && (
85
+ <div
86
+ style={{
87
+ display: 'grid',
88
+ gridTemplateColumns: 'repeat(11, 1fr)',
89
+ gap: '2px',
90
+ padding: '6px',
91
+ backgroundColor: '#fafafa',
92
+ border: '1px solid #e4e4e7',
93
+ borderRadius: '8px',
94
+ maxHeight: '200px',
95
+ overflow: 'auto',
96
+ }}
97
+ >
98
+ {colorGrid.map((color, i) => (
99
+ <button
100
+ key={i}
101
+ onClick={() => onChange(color.value)}
102
+ style={{
103
+ width: '18px',
104
+ height: '18px',
105
+ borderRadius: '3px',
106
+ backgroundColor: color.value,
107
+ border: value === color.value ? '2px solid #18181b' : '1px solid #e4e4e7',
108
+ cursor: 'pointer',
109
+ }}
110
+ title={`${color.name}-${color.shade}`}
111
+ />
112
+ ))}
113
+ </div>
114
+ )}
115
+
116
+ {/* Compact color swatches */}
117
+ {!isExpanded && (
118
+ <div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
119
+ {compactColors.map((color, i) => (
120
+ <button
121
+ key={i}
122
+ onClick={() => onChange(color.value)}
123
+ style={{
124
+ width: '14px',
125
+ height: '14px',
126
+ borderRadius: '3px',
127
+ backgroundColor: color.value,
128
+ border: value === color.value ? '2px solid #18181b' : '1px solid #e4e4e7',
129
+ cursor: 'pointer',
130
+ }}
131
+ title={`${color.name}-${color.shade}`}
132
+ />
133
+ ))}
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ }