@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
@@ -0,0 +1,136 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { SectionWrapper } from './SectionWrapper';
3
+
4
+ interface AnnotationSectionProps {
5
+ expanded: boolean;
6
+ onToggle: () => void;
7
+ onAnnotate: (content: string) => void;
8
+ existingAnnotation?: string;
9
+ hasChanges?: boolean;
10
+ }
11
+
12
+ export function AnnotationSection({
13
+ expanded,
14
+ onToggle,
15
+ onAnnotate,
16
+ existingAnnotation = '',
17
+ hasChanges = false,
18
+ }: AnnotationSectionProps) {
19
+ const [note, setNote] = useState(existingAnnotation);
20
+ const [isSaved, setIsSaved] = useState(!!existingAnnotation);
21
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
23
+
24
+ // Initialize with existing annotation
25
+ useEffect(() => {
26
+ setNote(existingAnnotation);
27
+ setIsSaved(!!existingAnnotation);
28
+ }, [existingAnnotation]);
29
+
30
+ // Auto-save with debounce
31
+ const handleChange = useCallback((value: string) => {
32
+ setNote(value);
33
+ setIsSaved(false);
34
+
35
+ // Clear previous timeout
36
+ if (debounceRef.current) {
37
+ clearTimeout(debounceRef.current);
38
+ }
39
+
40
+ // Auto-save after 500ms of no typing
41
+ if (value.trim()) {
42
+ debounceRef.current = setTimeout(() => {
43
+ onAnnotate(value.trim());
44
+ setIsSaved(true);
45
+ }, 500);
46
+ }
47
+ }, [onAnnotate]);
48
+
49
+ // Cleanup timeout on unmount
50
+ useEffect(() => {
51
+ return () => {
52
+ if (debounceRef.current) {
53
+ clearTimeout(debounceRef.current);
54
+ }
55
+ };
56
+ }, []);
57
+
58
+ // Save immediately on blur if there's unsaved content
59
+ const handleBlur = () => {
60
+ if (debounceRef.current) {
61
+ clearTimeout(debounceRef.current);
62
+ }
63
+ if (note.trim() && !isSaved) {
64
+ onAnnotate(note.trim());
65
+ setIsSaved(true);
66
+ }
67
+ };
68
+
69
+ const showActiveState = !!(note.trim() && isSaved);
70
+
71
+ return (
72
+ <SectionWrapper
73
+ title="Annotation"
74
+ expanded={expanded}
75
+ onToggle={onToggle}
76
+ hasChanges={hasChanges || showActiveState}
77
+ >
78
+ <div
79
+ style={{
80
+ position: 'relative',
81
+ borderRadius: '4px',
82
+ border: `1px solid ${showActiveState ? '#3b82f6' : '#e4e4e7'}`,
83
+ backgroundColor: showActiveState ? '#eff6ff' : '#ffffff',
84
+ transition: 'all 0.15s ease',
85
+ }}
86
+ >
87
+ <textarea
88
+ ref={textareaRef}
89
+ value={note}
90
+ onChange={(e) => handleChange(e.target.value)}
91
+ onBlur={handleBlur}
92
+ placeholder="Add a note about this element..."
93
+ style={{
94
+ width: '100%',
95
+ minHeight: '60px',
96
+ padding: '8px',
97
+ borderRadius: '4px',
98
+ border: 'none',
99
+ backgroundColor: 'transparent',
100
+ color: '#18181b',
101
+ fontSize: '11px',
102
+ resize: 'vertical',
103
+ fontFamily: 'system-ui, -apple-system, sans-serif',
104
+ outline: 'none',
105
+ }}
106
+ />
107
+ {/* Save indicator */}
108
+ {note.trim() && (
109
+ <div
110
+ style={{
111
+ position: 'absolute',
112
+ bottom: '4px',
113
+ right: '4px',
114
+ fontSize: '9px',
115
+ color: isSaved ? '#3b82f6' : '#a1a1aa',
116
+ display: 'flex',
117
+ alignItems: 'center',
118
+ gap: '2px',
119
+ }}
120
+ >
121
+ {isSaved ? (
122
+ <>
123
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
124
+ <polyline points="20 6 9 17 4 12" />
125
+ </svg>
126
+ Saved
127
+ </>
128
+ ) : (
129
+ 'Saving...'
130
+ )}
131
+ </div>
132
+ )}
133
+ </div>
134
+ </SectionWrapper>
135
+ );
136
+ }
@@ -0,0 +1,79 @@
1
+ import type { DesignTokens } from '../../../types';
2
+ import { SectionWrapper } from './SectionWrapper';
3
+ import { ColorPicker } from '../controls/ColorPicker';
4
+ import { TokenDropdown } from '../controls/TokenDropdown';
5
+
6
+ interface BackgroundSectionProps {
7
+ expanded: boolean;
8
+ onToggle: () => void;
9
+ computedStyle: CSSStyleDeclaration;
10
+ onApplyStyle: (property: string, value: string) => void;
11
+ tokens: DesignTokens;
12
+ hasChanges?: boolean;
13
+ }
14
+
15
+ export function BackgroundSection({
16
+ expanded,
17
+ onToggle,
18
+ computedStyle,
19
+ onApplyStyle,
20
+ tokens,
21
+ hasChanges = false,
22
+ }: BackgroundSectionProps) {
23
+ const backgroundColor = computedStyle.backgroundColor;
24
+ const borderColor = computedStyle.borderColor;
25
+ const borderWidth = computedStyle.borderWidth;
26
+ const borderRadius = computedStyle.borderRadius;
27
+
28
+ return (
29
+ <SectionWrapper title="Background & Border" expanded={expanded} onToggle={onToggle} hasChanges={hasChanges}>
30
+ {/* Background Color */}
31
+ <div style={{ marginBottom: '12px' }}>
32
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
33
+ Background
34
+ </label>
35
+ <ColorPicker
36
+ value={backgroundColor}
37
+ colors={tokens.colors}
38
+ onChange={(value) => onApplyStyle('background-color', value)}
39
+ />
40
+ </div>
41
+
42
+ {/* Border Color */}
43
+ <div style={{ marginBottom: '12px' }}>
44
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
45
+ Border Color
46
+ </label>
47
+ <ColorPicker
48
+ value={borderColor}
49
+ colors={tokens.colors}
50
+ onChange={(value) => onApplyStyle('border-color', value)}
51
+ />
52
+ </div>
53
+
54
+ {/* Border Width */}
55
+ <div style={{ marginBottom: '12px' }}>
56
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
57
+ Border Width
58
+ </label>
59
+ <TokenDropdown
60
+ value={borderWidth}
61
+ tokens={tokens.borderWidth}
62
+ onChange={(value) => onApplyStyle('border-width', value)}
63
+ />
64
+ </div>
65
+
66
+ {/* Border Radius */}
67
+ <div>
68
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
69
+ Border Radius
70
+ </label>
71
+ <TokenDropdown
72
+ value={borderRadius}
73
+ tokens={tokens.borderRadius}
74
+ onChange={(value) => onApplyStyle('border-radius', value)}
75
+ />
76
+ </div>
77
+ </SectionWrapper>
78
+ );
79
+ }
@@ -0,0 +1,85 @@
1
+ import type { DesignTokens } from '../../../types';
2
+ import { SectionWrapper } from './SectionWrapper';
3
+ import { QuickSelectDropdown } from '../controls/QuickSelectDropdown';
4
+ import { SliderInput } from '../controls/SliderInput';
5
+
6
+ interface EffectsSectionProps {
7
+ expanded: boolean;
8
+ onToggle: () => void;
9
+ computedStyle: CSSStyleDeclaration;
10
+ onApplyStyle: (property: string, value: string) => void;
11
+ tokens: DesignTokens;
12
+ hasChanges?: boolean;
13
+ }
14
+
15
+ export function EffectsSection({
16
+ expanded,
17
+ onToggle,
18
+ computedStyle,
19
+ onApplyStyle,
20
+ tokens,
21
+ hasChanges = false,
22
+ }: EffectsSectionProps) {
23
+ const boxShadow = computedStyle.boxShadow;
24
+ const borderRadius = computedStyle.borderRadius;
25
+ const borderWidth = computedStyle.borderWidth;
26
+ const opacity = parseFloat(computedStyle.opacity) * 100;
27
+
28
+ return (
29
+ <SectionWrapper title="Effects" expanded={expanded} onToggle={onToggle} hasChanges={hasChanges}>
30
+ {/* Border Radius */}
31
+ <div style={{ marginBottom: '12px' }}>
32
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
33
+ Border Radius
34
+ </label>
35
+ <QuickSelectDropdown
36
+ value={borderRadius}
37
+ tokens={tokens.borderRadius}
38
+ quickKeys={['none', 'sm', 'md', 'lg', 'full']}
39
+ onChange={(value) => onApplyStyle('border-radius', value)}
40
+ />
41
+ </div>
42
+
43
+ {/* Border Width */}
44
+ <div style={{ marginBottom: '12px' }}>
45
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
46
+ Border Width
47
+ </label>
48
+ <QuickSelectDropdown
49
+ value={borderWidth}
50
+ tokens={tokens.borderWidth}
51
+ quickKeys={['0', 'DEFAULT', '2', '4']}
52
+ onChange={(value) => onApplyStyle('border-width', value)}
53
+ />
54
+ </div>
55
+
56
+ {/* Box Shadow */}
57
+ <div style={{ marginBottom: '12px' }}>
58
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
59
+ Shadow
60
+ </label>
61
+ <QuickSelectDropdown
62
+ value={boxShadow}
63
+ tokens={tokens.boxShadow}
64
+ quickKeys={['none', 'sm', 'md', 'lg']}
65
+ onChange={(value) => onApplyStyle('box-shadow', value)}
66
+ />
67
+ </div>
68
+
69
+ {/* Opacity */}
70
+ <div>
71
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
72
+ Opacity
73
+ </label>
74
+ <SliderInput
75
+ value={opacity}
76
+ min={0}
77
+ max={100}
78
+ step={1}
79
+ unit="%"
80
+ onChange={(value) => onApplyStyle('opacity', String(value / 100))}
81
+ />
82
+ </div>
83
+ </SectionWrapper>
84
+ );
85
+ }
@@ -0,0 +1,224 @@
1
+ import type { DesignTokens } from '../../../types';
2
+ import { SectionWrapper } from './SectionWrapper';
3
+ import { ToggleGroup } from '../controls/ToggleGroup';
4
+ import { TokenDropdown } from '../controls/TokenDropdown';
5
+
6
+ // Direction icons
7
+ const ArrowRightIcon = () => (
8
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
9
+ <line x1="5" y1="12" x2="19" y2="12" />
10
+ <polyline points="12 5 19 12 12 19" />
11
+ </svg>
12
+ );
13
+
14
+ const ArrowLeftIcon = () => (
15
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
16
+ <line x1="19" y1="12" x2="5" y2="12" />
17
+ <polyline points="12 19 5 12 12 5" />
18
+ </svg>
19
+ );
20
+
21
+ const ArrowDownIcon = () => (
22
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
23
+ <line x1="12" y1="5" x2="12" y2="19" />
24
+ <polyline points="19 12 12 19 5 12" />
25
+ </svg>
26
+ );
27
+
28
+ const ArrowUpIcon = () => (
29
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
30
+ <line x1="12" y1="19" x2="12" y2="5" />
31
+ <polyline points="5 12 12 5 19 12" />
32
+ </svg>
33
+ );
34
+
35
+ // Justify icons
36
+ const JustifyStartIcon = () => (
37
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
38
+ <line x1="3" y1="4" x2="3" y2="20" />
39
+ <rect x="7" y="8" width="4" height="8" rx="1" />
40
+ <rect x="13" y="8" width="4" height="8" rx="1" />
41
+ </svg>
42
+ );
43
+
44
+ const JustifyCenterIcon = () => (
45
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
46
+ <rect x="6" y="8" width="4" height="8" rx="1" />
47
+ <rect x="14" y="8" width="4" height="8" rx="1" />
48
+ </svg>
49
+ );
50
+
51
+ const JustifyEndIcon = () => (
52
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
53
+ <line x1="21" y1="4" x2="21" y2="20" />
54
+ <rect x="7" y="8" width="4" height="8" rx="1" />
55
+ <rect x="13" y="8" width="4" height="8" rx="1" />
56
+ </svg>
57
+ );
58
+
59
+ const JustifyBetweenIcon = () => (
60
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
61
+ <line x1="3" y1="4" x2="3" y2="20" />
62
+ <line x1="21" y1="4" x2="21" y2="20" />
63
+ <rect x="6" y="8" width="4" height="8" rx="1" />
64
+ <rect x="14" y="8" width="4" height="8" rx="1" />
65
+ </svg>
66
+ );
67
+
68
+ const JustifyAroundIcon = () => (
69
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
70
+ <rect x="4" y="8" width="4" height="8" rx="1" />
71
+ <rect x="10" y="8" width="4" height="8" rx="1" />
72
+ <rect x="16" y="8" width="4" height="8" rx="1" />
73
+ </svg>
74
+ );
75
+
76
+ // Align icons
77
+ const AlignStartIcon = () => (
78
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
79
+ <line x1="4" y1="3" x2="20" y2="3" />
80
+ <rect x="6" y="6" width="4" height="10" rx="1" />
81
+ <rect x="14" y="6" width="4" height="6" rx="1" />
82
+ </svg>
83
+ );
84
+
85
+ const AlignCenterVIcon = () => (
86
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
87
+ <rect x="6" y="5" width="4" height="14" rx="1" />
88
+ <rect x="14" y="7" width="4" height="10" rx="1" />
89
+ </svg>
90
+ );
91
+
92
+ const AlignEndIcon = () => (
93
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
94
+ <line x1="4" y1="21" x2="20" y2="21" />
95
+ <rect x="6" y="8" width="4" height="10" rx="1" />
96
+ <rect x="14" y="12" width="4" height="6" rx="1" />
97
+ </svg>
98
+ );
99
+
100
+ const AlignStretchIcon = () => (
101
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
102
+ <line x1="4" y1="3" x2="20" y2="3" />
103
+ <line x1="4" y1="21" x2="20" y2="21" />
104
+ <rect x="6" y="6" width="4" height="12" rx="1" />
105
+ <rect x="14" y="6" width="4" height="12" rx="1" />
106
+ </svg>
107
+ );
108
+
109
+ interface LayoutSectionProps {
110
+ expanded: boolean;
111
+ onToggle: () => void;
112
+ computedStyle: CSSStyleDeclaration;
113
+ onApplyStyle: (property: string, value: string) => void;
114
+ tokens: DesignTokens;
115
+ hasChanges?: boolean;
116
+ }
117
+
118
+ export function LayoutSection({
119
+ expanded,
120
+ onToggle,
121
+ computedStyle,
122
+ onApplyStyle,
123
+ tokens,
124
+ hasChanges = false,
125
+ }: LayoutSectionProps) {
126
+ const display = computedStyle.display;
127
+ const flexDirection = computedStyle.flexDirection;
128
+ const justifyContent = computedStyle.justifyContent;
129
+ const alignItems = computedStyle.alignItems;
130
+ const gap = computedStyle.gap;
131
+
132
+ const isFlex = display === 'flex' || display === 'inline-flex';
133
+ const isGrid = display === 'grid' || display === 'inline-grid';
134
+
135
+ return (
136
+ <SectionWrapper title="Layout" expanded={expanded} onToggle={onToggle} hasChanges={hasChanges}>
137
+ {/* Display */}
138
+ <div style={{ marginBottom: '12px' }}>
139
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
140
+ Display
141
+ </label>
142
+ <ToggleGroup
143
+ value={display}
144
+ options={[
145
+ { value: 'block', label: 'Block' },
146
+ { value: 'flex', label: 'Flex' },
147
+ { value: 'grid', label: 'Grid' },
148
+ { value: 'inline', label: 'Inline' },
149
+ { value: 'none', label: 'None' },
150
+ ]}
151
+ onChange={(value) => onApplyStyle('display', value)}
152
+ />
153
+ </div>
154
+
155
+ {/* Flex-specific controls */}
156
+ {isFlex && (
157
+ <>
158
+ <div style={{ marginBottom: '12px' }}>
159
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
160
+ Direction
161
+ </label>
162
+ <ToggleGroup
163
+ value={flexDirection}
164
+ options={[
165
+ { value: 'row', label: <ArrowRightIcon /> },
166
+ { value: 'row-reverse', label: <ArrowLeftIcon /> },
167
+ { value: 'column', label: <ArrowDownIcon /> },
168
+ { value: 'column-reverse', label: <ArrowUpIcon /> },
169
+ ]}
170
+ onChange={(value) => onApplyStyle('flex-direction', value)}
171
+ />
172
+ </div>
173
+
174
+ <div style={{ marginBottom: '12px' }}>
175
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
176
+ Justify
177
+ </label>
178
+ <ToggleGroup
179
+ value={justifyContent}
180
+ options={[
181
+ { value: 'flex-start', label: <JustifyStartIcon /> },
182
+ { value: 'center', label: <JustifyCenterIcon /> },
183
+ { value: 'flex-end', label: <JustifyEndIcon /> },
184
+ { value: 'space-between', label: <JustifyBetweenIcon /> },
185
+ { value: 'space-around', label: <JustifyAroundIcon /> },
186
+ ]}
187
+ onChange={(value) => onApplyStyle('justify-content', value)}
188
+ />
189
+ </div>
190
+
191
+ <div style={{ marginBottom: '12px' }}>
192
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
193
+ Align
194
+ </label>
195
+ <ToggleGroup
196
+ value={alignItems}
197
+ options={[
198
+ { value: 'flex-start', label: <AlignStartIcon /> },
199
+ { value: 'center', label: <AlignCenterVIcon /> },
200
+ { value: 'flex-end', label: <AlignEndIcon /> },
201
+ { value: 'stretch', label: <AlignStretchIcon /> },
202
+ ]}
203
+ onChange={(value) => onApplyStyle('align-items', value)}
204
+ />
205
+ </div>
206
+ </>
207
+ )}
208
+
209
+ {/* Gap (for flex and grid) */}
210
+ {(isFlex || isGrid) && (
211
+ <div style={{ marginBottom: '12px' }}>
212
+ <label style={{ fontSize: '10px', color: '#71717a', marginBottom: '4px', display: 'block' }}>
213
+ Gap
214
+ </label>
215
+ <TokenDropdown
216
+ value={gap}
217
+ tokens={tokens.spacing}
218
+ onChange={(value) => onApplyStyle('gap', value)}
219
+ />
220
+ </div>
221
+ )}
222
+ </SectionWrapper>
223
+ );
224
+ }
@@ -0,0 +1,57 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ interface SectionWrapperProps {
4
+ title: string;
5
+ expanded: boolean;
6
+ onToggle: () => void;
7
+ children: ReactNode;
8
+ hasChanges?: boolean;
9
+ }
10
+
11
+ export function SectionWrapper({ title, expanded, onToggle, children, hasChanges = false }: SectionWrapperProps) {
12
+ return (
13
+ <div style={{ borderBottom: '1px solid #f4f4f5' }}>
14
+ <button
15
+ onClick={onToggle}
16
+ style={{
17
+ width: '100%',
18
+ padding: '8px 12px',
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ justifyContent: 'space-between',
22
+ backgroundColor: hasChanges ? '#eff6ff' : 'transparent',
23
+ border: 'none',
24
+ cursor: 'pointer',
25
+ color: '#18181b',
26
+ fontSize: '11px',
27
+ fontWeight: 500,
28
+ textTransform: 'uppercase',
29
+ letterSpacing: '0.05em',
30
+ }}
31
+ >
32
+ <span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
33
+ {title}
34
+ {hasChanges && (
35
+ <span
36
+ style={{
37
+ width: '6px',
38
+ height: '6px',
39
+ borderRadius: '50%',
40
+ backgroundColor: '#3b82f6',
41
+ }}
42
+ title="Has unsaved changes"
43
+ />
44
+ )}
45
+ </span>
46
+ <span style={{ fontSize: '10px', color: '#a1a1aa' }}>
47
+ {expanded ? '▲' : '▼'}
48
+ </span>
49
+ </button>
50
+ {expanded && (
51
+ <div style={{ padding: '0 12px 12px' }}>
52
+ {children}
53
+ </div>
54
+ )}
55
+ </div>
56
+ );
57
+ }