@morphika/andami 0.1.2 → 0.1.5

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 (85) hide show
  1. package/app/(site)/[slug]/page.tsx +2 -2
  2. package/app/(site)/layout.tsx +1 -0
  3. package/app/(site)/page.tsx +2 -2
  4. package/app/(site)/preview/page.tsx +4 -4
  5. package/app/(site)/work/[slug]/page.tsx +2 -2
  6. package/app/admin/layout.tsx +2 -2
  7. package/app/admin/login/page.tsx +5 -5
  8. package/app/admin/navigation/page.tsx +255 -157
  9. package/app/api/admin/assets/relink/confirm/route.ts +1 -1
  10. package/app/api/admin/pages/[slug]/route.ts +1 -1
  11. package/app/api/admin/settings/route.ts +40 -15
  12. package/app/api/admin/setup/complete/route.ts +1 -1
  13. package/app/api/admin/setup/route.ts +6 -3
  14. package/components/admin/index.ts +7 -0
  15. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  16. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  17. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  18. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  19. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  20. package/components/admin/nav-builder/index.ts +2 -0
  21. package/components/blocks/BlockRenderer.tsx +65 -13
  22. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  23. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  24. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  25. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  26. package/components/blocks/PageRenderer.tsx +4 -2
  27. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  28. package/components/blocks/SectionRenderer.tsx +9 -8
  29. package/components/blocks/SectionV2Renderer.tsx +8 -8
  30. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  31. package/components/blocks/TextBlockRenderer.tsx +9 -4
  32. package/components/builder/BuilderCanvas.tsx +10 -4
  33. package/components/builder/ColorPicker.tsx +51 -243
  34. package/components/builder/ColorSwatchPicker.tsx +214 -274
  35. package/components/builder/DndWrapper.tsx +5 -2
  36. package/components/builder/SectionV2Canvas.tsx +15 -4
  37. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  38. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  39. package/components/builder/color-picker/AngleControl.tsx +138 -0
  40. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  41. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  42. package/components/builder/color-picker/GradientBar.tsx +222 -0
  43. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  44. package/components/builder/color-picker/HueSlider.tsx +124 -0
  45. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  46. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  47. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  48. package/components/builder/color-picker/PositionControl.tsx +158 -0
  49. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  50. package/components/builder/color-picker/StopEditor.tsx +178 -0
  51. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  52. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  53. package/components/builder/color-picker/index.ts +62 -0
  54. package/components/builder/color-picker/types.ts +115 -0
  55. package/components/builder/color-picker/utils.ts +138 -0
  56. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  57. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  58. package/components/builder/hooks/useColumnDrag.ts +25 -27
  59. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  60. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  61. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  62. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  63. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  64. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  65. package/components/ui/Navbar.tsx +95 -25
  66. package/components/ui/PortfolioTracker.tsx +3 -3
  67. package/lib/assets.ts +1 -1
  68. package/lib/auth.ts +1 -1
  69. package/lib/builder/gradient-presets.ts +128 -0
  70. package/lib/builder/layout-styles.ts +16 -10
  71. package/lib/builder/serializer.ts +1 -0
  72. package/lib/builder/store-blocks.ts +48 -61
  73. package/lib/builder/store-helpers.ts +31 -14
  74. package/lib/builder/store.ts +59 -41
  75. package/lib/builder/types.ts +14 -0
  76. package/lib/color-utils.ts +200 -0
  77. package/lib/config/index.ts +14 -43
  78. package/lib/revalidate.ts +2 -2
  79. package/lib/sanity/queries.ts +4 -3
  80. package/lib/sanity/types.ts +76 -1
  81. package/lib/setup/detect.ts +1 -1
  82. package/package.json +8 -12
  83. package/sanity/schemas/siteSettings.ts +34 -0
  84. package/styles/base.css +7 -51
  85. package/app/globals.css +0 -7
@@ -1,312 +1,392 @@
1
- "use client";
2
-
3
- /**
4
- * SectionV2LayoutTab — Layout tab content for V2 grid sections.
5
- *
6
- * Extracted from SectionV2Settings.tsx in Session 95.
7
- * Controls: spacing (TRBL padding), background (color/opacity/image),
8
- * offset (TRBL margins), and border properties.
9
- */
10
-
11
- import { useBuilderStore } from "../../../lib/builder/store";
12
- import type { PageSectionV2, SectionV2Settings as SectionV2SettingsType } from "../../../lib/sanity/types";
13
- import {
14
- SettingsField,
15
- SettingsSection,
16
- INPUT_CLASS,
17
- } from "../editors/shared";
18
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
19
-
20
- export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
21
- const store = useBuilderStore();
22
- const paletteSwatches = usePaletteSwatches();
23
- const settings = section.settings;
24
- const activeViewport = store.activeViewport;
25
-
26
- const viewportLabel = activeViewport !== "desktop"
27
- ? activeViewport === "tablet" ? "Tablet" : "Phone"
28
- : null;
29
-
30
- /**
31
- * Update setting with viewport awareness.
32
- * Desktop writes directly to settings; tablet/phone writes to responsive overrides.
33
- */
34
- const updateSettingResponsive = (property: string, value: unknown) => {
35
- if (activeViewport === "desktop") {
36
- store.updateSectionV2Settings(section._key, { [property]: value } as Partial<SectionV2SettingsType>);
37
- } else {
38
- const existing = section.responsive || {};
39
- const vp = activeViewport as "tablet" | "phone";
40
- const vpSettings = { ...(existing[vp]?.settings || {}), [property]: value };
41
- if (value === undefined) delete (vpSettings as Record<string, unknown>)[property];
42
- const vpOverride = { ...(existing[vp] || {}), settings: Object.keys(vpSettings).length ? vpSettings : undefined };
43
- const responsive = { ...existing, [vp]: vpOverride };
44
- // Clean up empty viewport override
45
- if (!vpOverride.columns?.length && !vpOverride.settings) delete responsive[vp];
46
- store.updateSectionV2Responsive(section._key, Object.keys(responsive).length ? responsive : undefined);
47
- }
48
- };
49
-
50
- /** Read a setting value respecting viewport overrides */
51
- const getSettingValue = <T,>(property: string, fallback: T): T => {
52
- if (activeViewport !== "desktop") {
53
- const vp = activeViewport as "tablet" | "phone";
54
- const vpSettings = section.responsive?.[vp]?.settings as Record<string, unknown> | undefined;
55
- const override = vpSettings?.[property];
56
- if (override !== undefined) return override as T;
57
- }
58
- const val = (settings as unknown as Record<string, unknown>)[property];
59
- return (val !== undefined ? val : fallback) as T;
60
- };
61
-
62
- const hasOverride = (property: string): boolean => {
63
- if (activeViewport === "desktop") return false;
64
- const vp = activeViewport as "tablet" | "phone";
65
- const vpSettings = section.responsive?.[vp]?.settings as Record<string, unknown> | undefined;
66
- return vpSettings?.[property] !== undefined;
67
- };
68
-
69
- return (
70
- <>
71
- {viewportLabel && (
72
- <div className="px-4 pt-3">
73
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
74
- <span className="text-[11px] font-medium text-[#076bff]">
75
- Editing {viewportLabel} overrides
76
- </span>
77
- </div>
78
- </div>
79
- )}
80
-
81
- {/* Spacing (Padding) */}
82
- <SettingsSection title="Spacing" defaultOpen>
83
- <div className="space-y-2">
84
- {(["top", "right", "bottom", "left"] as const).map((side) => {
85
- const prop = `spacing_${side}`;
86
- return (
87
- <SettingsField key={side} label={
88
- <span className="capitalize">
89
- {side}
90
- {hasOverride(prop) && (
91
- <span className="ml-1 text-[9px] text-[#076bff]">overridden</span>
92
- )}
93
- </span>
94
- }>
95
- <div className="flex items-center gap-2">
96
- <input
97
- type="text"
98
- value={getSettingValue<string>(prop, "0")}
99
- onChange={(e) => updateSettingResponsive(prop, e.target.value)}
100
- placeholder="0"
101
- className={INPUT_CLASS}
102
- style={{ width: 80 }}
103
- />
104
- <span className="text-[10px] text-neutral-400">px</span>
105
- {hasOverride(prop) && (
106
- <button
107
- onClick={() => updateSettingResponsive(prop, undefined)}
108
- className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors ml-auto"
109
- >
110
- Reset
111
- </button>
112
- )}
113
- </div>
114
- </SettingsField>
115
- );
116
- })}
117
- </div>
118
- </SettingsSection>
119
-
120
- {/* Background (full controls) */}
121
- <SettingsSection title="Background" defaultOpen>
122
- <SettingsField label="Color">
123
- <ColorSwatchPicker
124
- value={getSettingValue<string>("background_color", "")}
125
- onChange={(hex) => updateSettingResponsive("background_color", hex)}
126
- swatches={paletteSwatches}
127
- />
128
- </SettingsField>
129
-
130
- <SettingsField label="Opacity">
131
- <div className="flex items-center gap-2">
132
- <input
133
- type="range"
134
- min={0}
135
- max={100}
136
- value={getSettingValue<number>("background_opacity", 100)}
137
- onChange={(e) => updateSettingResponsive("background_opacity", parseInt(e.target.value))}
138
- className="flex-1 accent-[#076bff]"
139
- />
140
- <span className="text-xs text-neutral-900 w-10 text-right">
141
- {getSettingValue<number>("background_opacity", 100)}%
142
- </span>
143
- </div>
144
- </SettingsField>
145
-
146
- <SettingsField label="Image">
147
- <input
148
- type="text"
149
- value={getSettingValue<string>("background_image", "")}
150
- onChange={(e) => updateSettingResponsive("background_image", e.target.value)}
151
- placeholder="path/to/image.jpg"
152
- className={INPUT_CLASS}
153
- />
154
- </SettingsField>
155
-
156
- {getSettingValue<string>("background_image", "") && (
157
- <>
158
- <SettingsField label="Size">
159
- <select
160
- value={getSettingValue<string>("background_size", "cover")}
161
- onChange={(e) => updateSettingResponsive("background_size", e.target.value)}
162
- className={INPUT_CLASS}
163
- >
164
- <option value="cover">Cover</option>
165
- <option value="contain">Contain</option>
166
- <option value="auto">Auto</option>
167
- </select>
168
- </SettingsField>
169
-
170
- <SettingsField label="Position">
171
- <select
172
- value={getSettingValue<string>("background_position", "center center")}
173
- onChange={(e) => updateSettingResponsive("background_position", e.target.value)}
174
- className={INPUT_CLASS}
175
- >
176
- <option value="center center">Center</option>
177
- <option value="top center">Top</option>
178
- <option value="bottom center">Bottom</option>
179
- <option value="left center">Left</option>
180
- <option value="right center">Right</option>
181
- </select>
182
- </SettingsField>
183
-
184
- <SettingsField label="Repeat">
185
- <select
186
- value={getSettingValue<string>("background_repeat", "no-repeat")}
187
- onChange={(e) => updateSettingResponsive("background_repeat", e.target.value)}
188
- className={INPUT_CLASS}
189
- >
190
- <option value="no-repeat">No Repeat</option>
191
- <option value="repeat">Repeat</option>
192
- <option value="repeat-x">Repeat X</option>
193
- <option value="repeat-y">Repeat Y</option>
194
- </select>
195
- </SettingsField>
196
- </>
197
- )}
198
- </SettingsSection>
199
-
200
- {/* Offset (Margin) */}
201
- <SettingsSection title="Offset">
202
- <div className="space-y-2">
203
- {(["top", "right", "bottom", "left"] as const).map((side) => {
204
- const prop = `offset_${side}`;
205
- return (
206
- <SettingsField key={side} label={
207
- <span className="capitalize">
208
- {side}
209
- {hasOverride(prop) && (
210
- <span className="ml-1 text-[9px] text-[#076bff]">overridden</span>
211
- )}
212
- </span>
213
- }>
214
- <div className="flex items-center gap-2">
215
- <input
216
- type="text"
217
- value={getSettingValue<string>(prop, "0")}
218
- onChange={(e) => updateSettingResponsive(prop, e.target.value)}
219
- placeholder="0"
220
- className={INPUT_CLASS}
221
- style={{ width: 80 }}
222
- />
223
- <span className="text-[10px] text-neutral-400">px</span>
224
- {hasOverride(prop) && (
225
- <button
226
- onClick={() => updateSettingResponsive(prop, undefined)}
227
- className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors ml-auto"
228
- >
229
- Reset
230
- </button>
231
- )}
232
- </div>
233
- </SettingsField>
234
- );
235
- })}
236
- </div>
237
- </SettingsSection>
238
-
239
- {/* Border */}
240
- <SettingsSection title="Border">
241
- <SettingsField label="Color">
242
- <ColorSwatchPicker
243
- value={getSettingValue<string>("border_color", "")}
244
- onChange={(hex) => updateSettingResponsive("border_color", hex)}
245
- swatches={paletteSwatches}
246
- />
247
- </SettingsField>
248
-
249
- <SettingsField label="Width">
250
- <div className="flex items-center gap-2">
251
- <input
252
- type="range"
253
- min={0}
254
- max={20}
255
- value={parseInt(getSettingValue<string>("border_width", "0"))}
256
- onChange={(e) => updateSettingResponsive("border_width", e.target.value)}
257
- className="flex-1 accent-[#076bff]"
258
- />
259
- <span className="text-xs text-neutral-900 w-10 text-right">
260
- {getSettingValue<string>("border_width", "0")}px
261
- </span>
262
- </div>
263
- </SettingsField>
264
-
265
- <SettingsField label="Style">
266
- <select
267
- value={getSettingValue<string>("border_style", "none")}
268
- onChange={(e) => updateSettingResponsive("border_style", e.target.value)}
269
- className={INPUT_CLASS}
270
- >
271
- <option value="none">None</option>
272
- <option value="solid">Solid</option>
273
- <option value="dashed">Dashed</option>
274
- <option value="dotted">Dotted</option>
275
- </select>
276
- </SettingsField>
277
-
278
- <SettingsField label="Sides">
279
- <select
280
- value={getSettingValue<string>("border_sides", "all")}
281
- onChange={(e) => updateSettingResponsive("border_sides", e.target.value)}
282
- className={INPUT_CLASS}
283
- >
284
- <option value="all">All</option>
285
- <option value="top">Top</option>
286
- <option value="right">Right</option>
287
- <option value="bottom">Bottom</option>
288
- <option value="left">Left</option>
289
- <option value="top-bottom">Top & Bottom</option>
290
- <option value="left-right">Left & Right</option>
291
- </select>
292
- </SettingsField>
293
-
294
- <SettingsField label="Radius">
295
- <div className="flex items-center gap-2">
296
- <input
297
- type="range"
298
- min={0}
299
- max={50}
300
- value={parseInt(getSettingValue<string>("border_radius", "0"))}
301
- onChange={(e) => updateSettingResponsive("border_radius", e.target.value)}
302
- className="flex-1 accent-[#076bff]"
303
- />
304
- <span className="text-xs text-neutral-900 w-10 text-right">
305
- {getSettingValue<string>("border_radius", "0")}px
306
- </span>
307
- </div>
308
- </SettingsField>
309
- </SettingsSection>
310
- </>
311
- );
312
- }
1
+ "use client";
2
+
3
+ /**
4
+ * SectionV2LayoutTab — Layout tab content for V2 grid sections.
5
+ *
6
+ * Extracted from SectionV2Settings.tsx in Session 95.
7
+ * Session 158: Redesigned to match BlockLayoutTab style —
8
+ * section title icons, TRBLInputs for spacing/offset,
9
+ * AssetPathInput for background image, SELECT_CLASS for selects.
10
+ * No Alignment section (that's for blocks only).
11
+ *
12
+ * Controls: spacing (TRBL padding), background (color/opacity/image),
13
+ * offset (TRBL margins), and border properties.
14
+ */
15
+
16
+ import { useBuilderStore } from "../../../lib/builder/store";
17
+ import type { PageSectionV2, SectionV2Settings as SectionV2SettingsType } from "../../../lib/sanity/types";
18
+ import {
19
+ SettingsField,
20
+ SettingsSection,
21
+ SELECT_CLASS,
22
+ AssetPathInput,
23
+ } from "../editors/shared";
24
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
25
+ import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
26
+ import { TRBLInputs } from "./TRBLInputs";
27
+
28
+ // ── Section title icons (matching BlockLayoutTab) ──
29
+
30
+ function SpacingSectionIcon() {
31
+ return (
32
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
33
+ <rect x="4" y="4" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.5" />
34
+ <path d="M7 1 L7 3.5" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
35
+ <path d="M7 10.5 L7 13" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
36
+ <path d="M1 7 L3.5 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
37
+ <path d="M10.5 7 L13 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ function BackgroundSectionIcon() {
43
+ return (
44
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
45
+ <rect x="1.5" y="1.5" width="11" height="11" rx="2" fill="currentColor" opacity="0.15" />
46
+ <rect x="1.5" y="1.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="0.8" opacity="0.5" fill="none" />
47
+ <circle cx="5" cy="5" r="1.5" fill="currentColor" opacity="0.5" />
48
+ <path d="M1.5 10 L5 7 L8 9 L10.5 6.5 L12.5 8.5 L12.5 11 C12.5 11.8 11.8 12.5 11 12.5 L3 12.5 C2.2 12.5 1.5 11.8 1.5 11 Z" fill="currentColor" opacity="0.3" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ function OffsetSectionIcon() {
54
+ return (
55
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
56
+ <rect x="3" y="3" width="8" height="8" rx="1" stroke="currentColor" strokeWidth="0.8" strokeDasharray="2 1" fill="none" opacity="0.35" />
57
+ <rect x="5" y="5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.7" />
58
+ <path d="M4 4 L5 5" stroke="currentColor" strokeWidth="0.6" opacity="0.5" />
59
+ </svg>
60
+ );
61
+ }
62
+
63
+ function BorderSectionIcon() {
64
+ return (
65
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
66
+ <rect x="2" y="2" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.2" fill="none" opacity="0.6" />
67
+ <rect x="2" y="2" width="10" height="1.2" rx="0.5" fill="currentColor" opacity="0.7" />
68
+ </svg>
69
+ );
70
+ }
71
+
72
+ // ── Override indicator badge (matching BlockLayoutTab pattern) ──
73
+
74
+ function SectionOverrideBadge({
75
+ hasAny,
76
+ onReset,
77
+ viewport,
78
+ }: {
79
+ hasAny: boolean;
80
+ onReset: () => void;
81
+ viewport: string;
82
+ }) {
83
+ if (viewport === "desktop") return null;
84
+ if (hasAny) {
85
+ return (
86
+ <div className="flex items-center gap-2 mt-1">
87
+ <span className="text-[9px] text-[#076bff]">overridden</span>
88
+ <button
89
+ onClick={onReset}
90
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
91
+ >
92
+ Reset
93
+ </button>
94
+ </div>
95
+ );
96
+ }
97
+ return <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>;
98
+ }
99
+
100
+ export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
101
+ const store = useBuilderStore();
102
+ const paletteSwatches = usePaletteSwatches();
103
+ const settings = section.settings;
104
+ const activeViewport = store.activeViewport;
105
+
106
+ // Live preview callbacks (Phase 4)
107
+ const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
108
+ store.setColorPickerPreview({ sectionKey: section._key, field: "background_color", value: val });
109
+ };
110
+ const handleBorderPreview = (val: import("../../../lib/sanity/types").ColorField) => {
111
+ store.setColorPickerPreview({ sectionKey: section._key, field: "border_color", value: val });
112
+ };
113
+
114
+ const viewportLabel = activeViewport !== "desktop"
115
+ ? activeViewport === "tablet" ? "Tablet" : "Phone"
116
+ : null;
117
+
118
+ /**
119
+ * Update setting with viewport awareness.
120
+ * Desktop writes directly to settings; tablet/phone writes to responsive overrides.
121
+ */
122
+ const updateSettingResponsive = (property: string, value: unknown) => {
123
+ if (activeViewport === "desktop") {
124
+ store.updateSectionV2Settings(section._key, { [property]: value } as Partial<SectionV2SettingsType>);
125
+ } else {
126
+ const existing = section.responsive || {};
127
+ const vp = activeViewport as "tablet" | "phone";
128
+ const vpSettings = { ...(existing[vp]?.settings || {}), [property]: value };
129
+ if (value === undefined) delete (vpSettings as Record<string, unknown>)[property];
130
+ const vpOverride = { ...(existing[vp] || {}), settings: Object.keys(vpSettings).length ? vpSettings : undefined };
131
+ const responsive = { ...existing, [vp]: vpOverride };
132
+ // Clean up empty viewport override
133
+ if (!vpOverride.columns?.length && !vpOverride.settings) delete responsive[vp];
134
+ store.updateSectionV2Responsive(section._key, Object.keys(responsive).length ? responsive : undefined);
135
+ }
136
+ };
137
+
138
+ /** Read a setting value respecting viewport overrides */
139
+ const getSettingValue = <T,>(property: string, fallback: T): T => {
140
+ if (activeViewport !== "desktop") {
141
+ const vp = activeViewport as "tablet" | "phone";
142
+ const vpSettings = section.responsive?.[vp]?.settings as Record<string, unknown> | undefined;
143
+ const override = vpSettings?.[property];
144
+ if (override !== undefined) return override as T;
145
+ }
146
+ const val = (settings as unknown as Record<string, unknown>)[property];
147
+ return (val !== undefined ? val : fallback) as T;
148
+ };
149
+
150
+ const hasOverride = (property: string): boolean => {
151
+ if (activeViewport === "desktop") return false;
152
+ const vp = activeViewport as "tablet" | "phone";
153
+ const vpSettings = section.responsive?.[vp]?.settings as Record<string, unknown> | undefined;
154
+ return vpSettings?.[property] !== undefined;
155
+ };
156
+
157
+ const hasAnyOverrideInGroup = (properties: string[]): boolean => {
158
+ return properties.some((p) => hasOverride(p));
159
+ };
160
+
161
+ const resetGroup = (properties: string[]) => {
162
+ for (const prop of properties) {
163
+ updateSettingResponsive(prop, undefined);
164
+ }
165
+ };
166
+
167
+ const bgIsGradient = isGradient(parseColorField(getSettingValue<string>("background_color", "")));
168
+
169
+ return (
170
+ <>
171
+ {viewportLabel && (
172
+ <div className="px-4 pt-3">
173
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
174
+ <span className="text-[11px] font-medium text-[#076bff]">
175
+ Editing {viewportLabel} overrides
176
+ </span>
177
+ </div>
178
+ </div>
179
+ )}
180
+
181
+ {/* Spacing (Padding) */}
182
+ <SettingsSection title="Spacing" defaultOpen icon={<SpacingSectionIcon />}>
183
+ <TRBLInputs
184
+ top={getSettingValue<string>("spacing_top", "0")}
185
+ right={getSettingValue<string>("spacing_right", "0")}
186
+ bottom={getSettingValue<string>("spacing_bottom", "0")}
187
+ left={getSettingValue<string>("spacing_left", "0")}
188
+ onChange={(field, value) => {
189
+ updateSettingResponsive(`spacing_${field}`, value);
190
+ }}
191
+ />
192
+ <SectionOverrideBadge
193
+ hasAny={hasAnyOverrideInGroup(["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"])}
194
+ onReset={() => resetGroup(["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"])}
195
+ viewport={activeViewport}
196
+ />
197
+ </SettingsSection>
198
+
199
+ {/* Offset (Margin) */}
200
+ <SettingsSection title="Offset" icon={<OffsetSectionIcon />}>
201
+ <TRBLInputs
202
+ top={getSettingValue<string>("offset_top", "0")}
203
+ right={getSettingValue<string>("offset_right", "0")}
204
+ bottom={getSettingValue<string>("offset_bottom", "0")}
205
+ left={getSettingValue<string>("offset_left", "0")}
206
+ onChange={(field, value) => {
207
+ updateSettingResponsive(`offset_${field}`, value);
208
+ }}
209
+ />
210
+ <SectionOverrideBadge
211
+ hasAny={hasAnyOverrideInGroup(["offset_top", "offset_right", "offset_bottom", "offset_left"])}
212
+ onReset={() => resetGroup(["offset_top", "offset_right", "offset_bottom", "offset_left"])}
213
+ viewport={activeViewport}
214
+ />
215
+ </SettingsSection>
216
+
217
+ {/* Background */}
218
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundSectionIcon />}>
219
+ <SettingsField label="Color">
220
+ <ColorSwatchPicker
221
+ value={parseColorField(getSettingValue<string>("background_color", ""))}
222
+ onChange={(val) => { store.clearColorPickerPreview(); updateSettingResponsive("background_color", serializeColorField(val)); }}
223
+ swatches={paletteSwatches}
224
+ allowGradients
225
+ onPreview={handleBgPreview}
226
+ />
227
+ </SettingsField>
228
+
229
+ <SettingsField label="Opacity">
230
+ <div className="flex items-center gap-2">
231
+ <input
232
+ type="range"
233
+ min={0}
234
+ max={100}
235
+ value={getSettingValue<number>("background_opacity", 100)}
236
+ onChange={(e) => updateSettingResponsive("background_opacity", parseInt(e.target.value))}
237
+ className={`flex-1 accent-[#076bff] ${bgIsGradient ? "opacity-40 pointer-events-none" : ""}`}
238
+ disabled={bgIsGradient}
239
+ />
240
+ <span className="text-xs text-neutral-900 w-10 text-right">
241
+ {getSettingValue<number>("background_opacity", 100)}%
242
+ </span>
243
+ </div>
244
+ {bgIsGradient && (
245
+ <p className="text-[9px] text-neutral-400 italic mt-1">
246
+ Opacity is controlled per stop in gradient mode
247
+ </p>
248
+ )}
249
+ </SettingsField>
250
+
251
+ <SettingsField label="Image">
252
+ <AssetPathInput
253
+ value={getSettingValue<string>("background_image", "")}
254
+ onFocus={() => store._pushSnapshot()}
255
+ onChange={(v) => updateSettingResponsive("background_image", v)}
256
+ placeholder="path/to/image.jpg"
257
+ filterType="image"
258
+ />
259
+ </SettingsField>
260
+
261
+ {getSettingValue<string>("background_image", "") && (
262
+ <>
263
+ <SettingsField label="Size">
264
+ <select
265
+ value={getSettingValue<string>("background_size", "cover")}
266
+ onChange={(e) => updateSettingResponsive("background_size", e.target.value)}
267
+ className={SELECT_CLASS}
268
+ >
269
+ <option value="cover">Cover</option>
270
+ <option value="contain">Contain</option>
271
+ <option value="auto">Auto</option>
272
+ </select>
273
+ </SettingsField>
274
+
275
+ <SettingsField label="Position">
276
+ <select
277
+ value={getSettingValue<string>("background_position", "center center")}
278
+ onChange={(e) => updateSettingResponsive("background_position", e.target.value)}
279
+ className={SELECT_CLASS}
280
+ >
281
+ <option value="center center">Center</option>
282
+ <option value="top center">Top</option>
283
+ <option value="bottom center">Bottom</option>
284
+ <option value="left center">Left</option>
285
+ <option value="right center">Right</option>
286
+ </select>
287
+ </SettingsField>
288
+
289
+ <SettingsField label="Repeat">
290
+ <select
291
+ value={getSettingValue<string>("background_repeat", "no-repeat")}
292
+ onChange={(e) => updateSettingResponsive("background_repeat", e.target.value)}
293
+ className={SELECT_CLASS}
294
+ >
295
+ <option value="no-repeat">No Repeat</option>
296
+ <option value="repeat">Repeat</option>
297
+ <option value="repeat-x">Repeat X</option>
298
+ <option value="repeat-y">Repeat Y</option>
299
+ </select>
300
+ </SettingsField>
301
+ </>
302
+ )}
303
+
304
+ <SectionOverrideBadge
305
+ hasAny={hasAnyOverrideInGroup(["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"])}
306
+ onReset={() => resetGroup(["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"])}
307
+ viewport={activeViewport}
308
+ />
309
+ </SettingsSection>
310
+
311
+ {/* Border */}
312
+ <SettingsSection title="Border" icon={<BorderSectionIcon />}>
313
+ <SettingsField label="Color">
314
+ <ColorSwatchPicker
315
+ value={parseColorField(getSettingValue<string>("border_color", ""))}
316
+ onChange={(val) => { store.clearColorPickerPreview(); updateSettingResponsive("border_color", serializeColorField(val)); }}
317
+ swatches={paletteSwatches}
318
+ allowGradients
319
+ onPreview={handleBorderPreview}
320
+ />
321
+ </SettingsField>
322
+
323
+ <SettingsField label="Width">
324
+ <div className="flex items-center gap-2">
325
+ <input
326
+ type="range"
327
+ min={0}
328
+ max={20}
329
+ value={parseInt(getSettingValue<string>("border_width", "0"))}
330
+ onChange={(e) => updateSettingResponsive("border_width", e.target.value)}
331
+ className="flex-1 accent-[#076bff]"
332
+ />
333
+ <span className="text-xs text-neutral-900 w-10 text-right">
334
+ {getSettingValue<string>("border_width", "0")}px
335
+ </span>
336
+ </div>
337
+ </SettingsField>
338
+
339
+ <SettingsField label="Style">
340
+ <select
341
+ value={getSettingValue<string>("border_style", "none")}
342
+ onChange={(e) => updateSettingResponsive("border_style", e.target.value)}
343
+ className={SELECT_CLASS}
344
+ >
345
+ <option value="none">None</option>
346
+ <option value="solid">Solid</option>
347
+ <option value="dashed">Dashed</option>
348
+ <option value="dotted">Dotted</option>
349
+ </select>
350
+ </SettingsField>
351
+
352
+ <SettingsField label="Sides">
353
+ <select
354
+ value={getSettingValue<string>("border_sides", "all")}
355
+ onChange={(e) => updateSettingResponsive("border_sides", e.target.value)}
356
+ className={SELECT_CLASS}
357
+ >
358
+ <option value="all">All</option>
359
+ <option value="top">Top</option>
360
+ <option value="right">Right</option>
361
+ <option value="bottom">Bottom</option>
362
+ <option value="left">Left</option>
363
+ <option value="top-bottom">Top & Bottom</option>
364
+ <option value="left-right">Left & Right</option>
365
+ </select>
366
+ </SettingsField>
367
+
368
+ <SettingsField label="Radius">
369
+ <div className="flex items-center gap-2">
370
+ <input
371
+ type="range"
372
+ min={0}
373
+ max={50}
374
+ value={parseInt(getSettingValue<string>("border_radius", "0"))}
375
+ onChange={(e) => updateSettingResponsive("border_radius", e.target.value)}
376
+ className="flex-1 accent-[#076bff]"
377
+ />
378
+ <span className="text-xs text-neutral-900 w-10 text-right">
379
+ {getSettingValue<string>("border_radius", "0")}px
380
+ </span>
381
+ </div>
382
+ </SettingsField>
383
+
384
+ <SectionOverrideBadge
385
+ hasAny={hasAnyOverrideInGroup(["border_color", "border_width", "border_style", "border_sides", "border_radius"])}
386
+ onReset={() => resetGroup(["border_color", "border_width", "border_style", "border_sides", "border_radius"])}
387
+ viewport={activeViewport}
388
+ />
389
+ </SettingsSection>
390
+ </>
391
+ );
392
+ }