@morphika/andami 0.1.3 → 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 (84) 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/revalidate.ts +2 -2
  78. package/lib/sanity/queries.ts +4 -3
  79. package/lib/sanity/types.ts +76 -1
  80. package/lib/setup/detect.ts +1 -1
  81. package/package.json +8 -2
  82. package/sanity/schemas/siteSettings.ts +34 -0
  83. package/styles/base.css +3 -3
  84. package/app/globals.css +0 -7
@@ -1,310 +1,382 @@
1
- "use client";
2
-
3
- /**
4
- * LayoutTab — Page Section styling (spacing, background, offset, border).
5
- * Viewport-aware with responsive override support.
6
- *
7
- * Session 64: Extracted from SettingsPanel.tsx.
8
- * Session 65: Split out RowLayoutPresetPicker, TRBLInputs, BlockLayoutTab
9
- * into separate modules. This file now contains only LayoutTab.
10
- */
11
-
12
- import { useBuilderStore } from "../../../lib/builder/store";
13
- import { resolveEffectiveSpacing } from "../../../lib/builder/layout-styles";
14
- import type { PageSection } from "../../../lib/sanity/types";
15
- import {
16
- SettingsField,
17
- SettingsSection,
18
- SELECT_CLASS,
19
- AssetPathInput,
20
- } from "../editors/shared";
21
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
22
- import {
23
- getRowSettingValue,
24
- hasRowSettingOverride,
25
- setRowResponsiveOverride,
26
- } from "./responsive-helpers";
27
- import { TRBLInputs } from "./TRBLInputs";
28
-
29
- /**
30
- * BUG-007 fix: LayoutTab now handles PageSection styling (spacing, background, border).
31
- * BUG-013 fix: Sections support responsive overrides (tablet/phone).
32
- */
33
- export function LayoutTab({ section, sectionKey }: { section: PageSection; sectionKey: string }) {
34
- const store = useBuilderStore();
35
- const paletteSwatches = usePaletteSwatches();
36
- const settings = section.settings || {};
37
- const activeViewport = store.activeViewport;
38
-
39
- const updateSetting = (updates: Partial<NonNullable<PageSection["settings"]>>) => {
40
- store.updateSectionSettings(sectionKey, updates);
41
- };
42
-
43
- /** Update a setting, viewport-aware. Supports sections with responsive overrides. */
44
- const updateSettingResponsive = (property: string, value: unknown) => {
45
- // BUG-013 fix: Sections now support responsive overrides
46
- if (activeViewport === "desktop") {
47
- store.updateSectionSettings(sectionKey, { [property]: value });
48
- } else {
49
- // Build responsive override for section
50
- const existing = section.responsive || {};
51
- const vp = activeViewport as "tablet" | "phone";
52
- const vpOverrides = { ...(existing[vp] || {}), [property]: value };
53
- if (value === undefined) delete (vpOverrides as Record<string, unknown>)[property];
54
- const responsive = { ...existing, [vp]: vpOverrides };
55
- if (Object.keys(vpOverrides).length === 0) delete responsive[vp];
56
- store.updateSectionResponsive(sectionKey, Object.keys(responsive).length ? responsive : undefined);
57
- }
58
- };
59
-
60
- // Resolve effective spacing shows real values even when legacy enum is active
61
- const effective = resolveEffectiveSpacing(settings);
62
-
63
- // Viewport-aware spacing values
64
- const effectiveSpacingTop = getRowSettingValue<string>(section, activeViewport, "spacing_top", effective.top);
65
- const effectiveSpacingRight = getRowSettingValue<string>(section, activeViewport, "spacing_right", effective.right);
66
- const effectiveSpacingBottom = getRowSettingValue<string>(section, activeViewport, "spacing_bottom", effective.bottom);
67
- const effectiveSpacingLeft = getRowSettingValue<string>(section, activeViewport, "spacing_left", effective.left);
68
-
69
- // Parse background color + opacity to display
70
- const bgOpacity = getRowSettingValue<number>(section, activeViewport, "background_opacity", settings.background_opacity ?? 100);
71
-
72
- const viewportLabel = activeViewport !== "desktop"
73
- ? activeViewport === "tablet" ? "Tablet" : "Phone"
74
- : null;
75
-
76
- return (
77
- <>
78
- {viewportLabel && (
79
- <div className="px-4 pt-3">
80
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
81
- <span className="text-[11px] font-medium text-[#076bff]">
82
- Editing {viewportLabel} overrides
83
- </span>
84
- </div>
85
- </div>
86
- )}
87
-
88
- {/* Spacing (Padding) */}
89
- <SettingsSection title="Spacing" defaultOpen>
90
- <TRBLInputs
91
- top={effectiveSpacingTop}
92
- right={effectiveSpacingRight}
93
- bottom={effectiveSpacingBottom}
94
- left={effectiveSpacingLeft}
95
- onChange={(field, value) => {
96
- if (activeViewport === "desktop") {
97
- // When user edits TRBL, set explicit TRBL values
98
- const base = resolveEffectiveSpacing(settings);
99
- updateSetting({
100
- spacing_top: field === "top" ? value : (settings.spacing_top ?? base.top),
101
- spacing_right: field === "right" ? value : (settings.spacing_right ?? base.right),
102
- spacing_bottom: field === "bottom" ? value : (settings.spacing_bottom ?? base.bottom),
103
- spacing_left: field === "left" ? value : (settings.spacing_left ?? base.left),
104
- });
105
- } else {
106
- updateSettingResponsive(`spacing_${field}`, value);
107
- }
108
- }}
109
- />
110
- {activeViewport !== "desktop" && (
111
- ["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"].some(
112
- (p) => hasRowSettingOverride(section, activeViewport, p)
113
- ) ? (
114
- <div className="flex items-center gap-2 mt-1">
115
- <span className="text-[9px] text-[#076bff]">overridden</span>
116
- <button
117
- onClick={() => {
118
- // BUG-021 fix: use proper store action for responsive reset
119
- let updated = section;
120
- ["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"].forEach((p) => {
121
- const updates = setRowResponsiveOverride(updated, activeViewport, p, undefined);
122
- if (updates.responsive !== undefined) {
123
- updated = { ...updated, responsive: updates.responsive };
124
- }
125
- });
126
- store.updateSectionResponsive(sectionKey, (updated as PageSection).responsive);
127
- }}
128
- className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
129
- >
130
- Reset
131
- </button>
132
- </div>
133
- ) : (
134
- <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>
135
- )
136
- )}
137
- </SettingsSection>
138
-
139
- {/* Background */}
140
- <SettingsSection title="Background" defaultOpen>
141
- <SettingsField label="Color">
142
- <ColorSwatchPicker
143
- value={getRowSettingValue<string>(section, activeViewport, "background_color", "")}
144
- onChange={(hex) => updateSettingResponsive("background_color", hex)}
145
- swatches={paletteSwatches}
146
- />
147
- </SettingsField>
148
-
149
- <SettingsField label="Opacity">
150
- <div className="flex items-center gap-2">
151
- <input
152
- type="range"
153
- min={0}
154
- max={100}
155
- value={bgOpacity}
156
- onChange={(e) => updateSettingResponsive("background_opacity", parseInt(e.target.value))}
157
- className="flex-1 accent-[#076bff]"
158
- />
159
- <span className="text-xs text-neutral-900 w-10 text-right">
160
- {bgOpacity}%
161
- </span>
162
- </div>
163
- </SettingsField>
164
-
165
- <SettingsField label="Image">
166
- <AssetPathInput
167
- value={getRowSettingValue<string>(section, activeViewport, "background_image", "")}
168
- onFocus={() => store._pushSnapshot()}
169
- onChange={(v) => updateSettingResponsive("background_image", v)}
170
- placeholder="path/to/image.jpg"
171
- filterType="image"
172
- />
173
- </SettingsField>
174
-
175
- {getRowSettingValue<string>(section, activeViewport, "background_image", "") && (
176
- <>
177
- <SettingsField label="Size">
178
- <select
179
- value={getRowSettingValue<string>(section, activeViewport, "background_size", "cover")}
180
- onChange={(e) => updateSettingResponsive("background_size", e.target.value)}
181
- className={SELECT_CLASS}
182
- >
183
- <option value="cover">Cover</option>
184
- <option value="contain">Contain</option>
185
- <option value="auto">Auto</option>
186
- </select>
187
- </SettingsField>
188
-
189
- <SettingsField label="Position">
190
- <select
191
- value={getRowSettingValue<string>(section, activeViewport, "background_position", "center center")}
192
- onFocus={() => store._pushSnapshot()}
193
- onChange={(e) => updateSettingResponsive("background_position", e.target.value)}
194
- className={SELECT_CLASS}
195
- >
196
- <option value="center center">Center</option>
197
- <option value="top center">Top</option>
198
- <option value="bottom center">Bottom</option>
199
- <option value="left center">Left</option>
200
- <option value="right center">Right</option>
201
- <option value="top left">Top Left</option>
202
- <option value="top right">Top Right</option>
203
- <option value="bottom left">Bottom Left</option>
204
- <option value="bottom right">Bottom Right</option>
205
- </select>
206
- </SettingsField>
207
-
208
- <SettingsField label="Repeat">
209
- <select
210
- value={getRowSettingValue<string>(section, activeViewport, "background_repeat", "no-repeat")}
211
- onChange={(e) => updateSettingResponsive("background_repeat", e.target.value)}
212
- className={SELECT_CLASS}
213
- >
214
- <option value="no-repeat">No Repeat</option>
215
- <option value="repeat">Repeat</option>
216
- <option value="repeat-x">Repeat X</option>
217
- <option value="repeat-y">Repeat Y</option>
218
- </select>
219
- </SettingsField>
220
- </>
221
- )}
222
- </SettingsSection>
223
-
224
- {/* Offset (Margin) */}
225
- <SettingsSection title="Offset">
226
- <TRBLInputs
227
- top={getRowSettingValue<string>(section, activeViewport, "offset_top", "0")}
228
- right={getRowSettingValue<string>(section, activeViewport, "offset_right", "0")}
229
- bottom={getRowSettingValue<string>(section, activeViewport, "offset_bottom", "0")}
230
- left={getRowSettingValue<string>(section, activeViewport, "offset_left", "0")}
231
- onChange={(field, value) => {
232
- updateSettingResponsive(`offset_${field}`, value);
233
- }}
234
- />
235
- </SettingsSection>
236
-
237
- {/* Border */}
238
- <SettingsSection title="Border">
239
- <SettingsField label="Color">
240
- <ColorSwatchPicker
241
- value={getRowSettingValue<string>(section, activeViewport, "border_color", "")}
242
- onChange={(hex) => updateSettingResponsive("border_color", hex)}
243
- swatches={paletteSwatches}
244
- />
245
- </SettingsField>
246
-
247
- <SettingsField label="Width">
248
- <div className="flex items-center gap-2">
249
- <input
250
- type="range"
251
- min={0}
252
- max={20}
253
- value={parseInt(getRowSettingValue<string>(section, activeViewport, "border_width", "0"))}
254
- onChange={(e) => updateSettingResponsive("border_width", e.target.value)}
255
- className="flex-1 accent-[#076bff]"
256
- />
257
- <span className="text-xs text-neutral-900 w-10 text-right">
258
- {getRowSettingValue<string>(section, activeViewport, "border_width", "0")}px
259
- </span>
260
- </div>
261
- </SettingsField>
262
-
263
- <SettingsField label="Style">
264
- <select
265
- value={getRowSettingValue<string>(section, activeViewport, "border_style", "none")}
266
- onChange={(e) => updateSettingResponsive("border_style", e.target.value)}
267
- className={SELECT_CLASS}
268
- >
269
- <option value="none">None</option>
270
- <option value="solid">Solid</option>
271
- <option value="dashed">Dashed</option>
272
- <option value="dotted">Dotted</option>
273
- </select>
274
- </SettingsField>
275
-
276
- <SettingsField label="Sides">
277
- <select
278
- value={getRowSettingValue<string>(section, activeViewport, "border_sides", "all")}
279
- onChange={(e) => updateSettingResponsive("border_sides", e.target.value)}
280
- className={SELECT_CLASS}
281
- >
282
- <option value="all">All</option>
283
- <option value="top">Top</option>
284
- <option value="right">Right</option>
285
- <option value="bottom">Bottom</option>
286
- <option value="left">Left</option>
287
- <option value="top-bottom">Top & Bottom</option>
288
- <option value="left-right">Left & Right</option>
289
- </select>
290
- </SettingsField>
291
-
292
- <SettingsField label="Radius">
293
- <div className="flex items-center gap-2">
294
- <input
295
- type="range"
296
- min={0}
297
- max={50}
298
- value={parseInt(getRowSettingValue<string>(section, activeViewport, "border_radius", "0"))}
299
- onChange={(e) => updateSettingResponsive("border_radius", e.target.value)}
300
- className="flex-1 accent-[#076bff]"
301
- />
302
- <span className="text-xs text-neutral-900 w-10 text-right">
303
- {getRowSettingValue<string>(section, activeViewport, "border_radius", "0")}px
304
- </span>
305
- </div>
306
- </SettingsField>
307
- </SettingsSection>
308
- </>
309
- );
310
- }
1
+ "use client";
2
+
3
+ /**
4
+ * LayoutTab — Page Section styling (spacing, offset, background, border).
5
+ * Viewport-aware with responsive override support.
6
+ *
7
+ * Session 64: Extracted from SettingsPanel.tsx.
8
+ * Session 65: Split out RowLayoutPresetPicker, TRBLInputs, BlockLayoutTab
9
+ * into separate modules. This file now contains only LayoutTab.
10
+ * Session 158: Added section title icons matching BlockLayoutTab/SectionV2LayoutTab.
11
+ * Reordered sections: Spacing → Offset → Background → Border.
12
+ */
13
+
14
+ import { useBuilderStore } from "../../../lib/builder/store";
15
+ import { resolveEffectiveSpacing } from "../../../lib/builder/layout-styles";
16
+ import type { PageSection } from "../../../lib/sanity/types";
17
+ import {
18
+ SettingsField,
19
+ SettingsSection,
20
+ SELECT_CLASS,
21
+ AssetPathInput,
22
+ } from "../editors/shared";
23
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
24
+ import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
25
+ import {
26
+ getRowSettingValue,
27
+ hasRowSettingOverride,
28
+ setRowResponsiveOverride,
29
+ } from "./responsive-helpers";
30
+ import { TRBLInputs } from "./TRBLInputs";
31
+
32
+ // ── Section title icons (matching BlockLayoutTab / SectionV2LayoutTab) ──
33
+
34
+ function SpacingSectionIcon() {
35
+ return (
36
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
37
+ <rect x="4" y="4" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.5" />
38
+ <path d="M7 1 L7 3.5" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
39
+ <path d="M7 10.5 L7 13" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
40
+ <path d="M1 7 L3.5 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
41
+ <path d="M10.5 7 L13 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ function OffsetSectionIcon() {
47
+ return (
48
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
49
+ <rect x="3" y="3" width="8" height="8" rx="1" stroke="currentColor" strokeWidth="0.8" strokeDasharray="2 1" fill="none" opacity="0.35" />
50
+ <rect x="5" y="5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.7" />
51
+ <path d="M4 4 L5 5" stroke="currentColor" strokeWidth="0.6" opacity="0.5" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ function BackgroundSectionIcon() {
57
+ return (
58
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
59
+ <rect x="1.5" y="1.5" width="11" height="11" rx="2" fill="currentColor" opacity="0.15" />
60
+ <rect x="1.5" y="1.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="0.8" opacity="0.5" fill="none" />
61
+ <circle cx="5" cy="5" r="1.5" fill="currentColor" opacity="0.5" />
62
+ <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" />
63
+ </svg>
64
+ );
65
+ }
66
+
67
+ function BorderSectionIcon() {
68
+ return (
69
+ <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
70
+ <rect x="2" y="2" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.2" fill="none" opacity="0.6" />
71
+ <rect x="2" y="2" width="10" height="1.2" rx="0.5" fill="currentColor" opacity="0.7" />
72
+ </svg>
73
+ );
74
+ }
75
+
76
+ /**
77
+ * BUG-007 fix: LayoutTab now handles PageSection styling (spacing, background, border).
78
+ * BUG-013 fix: Sections support responsive overrides (tablet/phone).
79
+ */
80
+ export function LayoutTab({ section, sectionKey }: { section: PageSection; sectionKey: string }) {
81
+ const store = useBuilderStore();
82
+ const paletteSwatches = usePaletteSwatches();
83
+ const settings = section.settings || {};
84
+ const activeViewport = store.activeViewport;
85
+
86
+ // Live preview callbacks (Phase 4)
87
+ const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
88
+ store.setColorPickerPreview({ sectionKey, field: "background_color", value: val });
89
+ };
90
+ const handleBorderPreview = (val: import("../../../lib/sanity/types").ColorField) => {
91
+ store.setColorPickerPreview({ sectionKey, field: "border_color", value: val });
92
+ };
93
+
94
+ const updateSetting = (updates: Partial<NonNullable<PageSection["settings"]>>) => {
95
+ store.updateSectionSettings(sectionKey, updates);
96
+ };
97
+
98
+ /** Update a setting, viewport-aware. Supports sections with responsive overrides. */
99
+ const updateSettingResponsive = (property: string, value: unknown) => {
100
+ // BUG-013 fix: Sections now support responsive overrides
101
+ if (activeViewport === "desktop") {
102
+ store.updateSectionSettings(sectionKey, { [property]: value });
103
+ } else {
104
+ // Build responsive override for section
105
+ const existing = section.responsive || {};
106
+ const vp = activeViewport as "tablet" | "phone";
107
+ const vpOverrides = { ...(existing[vp] || {}), [property]: value };
108
+ if (value === undefined) delete (vpOverrides as Record<string, unknown>)[property];
109
+ const responsive = { ...existing, [vp]: vpOverrides };
110
+ if (Object.keys(vpOverrides).length === 0) delete responsive[vp];
111
+ store.updateSectionResponsive(sectionKey, Object.keys(responsive).length ? responsive : undefined);
112
+ }
113
+ };
114
+
115
+ // Resolve effective spacing — shows real values even when legacy enum is active
116
+ const effective = resolveEffectiveSpacing(settings);
117
+
118
+ // Viewport-aware spacing values
119
+ const effectiveSpacingTop = getRowSettingValue<string>(section, activeViewport, "spacing_top", effective.top);
120
+ const effectiveSpacingRight = getRowSettingValue<string>(section, activeViewport, "spacing_right", effective.right);
121
+ const effectiveSpacingBottom = getRowSettingValue<string>(section, activeViewport, "spacing_bottom", effective.bottom);
122
+ const effectiveSpacingLeft = getRowSettingValue<string>(section, activeViewport, "spacing_left", effective.left);
123
+
124
+ // Parse background color + opacity to display
125
+ const bgOpacity = getRowSettingValue<number>(section, activeViewport, "background_opacity", settings.background_opacity ?? 100);
126
+
127
+ const viewportLabel = activeViewport !== "desktop"
128
+ ? activeViewport === "tablet" ? "Tablet" : "Phone"
129
+ : null;
130
+
131
+ return (
132
+ <>
133
+ {viewportLabel && (
134
+ <div className="px-4 pt-3">
135
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
136
+ <span className="text-[11px] font-medium text-[#076bff]">
137
+ Editing {viewportLabel} overrides
138
+ </span>
139
+ </div>
140
+ </div>
141
+ )}
142
+
143
+ {/* Spacing (Padding) */}
144
+ <SettingsSection title="Spacing" defaultOpen icon={<SpacingSectionIcon />}>
145
+ <TRBLInputs
146
+ top={effectiveSpacingTop}
147
+ right={effectiveSpacingRight}
148
+ bottom={effectiveSpacingBottom}
149
+ left={effectiveSpacingLeft}
150
+ onChange={(field, value) => {
151
+ if (activeViewport === "desktop") {
152
+ // When user edits TRBL, set explicit TRBL values
153
+ const base = resolveEffectiveSpacing(settings);
154
+ updateSetting({
155
+ spacing_top: field === "top" ? value : (settings.spacing_top ?? base.top),
156
+ spacing_right: field === "right" ? value : (settings.spacing_right ?? base.right),
157
+ spacing_bottom: field === "bottom" ? value : (settings.spacing_bottom ?? base.bottom),
158
+ spacing_left: field === "left" ? value : (settings.spacing_left ?? base.left),
159
+ });
160
+ } else {
161
+ updateSettingResponsive(`spacing_${field}`, value);
162
+ }
163
+ }}
164
+ />
165
+ {activeViewport !== "desktop" && (
166
+ ["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"].some(
167
+ (p) => hasRowSettingOverride(section, activeViewport, p)
168
+ ) ? (
169
+ <div className="flex items-center gap-2 mt-1">
170
+ <span className="text-[9px] text-[#076bff]">overridden</span>
171
+ <button
172
+ onClick={() => {
173
+ // BUG-021 fix: use proper store action for responsive reset
174
+ let updated = section;
175
+ ["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"].forEach((p) => {
176
+ const updates = setRowResponsiveOverride(updated, activeViewport, p, undefined);
177
+ if (updates.responsive !== undefined) {
178
+ updated = { ...updated, responsive: updates.responsive };
179
+ }
180
+ });
181
+ store.updateSectionResponsive(sectionKey, (updated as PageSection).responsive);
182
+ }}
183
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
184
+ >
185
+ Reset
186
+ </button>
187
+ </div>
188
+ ) : (
189
+ <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>
190
+ )
191
+ )}
192
+ </SettingsSection>
193
+
194
+ {/* Offset (Margin) */}
195
+ <SettingsSection title="Offset" icon={<OffsetSectionIcon />}>
196
+ <TRBLInputs
197
+ top={getRowSettingValue<string>(section, activeViewport, "offset_top", "0")}
198
+ right={getRowSettingValue<string>(section, activeViewport, "offset_right", "0")}
199
+ bottom={getRowSettingValue<string>(section, activeViewport, "offset_bottom", "0")}
200
+ left={getRowSettingValue<string>(section, activeViewport, "offset_left", "0")}
201
+ onChange={(field, value) => {
202
+ updateSettingResponsive(`offset_${field}`, value);
203
+ }}
204
+ />
205
+ </SettingsSection>
206
+
207
+ {/* Background */}
208
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundSectionIcon />}>
209
+ <SettingsField label="Color">
210
+ <ColorSwatchPicker
211
+ value={parseColorField(getRowSettingValue<string>(section, activeViewport, "background_color", ""))}
212
+ onChange={(val) => { store.clearColorPickerPreview(); updateSettingResponsive("background_color", serializeColorField(val)); }}
213
+ swatches={paletteSwatches}
214
+ allowGradients
215
+ onPreview={handleBgPreview}
216
+ />
217
+ </SettingsField>
218
+
219
+ <SettingsField label="Opacity">
220
+ {(() => {
221
+ const bgIsGrad = isGradient(parseColorField(getRowSettingValue<string>(section, activeViewport, "background_color", "")));
222
+ return (
223
+ <>
224
+ <div className="flex items-center gap-2">
225
+ <input
226
+ type="range"
227
+ min={0}
228
+ max={100}
229
+ value={bgOpacity}
230
+ onChange={(e) => updateSettingResponsive("background_opacity", parseInt(e.target.value))}
231
+ className={`flex-1 accent-[#076bff] ${bgIsGrad ? "opacity-40 pointer-events-none" : ""}`}
232
+ disabled={bgIsGrad}
233
+ />
234
+ <span className="text-xs text-neutral-900 w-10 text-right">
235
+ {bgOpacity}%
236
+ </span>
237
+ </div>
238
+ {bgIsGrad && (
239
+ <p className="text-[9px] text-neutral-400 italic mt-1">
240
+ Opacity is controlled per stop in gradient mode
241
+ </p>
242
+ )}
243
+ </>
244
+ );
245
+ })()}
246
+ </SettingsField>
247
+
248
+ <SettingsField label="Image">
249
+ <AssetPathInput
250
+ value={getRowSettingValue<string>(section, activeViewport, "background_image", "")}
251
+ onFocus={() => store._pushSnapshot()}
252
+ onChange={(v) => updateSettingResponsive("background_image", v)}
253
+ placeholder="path/to/image.jpg"
254
+ filterType="image"
255
+ />
256
+ </SettingsField>
257
+
258
+ {getRowSettingValue<string>(section, activeViewport, "background_image", "") && (
259
+ <>
260
+ <SettingsField label="Size">
261
+ <select
262
+ value={getRowSettingValue<string>(section, activeViewport, "background_size", "cover")}
263
+ onChange={(e) => updateSettingResponsive("background_size", e.target.value)}
264
+ className={SELECT_CLASS}
265
+ >
266
+ <option value="cover">Cover</option>
267
+ <option value="contain">Contain</option>
268
+ <option value="auto">Auto</option>
269
+ </select>
270
+ </SettingsField>
271
+
272
+ <SettingsField label="Position">
273
+ <select
274
+ value={getRowSettingValue<string>(section, activeViewport, "background_position", "center center")}
275
+ onFocus={() => store._pushSnapshot()}
276
+ onChange={(e) => updateSettingResponsive("background_position", e.target.value)}
277
+ className={SELECT_CLASS}
278
+ >
279
+ <option value="center center">Center</option>
280
+ <option value="top center">Top</option>
281
+ <option value="bottom center">Bottom</option>
282
+ <option value="left center">Left</option>
283
+ <option value="right center">Right</option>
284
+ <option value="top left">Top Left</option>
285
+ <option value="top right">Top Right</option>
286
+ <option value="bottom left">Bottom Left</option>
287
+ <option value="bottom right">Bottom Right</option>
288
+ </select>
289
+ </SettingsField>
290
+
291
+ <SettingsField label="Repeat">
292
+ <select
293
+ value={getRowSettingValue<string>(section, activeViewport, "background_repeat", "no-repeat")}
294
+ onChange={(e) => updateSettingResponsive("background_repeat", e.target.value)}
295
+ className={SELECT_CLASS}
296
+ >
297
+ <option value="no-repeat">No Repeat</option>
298
+ <option value="repeat">Repeat</option>
299
+ <option value="repeat-x">Repeat X</option>
300
+ <option value="repeat-y">Repeat Y</option>
301
+ </select>
302
+ </SettingsField>
303
+ </>
304
+ )}
305
+ </SettingsSection>
306
+
307
+ {/* Border */}
308
+ <SettingsSection title="Border" icon={<BorderSectionIcon />}>
309
+ <SettingsField label="Color">
310
+ <ColorSwatchPicker
311
+ value={parseColorField(getRowSettingValue<string>(section, activeViewport, "border_color", ""))}
312
+ onChange={(val) => { store.clearColorPickerPreview(); updateSettingResponsive("border_color", serializeColorField(val)); }}
313
+ swatches={paletteSwatches}
314
+ allowGradients
315
+ onPreview={handleBorderPreview}
316
+ />
317
+ </SettingsField>
318
+
319
+ <SettingsField label="Width">
320
+ <div className="flex items-center gap-2">
321
+ <input
322
+ type="range"
323
+ min={0}
324
+ max={20}
325
+ value={parseInt(getRowSettingValue<string>(section, activeViewport, "border_width", "0"))}
326
+ onChange={(e) => updateSettingResponsive("border_width", e.target.value)}
327
+ className="flex-1 accent-[#076bff]"
328
+ />
329
+ <span className="text-xs text-neutral-900 w-10 text-right">
330
+ {getRowSettingValue<string>(section, activeViewport, "border_width", "0")}px
331
+ </span>
332
+ </div>
333
+ </SettingsField>
334
+
335
+ <SettingsField label="Style">
336
+ <select
337
+ value={getRowSettingValue<string>(section, activeViewport, "border_style", "none")}
338
+ onChange={(e) => updateSettingResponsive("border_style", e.target.value)}
339
+ className={SELECT_CLASS}
340
+ >
341
+ <option value="none">None</option>
342
+ <option value="solid">Solid</option>
343
+ <option value="dashed">Dashed</option>
344
+ <option value="dotted">Dotted</option>
345
+ </select>
346
+ </SettingsField>
347
+
348
+ <SettingsField label="Sides">
349
+ <select
350
+ value={getRowSettingValue<string>(section, activeViewport, "border_sides", "all")}
351
+ onChange={(e) => updateSettingResponsive("border_sides", e.target.value)}
352
+ className={SELECT_CLASS}
353
+ >
354
+ <option value="all">All</option>
355
+ <option value="top">Top</option>
356
+ <option value="right">Right</option>
357
+ <option value="bottom">Bottom</option>
358
+ <option value="left">Left</option>
359
+ <option value="top-bottom">Top & Bottom</option>
360
+ <option value="left-right">Left & Right</option>
361
+ </select>
362
+ </SettingsField>
363
+
364
+ <SettingsField label="Radius">
365
+ <div className="flex items-center gap-2">
366
+ <input
367
+ type="range"
368
+ min={0}
369
+ max={50}
370
+ value={parseInt(getRowSettingValue<string>(section, activeViewport, "border_radius", "0"))}
371
+ onChange={(e) => updateSettingResponsive("border_radius", e.target.value)}
372
+ className="flex-1 accent-[#076bff]"
373
+ />
374
+ <span className="text-xs text-neutral-900 w-10 text-right">
375
+ {getRowSettingValue<string>(section, activeViewport, "border_radius", "0")}px
376
+ </span>
377
+ </div>
378
+ </SettingsField>
379
+ </SettingsSection>
380
+ </>
381
+ );
382
+ }