@morphika/andami 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/app/admin/pages/[slug]/page.tsx +3 -7
  2. package/app/api/admin/pages/[slug]/route.ts +2 -28
  3. package/app/api/admin/settings/route.ts +30 -0
  4. package/components/admin/nav-builder/NavBuilder.tsx +90 -14
  5. package/components/admin/nav-builder/NavGeneralSettings.tsx +521 -271
  6. package/components/admin/nav-builder/NavItemSettings.tsx +331 -312
  7. package/components/admin/nav-builder/NavMobileSettings.tsx +159 -140
  8. package/components/admin/nav-builder/NavSettingsFields.tsx +287 -21
  9. package/components/admin/nav-builder/NavSettingsPanel.tsx +137 -127
  10. package/components/blocks/EnterAnimationWrapper.tsx +19 -4
  11. package/components/blocks/PageRenderer.tsx +2 -15
  12. package/components/blocks/ProjectGridBlockRenderer.tsx +34 -36
  13. package/components/blocks/TextBlockRenderer.tsx +1 -1
  14. package/components/builder/DndWrapper.tsx +2 -24
  15. package/components/builder/InsertionLines.tsx +5 -5
  16. package/components/builder/ReadOnlyFrame.tsx +5 -49
  17. package/components/builder/SectionV2Canvas.tsx +2 -2
  18. package/components/builder/SectionV2Column.tsx +5 -5
  19. package/components/builder/SettingsPanel.tsx +0 -12
  20. package/components/builder/SortableBlock.tsx +3 -3
  21. package/components/builder/SortableRow.tsx +6 -27
  22. package/components/builder/editors/ButtonBlockEditor.tsx +8 -3
  23. package/components/builder/editors/CoverBlockEditor.tsx +14 -6
  24. package/components/builder/editors/ImageBlockEditor.tsx +8 -3
  25. package/components/builder/editors/ImageGridBlockEditor.tsx +8 -3
  26. package/components/builder/editors/ProjectGridEditor.tsx +7 -46
  27. package/components/builder/editors/SpacerBlockEditor.tsx +4 -1
  28. package/components/builder/editors/StaggerSettings.tsx +2 -1
  29. package/components/builder/editors/TextBlockEditor.tsx +8 -3
  30. package/components/builder/editors/VideoBlockEditor.tsx +10 -4
  31. package/components/builder/editors/section-icons.tsx +492 -0
  32. package/components/builder/editors/shared.tsx +23 -4
  33. package/components/builder/live-preview/LiveTextEditor.tsx +1 -1
  34. package/components/builder/live-preview/ProjectCardWrapper.tsx +3 -3
  35. package/components/builder/live-preview/drag-utils.tsx +2 -2
  36. package/components/builder/settings-panel/AnimationTab.tsx +2 -16
  37. package/components/builder/settings-panel/BlockLayoutTab.tsx +13 -58
  38. package/components/builder/settings-panel/ColumnV2Settings.tsx +4 -1
  39. package/components/builder/settings-panel/PageSettings.tsx +10 -4
  40. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +6 -2
  41. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +8 -3
  42. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +11 -47
  43. package/components/builder/settings-panel/SectionV2Settings.tsx +6 -27
  44. package/components/builder/settings-panel/index.ts +0 -1
  45. package/components/builder/settings-panel/responsive-helpers.ts +2 -50
  46. package/components/builder/settings-panel/useSettingsPanelSelection.ts +1 -16
  47. package/components/ui/Navbar.tsx +151 -30
  48. package/lib/builder/constants.ts +5 -4
  49. package/lib/builder/serializer/normalizers.ts +2 -40
  50. package/lib/builder/serializer/serializers.ts +3 -74
  51. package/lib/builder/store-blocks.ts +3 -19
  52. package/lib/builder/store-helpers.ts +2 -2
  53. package/lib/builder/store-sections.ts +26 -64
  54. package/lib/builder/store.ts +3 -6
  55. package/lib/builder/templates.ts +9 -45
  56. package/lib/builder/types.ts +4 -11
  57. package/lib/sanity/queries.ts +6 -29
  58. package/lib/sanity/types.ts +24 -70
  59. package/package.json +4 -1
  60. package/sanity/schemas/index.ts +0 -5
  61. package/sanity/schemas/objects/parallaxGroup.ts +2 -2
  62. package/sanity/schemas/page.ts +1 -1
  63. package/sanity/schemas/pageSectionV2.ts +1 -0
  64. package/sanity/schemas/siteSettings.ts +42 -0
  65. package/styles/base.css +8 -2
  66. package/components/blocks/SectionRenderer.tsx +0 -171
  67. package/components/builder/settings-panel/LayoutTab.tsx +0 -382
  68. package/sanity/schemas/pageSection.ts +0 -157
@@ -1,271 +1,521 @@
1
- "use client";
2
-
3
- import type { NavDesign, ColorField } from "../../../lib/sanity/types";
4
- import { getSiteConfig } from "../../../lib/config";
5
- import ColorSwatchPicker, { usePaletteSwatches } from "../../builder/ColorSwatchPicker";
6
- import {
7
- Field,
8
- TextInput,
9
- SelectInput,
10
- SegmentedControl,
11
- Toggle,
12
- RangeSlider,
13
- Section,
14
- } from "./NavSettingsFields";
15
-
16
- interface NavGeneralSettingsProps {
17
- design: NavDesign;
18
- activeTab: "settings" | "layout";
19
- onChange: (design: NavDesign) => void;
20
- fonts: string[];
21
- }
22
-
23
- export default function NavGeneralSettings({
24
- design,
25
- activeTab,
26
- onChange,
27
- fonts,
28
- }: NavGeneralSettingsProps) {
29
- const swatches = usePaletteSwatches();
30
-
31
- const update = (partial: Partial<NavDesign>) =>
32
- onChange({ ...design, ...partial });
33
-
34
- if (activeTab === "settings") {
35
- return (
36
- <>
37
- <Section title="POSITION">
38
- <Field label="Position">
39
- <SegmentedControl
40
- value={design.position || "fixed"}
41
- onChange={(v) => update({ position: v as NavDesign["position"] })}
42
- options={[
43
- { value: "fixed", label: "Fixed" },
44
- { value: "sticky", label: "Sticky" },
45
- { value: "static", label: "Static" },
46
- ]}
47
- />
48
- </Field>
49
- <Field label="Hide scroll">
50
- <Toggle
51
- value={design.hide_on_scroll ?? true}
52
- onChange={(v) => update({ hide_on_scroll: v })}
53
- />
54
- </Field>
55
- </Section>
56
-
57
- <Section title="TYPOGRAPHY">
58
- <Field label="Font">
59
- <SelectInput
60
- value={design.font_family || ""}
61
- onChange={(v) => update({ font_family: v })}
62
- options={[
63
- { value: "", label: `Default (${getSiteConfig().typography.defaultFont})` },
64
- ...fonts.map((f) => ({ value: f, label: f })),
65
- ]}
66
- />
67
- </Field>
68
- <Field label="Size">
69
- <TextInput
70
- value={design.font_size ?? 14}
71
- onChange={(v) => update({ font_size: Math.max(8, Math.min(48, parseInt(v) || 14)) })}
72
- type="number"
73
- />
74
- </Field>
75
- <Field label="Weight">
76
- <SelectInput
77
- value={design.font_weight || "400"}
78
- onChange={(v) => update({ font_weight: v })}
79
- options={[
80
- { value: "100", label: "100 · Thin" },
81
- { value: "200", label: "200 · Extra Light" },
82
- { value: "300", label: "300 · Light" },
83
- { value: "400", label: "400 · Normal" },
84
- { value: "500", label: "500 · Medium" },
85
- { value: "600", label: "600 · Semi Bold" },
86
- { value: "700", label: "700 · Bold" },
87
- { value: "800", label: "800 · Extra Bold" },
88
- { value: "900", label: "900 · Black" },
89
- ]}
90
- />
91
- </Field>
92
- <Field label="Transform">
93
- <SelectInput
94
- value={design.text_transform || "uppercase"}
95
- onChange={(v) => update({ text_transform: v as NavDesign["text_transform"] })}
96
- options={[
97
- { value: "uppercase", label: "UPPERCASE" },
98
- { value: "none", label: "None" },
99
- { value: "lowercase", label: "lowercase" },
100
- { value: "capitalize", label: "Capitalize" },
101
- ]}
102
- />
103
- </Field>
104
- <Field label="Align">
105
- <SegmentedControl
106
- value={design.text_align || "left"}
107
- onChange={(v) => update({ text_align: v as NavDesign["text_align"] })}
108
- options={[
109
- { value: "left", label: "Left" },
110
- { value: "center", label: "Center" },
111
- { value: "right", label: "Right" },
112
- ]}
113
- />
114
- </Field>
115
- <Field label="V. Align">
116
- <SegmentedControl
117
- value={design.vertical_align || "top"}
118
- onChange={(v) => update({ vertical_align: v as NavDesign["vertical_align"] })}
119
- options={[
120
- { value: "top", label: "Top" },
121
- { value: "middle", label: "Middle" },
122
- { value: "bottom", label: "Bottom" },
123
- ]}
124
- />
125
- </Field>
126
- <Field label="Color">
127
- <ColorSwatchPicker
128
- value={design.color || ""}
129
- onChange={(v) => update({ color: typeof v === "string" ? v : "" })}
130
- swatches={swatches}
131
- />
132
- </Field>
133
- </Section>
134
-
135
- <Section title="ANIMATION">
136
- <Field label="Entrance">
137
- <SelectInput
138
- value={design.entrance_animation || ""}
139
- onChange={(v) => update({ entrance_animation: v as NavDesign["entrance_animation"] })}
140
- options={[
141
- { value: "", label: "None" },
142
- { value: "fade-in", label: "Fade In" },
143
- { value: "slide-down", label: "Slide Down" },
144
- { value: "blur-in", label: "Blur In" },
145
- ]}
146
- />
147
- </Field>
148
- {design.entrance_animation && (
149
- <>
150
- <Field label="Duration">
151
- <RangeSlider
152
- value={design.entrance_duration ?? 600}
153
- onChange={(v) => update({ entrance_duration: v })}
154
- min={200}
155
- max={5000}
156
- suffix="ms"
157
- />
158
- </Field>
159
- <Field label="Delay">
160
- <RangeSlider
161
- value={design.entrance_delay ?? 0}
162
- onChange={(v) => update({ entrance_delay: v })}
163
- min={0}
164
- max={5000}
165
- suffix="ms"
166
- />
167
- </Field>
168
- <Field label="Stagger">
169
- <Toggle
170
- value={design.entrance_stagger ?? false}
171
- onChange={(v) => update({ entrance_stagger: v })}
172
- />
173
- </Field>
174
- {design.entrance_stagger && (
175
- <Field label="Stagger delay">
176
- <RangeSlider
177
- value={design.entrance_stagger_delay ?? 80}
178
- onChange={(v) => update({ entrance_stagger_delay: v })}
179
- min={20}
180
- max={300}
181
- suffix="ms"
182
- />
183
- </Field>
184
- )}
185
- </>
186
- )}
187
- </Section>
188
-
189
- <Section title="SPACING">
190
- <Field label="Pad H">
191
- <RangeSlider
192
- value={design.padding_h ?? 24}
193
- onChange={(v) => update({ padding_h: v })}
194
- min={0}
195
- max={120}
196
- suffix="px"
197
- />
198
- </Field>
199
- <Field label="Pad V">
200
- <RangeSlider
201
- value={design.padding_v ?? 27}
202
- onChange={(v) => update({ padding_v: v })}
203
- min={0}
204
- max={80}
205
- suffix="px"
206
- />
207
- </Field>
208
- <Field label="Margin H">
209
- <RangeSlider
210
- value={design.margin_h ?? 0}
211
- onChange={(v) => update({ margin_h: v })}
212
- min={0}
213
- max={120}
214
- suffix="px"
215
- />
216
- </Field>
217
- <Field label="Margin V">
218
- <RangeSlider
219
- value={design.margin_v ?? 0}
220
- onChange={(v) => update({ margin_v: v })}
221
- min={0}
222
- max={80}
223
- suffix="px"
224
- />
225
- </Field>
226
- <Field label="Items gap">
227
- <RangeSlider
228
- value={design.items_gap ?? 32}
229
- onChange={(v) => update({ items_gap: v })}
230
- min={4}
231
- max={80}
232
- suffix="px"
233
- />
234
- </Field>
235
- </Section>
236
- </>
237
- );
238
- }
239
-
240
- // Layout tab
241
- return (
242
- <>
243
- <Section title="BACKGROUND">
244
- <Field label="Color">
245
- <ColorSwatchPicker
246
- value={design.background_color || ""}
247
- onChange={(v) => update({ background_color: typeof v === "string" ? v : "" })}
248
- swatches={swatches}
249
- />
250
- </Field>
251
- {design.background_color && (
252
- <Field label="Opacity">
253
- <RangeSlider
254
- value={design.background_opacity ?? 100}
255
- onChange={(v) => update({ background_opacity: v })}
256
- min={0}
257
- max={100}
258
- suffix="%"
259
- />
260
- </Field>
261
- )}
262
- <Field label="Blur">
263
- <Toggle
264
- value={design.backdrop_blur ?? false}
265
- onChange={(v) => update({ backdrop_blur: v })}
266
- />
267
- </Field>
268
- </Section>
269
- </>
270
- );
271
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import type { NavDesign, NavDesignResponsiveOverride } from "../../../lib/sanity/types";
5
+ import { getSiteConfig } from "../../../lib/config";
6
+ import ColorSwatchPicker, { usePaletteSwatches } from "../../builder/ColorSwatchPicker";
7
+ import {
8
+ Field,
9
+ TextInput,
10
+ SelectInput,
11
+ SegmentedControl,
12
+ Toggle,
13
+ RangeSlider,
14
+ CardSection,
15
+ Divider,
16
+ PositionIcon,
17
+ TypographyIcon,
18
+ AnimationIcon,
19
+ SpacingIcon,
20
+ BackgroundIcon,
21
+ ViewportSwitcher,
22
+ NavViewportBadge,
23
+ ResponsiveField,
24
+ type NavViewport,
25
+ } from "./NavSettingsFields";
26
+
27
+ // ── Responsive helpers ──
28
+
29
+ /** Read a responsive-overridable field value for the given viewport.
30
+ * Desktop always returns the top-level design value.
31
+ * Non-desktop returns the viewport override (or undefined if inherited). */
32
+ function getResponsiveValue<K extends keyof NavDesignResponsiveOverride>(
33
+ design: NavDesign,
34
+ viewport: NavViewport,
35
+ field: K,
36
+ ): NavDesignResponsiveOverride[K] | undefined {
37
+ if (viewport === "desktop") return design[field as keyof NavDesign] as NavDesignResponsiveOverride[K];
38
+ return design.responsive?.[viewport]?.[field] ?? undefined;
39
+ }
40
+
41
+ /** Write a responsive-overridable field value for the given viewport.
42
+ * Desktop writes to the top-level field. Non-desktop writes into the
43
+ * responsive.[viewport] object. Pass `undefined` to clear an override. */
44
+ function setResponsiveValue<K extends keyof NavDesignResponsiveOverride>(
45
+ design: NavDesign,
46
+ viewport: NavViewport,
47
+ field: K,
48
+ value: NavDesignResponsiveOverride[K] | undefined,
49
+ ): NavDesign {
50
+ if (viewport === "desktop") return { ...design, [field]: value };
51
+ const vpOverrides = { ...(design.responsive?.[viewport] ?? {}) };
52
+ if (value === undefined || value === null) {
53
+ delete vpOverrides[field];
54
+ } else {
55
+ (vpOverrides as Record<string, unknown>)[field] = value;
56
+ }
57
+ return {
58
+ ...design,
59
+ responsive: { ...(design.responsive ?? {}), [viewport]: vpOverrides },
60
+ };
61
+ }
62
+
63
+ /** Check if a field is overridden (has a non-null value) for this viewport */
64
+ function isFieldOverridden<K extends keyof NavDesignResponsiveOverride>(
65
+ design: NavDesign,
66
+ viewport: NavViewport,
67
+ field: K,
68
+ ): boolean {
69
+ if (viewport === "desktop") return false;
70
+ const val = design.responsive?.[viewport]?.[field];
71
+ return val !== undefined && val !== null;
72
+ }
73
+
74
+ // ── Alignment icon SVGs for segmented controls ──
75
+
76
+ const AlignLeftIcon = (
77
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
78
+ <line x1="3" y1="6" x2="15" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="17" y2="18" />
79
+ </svg>
80
+ );
81
+ const AlignCenterIcon = (
82
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
83
+ <line x1="5" y1="6" x2="19" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
84
+ </svg>
85
+ );
86
+ const AlignRightIcon = (
87
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
88
+ <line x1="9" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="7" y1="18" x2="21" y2="18" />
89
+ </svg>
90
+ );
91
+ const VAlignTopIcon = (
92
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
93
+ <line x1="12" y1="3" x2="12" y2="15" /><polyline points="8 7 12 3 16 7" /><line x1="4" y1="21" x2="20" y2="21" />
94
+ </svg>
95
+ );
96
+ const VAlignMiddleIcon = (
97
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
98
+ <line x1="4" y1="12" x2="8" y2="12" /><line x1="16" y1="12" x2="20" y2="12" /><rect x="9" y="6" width="6" height="12" rx="1" />
99
+ </svg>
100
+ );
101
+ const VAlignBottomIcon = (
102
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
103
+ <line x1="12" y1="9" x2="12" y2="21" /><polyline points="16 17 12 21 8 17" /><line x1="4" y1="3" x2="20" y2="3" />
104
+ </svg>
105
+ );
106
+
107
+ interface NavGeneralSettingsProps {
108
+ design: NavDesign;
109
+ activeTab: "settings" | "layout" | "animation";
110
+ onChange: (design: NavDesign) => void;
111
+ fonts: string[];
112
+ }
113
+
114
+ export default function NavGeneralSettings({
115
+ design,
116
+ activeTab,
117
+ onChange,
118
+ fonts,
119
+ }: NavGeneralSettingsProps) {
120
+ const swatches = usePaletteSwatches();
121
+ const [typoViewport, setTypoViewport] = useState<NavViewport>("desktop");
122
+ const [spacingViewport, setSpacingViewport] = useState<NavViewport>("desktop");
123
+
124
+ const update = (partial: Partial<NavDesign>) =>
125
+ onChange({ ...design, ...partial });
126
+
127
+ /** Update a responsive-overridable field respecting the current viewport */
128
+ const updateResponsive = (
129
+ viewport: NavViewport,
130
+ field: keyof NavDesignResponsiveOverride,
131
+ value: NavDesignResponsiveOverride[keyof NavDesignResponsiveOverride],
132
+ ) => onChange(setResponsiveValue(design, viewport, field, value));
133
+
134
+ /** Reset a viewport override back to inherited */
135
+ const resetResponsive = (
136
+ viewport: NavViewport,
137
+ field: keyof NavDesignResponsiveOverride,
138
+ ) => onChange(setResponsiveValue(design, viewport, field, undefined));
139
+
140
+ /** Get the display value: viewport override if set, else desktop fallback */
141
+ const getDisplay = <K extends keyof NavDesignResponsiveOverride>(
142
+ viewport: NavViewport,
143
+ field: K,
144
+ fallback: NonNullable<NavDesignResponsiveOverride[K]>,
145
+ ): NonNullable<NavDesignResponsiveOverride[K]> => {
146
+ const vpVal = getResponsiveValue(design, viewport, field);
147
+ if (vpVal !== undefined && vpVal !== null) return vpVal as NonNullable<NavDesignResponsiveOverride[K]>;
148
+ // Inherit from desktop
149
+ if (viewport !== "desktop") {
150
+ const desktopVal = design[field as keyof NavDesign];
151
+ if (desktopVal !== undefined && desktopVal !== null) return desktopVal as NonNullable<NavDesignResponsiveOverride[K]>;
152
+ }
153
+ return fallback;
154
+ };
155
+
156
+ // ── Settings tab ── (2-col grid on wide screens, 1-col on narrow)
157
+ if (activeTab === "settings") {
158
+ return (
159
+ <div className="grid grid-cols-1 min-[640px]:grid-cols-2 gap-0">
160
+ {/* Left column group */}
161
+ <div>
162
+ {/* Position card */}
163
+ <CardSection title="Position" icon={<PositionIcon />} iconBg="#ede9fe">
164
+ <Field label="Mode">
165
+ <SegmentedControl
166
+ value={design.position || "fixed"}
167
+ onChange={(v) => update({ position: v as NavDesign["position"] })}
168
+ options={[
169
+ { value: "fixed", label: "Fixed" },
170
+ { value: "sticky", label: "Sticky" },
171
+ { value: "static", label: "Static" },
172
+ ]}
173
+ />
174
+ </Field>
175
+ <Field label="Hide">
176
+ <div className="flex items-center gap-2">
177
+ <Toggle
178
+ value={design.hide_on_scroll ?? true}
179
+ onChange={(v) => update({ hide_on_scroll: v })}
180
+ />
181
+ <span className="text-[10px] text-neutral-400">on scroll</span>
182
+ </div>
183
+ </Field>
184
+ </CardSection>
185
+
186
+ {/* Typography card — responsive per viewport */}
187
+ <CardSection title="Typography" icon={<TypographyIcon />} iconBg="#dbeafe">
188
+ <ViewportSwitcher value={typoViewport} onChange={setTypoViewport} />
189
+ <NavViewportBadge viewport={typoViewport} />
190
+
191
+ {/* Font family — desktop only (not in responsive overrides) */}
192
+ {typoViewport === "desktop" && (
193
+ <Field label="Font">
194
+ <SelectInput
195
+ value={design.font_family || ""}
196
+ onChange={(v) => update({ font_family: v })}
197
+ options={[
198
+ { value: "", label: `Default (${getSiteConfig().typography.defaultFont})` },
199
+ ...fonts.map((f) => ({ value: f, label: f })),
200
+ ]}
201
+ />
202
+ </Field>
203
+ )}
204
+
205
+ <ResponsiveField
206
+ label="Size"
207
+ viewport={typoViewport}
208
+ isOverridden={isFieldOverridden(design, typoViewport, "font_size") || isFieldOverridden(design, typoViewport, "font_weight")}
209
+ onReset={() => { resetResponsive(typoViewport, "font_size"); resetResponsive(typoViewport, "font_weight"); }}
210
+ >
211
+ <div className="grid grid-cols-2 gap-1.5">
212
+ <TextInput
213
+ value={getDisplay(typoViewport, "font_size", 14)}
214
+ onChange={(v) => updateResponsive(typoViewport, "font_size", Math.max(8, Math.min(48, parseInt(v) || 14)))}
215
+ type="number"
216
+ />
217
+ <SelectInput
218
+ value={getDisplay(typoViewport, "font_weight", "400")}
219
+ onChange={(v) => updateResponsive(typoViewport, "font_weight", v)}
220
+ options={[
221
+ { value: "100", label: "Thin" },
222
+ { value: "200", label: "Extra Light" },
223
+ { value: "300", label: "Light" },
224
+ { value: "400", label: "Normal" },
225
+ { value: "500", label: "Medium" },
226
+ { value: "600", label: "Semi Bold" },
227
+ { value: "700", label: "Bold" },
228
+ { value: "800", label: "Extra Bold" },
229
+ { value: "900", label: "Black" },
230
+ ]}
231
+ />
232
+ </div>
233
+ </ResponsiveField>
234
+
235
+ <ResponsiveField
236
+ label="Case"
237
+ viewport={typoViewport}
238
+ isOverridden={isFieldOverridden(design, typoViewport, "text_transform")}
239
+ onReset={() => resetResponsive(typoViewport, "text_transform")}
240
+ >
241
+ <SegmentedControl
242
+ value={getDisplay(typoViewport, "text_transform", "uppercase")}
243
+ onChange={(v) => updateResponsive(typoViewport, "text_transform", v as NavDesignResponsiveOverride["text_transform"])}
244
+ options={[
245
+ { value: "uppercase", label: "AA" },
246
+ { value: "capitalize", label: "Aa" },
247
+ { value: "lowercase", label: "aa" },
248
+ { value: "none", label: "—" },
249
+ ]}
250
+ />
251
+ </ResponsiveField>
252
+
253
+ <Divider />
254
+
255
+ <ResponsiveField
256
+ label="Align"
257
+ viewport={typoViewport}
258
+ isOverridden={isFieldOverridden(design, typoViewport, "text_align")}
259
+ onReset={() => resetResponsive(typoViewport, "text_align")}
260
+ >
261
+ <SegmentedControl
262
+ value={getDisplay(typoViewport, "text_align", "left")}
263
+ onChange={(v) => updateResponsive(typoViewport, "text_align", v as NavDesignResponsiveOverride["text_align"])}
264
+ options={[
265
+ { value: "left", label: AlignLeftIcon },
266
+ { value: "center", label: AlignCenterIcon },
267
+ { value: "right", label: AlignRightIcon },
268
+ ]}
269
+ />
270
+ </ResponsiveField>
271
+
272
+ <ResponsiveField
273
+ label="V. Align"
274
+ viewport={typoViewport}
275
+ isOverridden={isFieldOverridden(design, typoViewport, "vertical_align")}
276
+ onReset={() => resetResponsive(typoViewport, "vertical_align")}
277
+ >
278
+ <SegmentedControl
279
+ value={getDisplay(typoViewport, "vertical_align", "top")}
280
+ onChange={(v) => updateResponsive(typoViewport, "vertical_align", v as NavDesignResponsiveOverride["vertical_align"])}
281
+ options={[
282
+ { value: "top", label: VAlignTopIcon },
283
+ { value: "middle", label: VAlignMiddleIcon },
284
+ { value: "bottom", label: VAlignBottomIcon },
285
+ ]}
286
+ />
287
+ </ResponsiveField>
288
+
289
+ <Divider />
290
+
291
+ {/* Color — desktop only (global, not responsive) */}
292
+ {typoViewport === "desktop" && (
293
+ <Field label="Color">
294
+ <ColorSwatchPicker
295
+ value={design.color || ""}
296
+ onChange={(v) => update({ color: typeof v === "string" ? v : "" })}
297
+ swatches={swatches}
298
+ />
299
+ </Field>
300
+ )}
301
+ </CardSection>
302
+ </div>
303
+
304
+ {/* Right column group */}
305
+ <div>
306
+ {/* Spacing card — responsive per viewport */}
307
+ <CardSection title="Spacing" icon={<SpacingIcon />} iconBg="#fef3c7">
308
+ <ViewportSwitcher value={spacingViewport} onChange={setSpacingViewport} />
309
+ <NavViewportBadge viewport={spacingViewport} />
310
+
311
+ {/* Box model visual */}
312
+ <div className="relative border border-dashed border-neutral-200 rounded-lg p-4 mb-2">
313
+ <div className="absolute top-1 left-1/2 -translate-x-1/2 text-[8px] text-neutral-300 uppercase tracking-[1px]">
314
+ margin
315
+ </div>
316
+ <div className="border border-blue-100 rounded-md p-3 bg-blue-50/30 relative">
317
+ <div className="absolute top-0.5 left-1/2 -translate-x-1/2 text-[8px] text-blue-300 uppercase tracking-[1px]">
318
+ padding
319
+ </div>
320
+ <div className="bg-blue-200/30 rounded h-3 mt-1" />
321
+ <div className="grid grid-cols-2 gap-1.5 mt-2">
322
+ <div className="text-center">
323
+ <div className="text-[8px] text-blue-300 mb-0.5">H</div>
324
+ <ResponsiveField
325
+ label=""
326
+ viewport={spacingViewport}
327
+ isOverridden={isFieldOverridden(design, spacingViewport, "padding_h")}
328
+ onReset={() => resetResponsive(spacingViewport, "padding_h")}
329
+ >
330
+ <TextInput
331
+ value={getDisplay(spacingViewport, "padding_h", 24)}
332
+ onChange={(v) => updateResponsive(spacingViewport, "padding_h", Math.max(0, Math.min(120, parseInt(v) || 0)))}
333
+ type="number"
334
+ />
335
+ </ResponsiveField>
336
+ </div>
337
+ <div className="text-center">
338
+ <div className="text-[8px] text-blue-300 mb-0.5">V</div>
339
+ <ResponsiveField
340
+ label=""
341
+ viewport={spacingViewport}
342
+ isOverridden={isFieldOverridden(design, spacingViewport, "padding_v")}
343
+ onReset={() => resetResponsive(spacingViewport, "padding_v")}
344
+ >
345
+ <TextInput
346
+ value={getDisplay(spacingViewport, "padding_v", 27)}
347
+ onChange={(v) => updateResponsive(spacingViewport, "padding_v", Math.max(0, Math.min(80, parseInt(v) || 0)))}
348
+ type="number"
349
+ />
350
+ </ResponsiveField>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ <div className="grid grid-cols-2 gap-1.5 mt-2">
355
+ <div className="text-center">
356
+ <div className="text-[8px] text-neutral-300 mb-0.5">H</div>
357
+ <ResponsiveField
358
+ label=""
359
+ viewport={spacingViewport}
360
+ isOverridden={isFieldOverridden(design, spacingViewport, "margin_h")}
361
+ onReset={() => resetResponsive(spacingViewport, "margin_h")}
362
+ >
363
+ <TextInput
364
+ value={getDisplay(spacingViewport, "margin_h", 0)}
365
+ onChange={(v) => updateResponsive(spacingViewport, "margin_h", Math.max(0, Math.min(120, parseInt(v) || 0)))}
366
+ type="number"
367
+ />
368
+ </ResponsiveField>
369
+ </div>
370
+ <div className="text-center">
371
+ <div className="text-[8px] text-neutral-300 mb-0.5">V</div>
372
+ <ResponsiveField
373
+ label=""
374
+ viewport={spacingViewport}
375
+ isOverridden={isFieldOverridden(design, spacingViewport, "margin_v")}
376
+ onReset={() => resetResponsive(spacingViewport, "margin_v")}
377
+ >
378
+ <TextInput
379
+ value={getDisplay(spacingViewport, "margin_v", 0)}
380
+ onChange={(v) => updateResponsive(spacingViewport, "margin_v", Math.max(0, Math.min(80, parseInt(v) || 0)))}
381
+ type="number"
382
+ />
383
+ </ResponsiveField>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ <Divider />
388
+ <ResponsiveField
389
+ label="Gap"
390
+ viewport={spacingViewport}
391
+ isOverridden={isFieldOverridden(design, spacingViewport, "items_gap")}
392
+ onReset={() => resetResponsive(spacingViewport, "items_gap")}
393
+ >
394
+ <RangeSlider
395
+ value={getDisplay(spacingViewport, "items_gap", 32)}
396
+ onChange={(v) => updateResponsive(spacingViewport, "items_gap", v)}
397
+ min={4}
398
+ max={80}
399
+ suffix="px"
400
+ />
401
+ </ResponsiveField>
402
+ </CardSection>
403
+ </div>
404
+
405
+ <div className="h-2" />
406
+ </div>
407
+ );
408
+ }
409
+
410
+ // ── Animation tab ──
411
+ if (activeTab === "animation") {
412
+ return (
413
+ <>
414
+ <CardSection title="Entrance" icon={<AnimationIcon />} iconBg="#d1fae5">
415
+ <Field label="Effect">
416
+ <SelectInput
417
+ value={design.entrance_animation || ""}
418
+ onChange={(v) => update({ entrance_animation: v as NavDesign["entrance_animation"] })}
419
+ options={[
420
+ { value: "", label: "None" },
421
+ { value: "fade-in", label: "Fade In" },
422
+ { value: "slide-down", label: "Slide Down" },
423
+ { value: "blur-in", label: "Blur In" },
424
+ ]}
425
+ />
426
+ </Field>
427
+ {design.entrance_animation && (
428
+ <>
429
+ <Field label="Duration">
430
+ <RangeSlider
431
+ value={design.entrance_duration ?? 600}
432
+ onChange={(v) => update({ entrance_duration: v })}
433
+ min={200}
434
+ max={5000}
435
+ suffix="ms"
436
+ />
437
+ </Field>
438
+ <Field label="Delay">
439
+ <RangeSlider
440
+ value={design.entrance_delay ?? 0}
441
+ onChange={(v) => update({ entrance_delay: v })}
442
+ min={0}
443
+ max={5000}
444
+ suffix="ms"
445
+ />
446
+ </Field>
447
+ <Divider />
448
+ <Field label="Stagger">
449
+ <div className="flex items-center gap-2">
450
+ <Toggle
451
+ value={design.entrance_stagger ?? false}
452
+ onChange={(v) => update({ entrance_stagger: v })}
453
+ />
454
+ <span className="text-[10px] text-neutral-400">per item</span>
455
+ </div>
456
+ </Field>
457
+ {design.entrance_stagger && (
458
+ <Field label="Interval">
459
+ <RangeSlider
460
+ value={design.entrance_stagger_delay ?? 80}
461
+ onChange={(v) => update({ entrance_stagger_delay: v })}
462
+ min={20}
463
+ max={300}
464
+ suffix="ms"
465
+ />
466
+ </Field>
467
+ )}
468
+ </>
469
+ )}
470
+ </CardSection>
471
+
472
+ {!design.entrance_animation && (
473
+ <div className="mx-4 mt-2 px-3 py-4 rounded-lg bg-neutral-50 text-center">
474
+ <p className="text-[11px] text-neutral-400">
475
+ Select an entrance effect to configure animation timing.
476
+ </p>
477
+ </div>
478
+ )}
479
+
480
+ <div className="h-2" />
481
+ </>
482
+ );
483
+ }
484
+
485
+ // ── Layout tab ──
486
+ return (
487
+ <>
488
+ <CardSection title="Background" icon={<BackgroundIcon />} iconBg="#e0e7ff">
489
+ <Field label="Color">
490
+ <ColorSwatchPicker
491
+ value={design.background_color || ""}
492
+ onChange={(v) => update({ background_color: typeof v === "string" ? v : "" })}
493
+ swatches={swatches}
494
+ />
495
+ </Field>
496
+ {design.background_color && (
497
+ <Field label="Opacity">
498
+ <RangeSlider
499
+ value={design.background_opacity ?? 100}
500
+ onChange={(v) => update({ background_opacity: v })}
501
+ min={0}
502
+ max={100}
503
+ suffix="%"
504
+ />
505
+ </Field>
506
+ )}
507
+ <Field label="Blur">
508
+ <div className="flex items-center gap-2">
509
+ <Toggle
510
+ value={design.backdrop_blur ?? false}
511
+ onChange={(v) => update({ backdrop_blur: v })}
512
+ />
513
+ <span className="text-[10px] text-neutral-400">backdrop blur</span>
514
+ </div>
515
+ </Field>
516
+ </CardSection>
517
+
518
+ <div className="h-2" />
519
+ </>
520
+ );
521
+ }