@morphika/andami 0.1.9 → 0.1.10

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 (31) hide show
  1. package/components/admin/nav-builder/NavBuilder.tsx +90 -14
  2. package/components/admin/nav-builder/NavGeneralSettings.tsx +521 -271
  3. package/components/admin/nav-builder/NavItemSettings.tsx +331 -312
  4. package/components/admin/nav-builder/NavMobileSettings.tsx +159 -140
  5. package/components/admin/nav-builder/NavSettingsFields.tsx +287 -21
  6. package/components/admin/nav-builder/NavSettingsPanel.tsx +137 -127
  7. package/components/blocks/TextBlockRenderer.tsx +1 -1
  8. package/components/builder/editors/ButtonBlockEditor.tsx +8 -3
  9. package/components/builder/editors/CoverBlockEditor.tsx +14 -6
  10. package/components/builder/editors/ImageBlockEditor.tsx +8 -3
  11. package/components/builder/editors/ImageGridBlockEditor.tsx +8 -3
  12. package/components/builder/editors/ProjectGridEditor.tsx +7 -46
  13. package/components/builder/editors/SpacerBlockEditor.tsx +4 -1
  14. package/components/builder/editors/StaggerSettings.tsx +2 -1
  15. package/components/builder/editors/TextBlockEditor.tsx +8 -3
  16. package/components/builder/editors/VideoBlockEditor.tsx +10 -4
  17. package/components/builder/editors/section-icons.tsx +492 -0
  18. package/components/builder/editors/shared.tsx +23 -4
  19. package/components/builder/live-preview/LiveTextEditor.tsx +1 -1
  20. package/components/builder/settings-panel/BlockLayoutTab.tsx +13 -58
  21. package/components/builder/settings-panel/ColumnV2Settings.tsx +4 -1
  22. package/components/builder/settings-panel/LayoutTab.tsx +11 -47
  23. package/components/builder/settings-panel/PageSettings.tsx +10 -4
  24. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +6 -2
  25. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +8 -3
  26. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +11 -47
  27. package/components/builder/settings-panel/SectionV2Settings.tsx +6 -27
  28. package/components/ui/Navbar.tsx +151 -30
  29. package/lib/sanity/types.ts +22 -0
  30. package/package.json +5 -2
  31. package/styles/base.css +7 -3
@@ -16,6 +16,9 @@
16
16
 
17
17
  import { useBuilderStore } from "../../../lib/builder/store";
18
18
  import type { PageSectionV2, SectionColumn } from "../../../lib/sanity/types";
19
+ import {
20
+ ColumnSizeIcon,
21
+ } from "../editors/section-icons";
19
22
  import {
20
23
  SettingsField,
21
24
  SettingsSection,
@@ -90,7 +93,7 @@ export default function ColumnV2Settings({
90
93
  </div>
91
94
  )}
92
95
 
93
- <SettingsSection title="Column Size" defaultOpen>
96
+ <SettingsSection title="Column Size" defaultOpen icon={<ColumnSizeIcon />}>
94
97
  <SettingsField label={
95
98
  <span>
96
99
  Span
@@ -29,49 +29,13 @@ import {
29
29
  } from "./responsive-helpers";
30
30
  import { TRBLInputs } from "./TRBLInputs";
31
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
- }
32
+ // ── Section title icons (centralized colored icons — Session 163) ──
33
+ import {
34
+ SpacingIcon,
35
+ OffsetIcon,
36
+ BackgroundIcon,
37
+ BorderIcon,
38
+ } from "../editors/section-icons";
75
39
 
76
40
  /**
77
41
  * BUG-007 fix: LayoutTab now handles PageSection styling (spacing, background, border).
@@ -141,7 +105,7 @@ export function LayoutTab({ section, sectionKey }: { section: PageSection; secti
141
105
  )}
142
106
 
143
107
  {/* Spacing (Padding) */}
144
- <SettingsSection title="Spacing" defaultOpen icon={<SpacingSectionIcon />}>
108
+ <SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
145
109
  <TRBLInputs
146
110
  top={effectiveSpacingTop}
147
111
  right={effectiveSpacingRight}
@@ -192,7 +156,7 @@ export function LayoutTab({ section, sectionKey }: { section: PageSection; secti
192
156
  </SettingsSection>
193
157
 
194
158
  {/* Offset (Margin) */}
195
- <SettingsSection title="Offset" icon={<OffsetSectionIcon />}>
159
+ <SettingsSection title="Offset" icon={<OffsetIcon />}>
196
160
  <TRBLInputs
197
161
  top={getRowSettingValue<string>(section, activeViewport, "offset_top", "0")}
198
162
  right={getRowSettingValue<string>(section, activeViewport, "offset_right", "0")}
@@ -205,7 +169,7 @@ export function LayoutTab({ section, sectionKey }: { section: PageSection; secti
205
169
  </SettingsSection>
206
170
 
207
171
  {/* Background */}
208
- <SettingsSection title="Background" defaultOpen icon={<BackgroundSectionIcon />}>
172
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
209
173
  <SettingsField label="Color">
210
174
  <ColorSwatchPicker
211
175
  value={parseColorField(getRowSettingValue<string>(section, activeViewport, "background_color", ""))}
@@ -305,7 +269,7 @@ export function LayoutTab({ section, sectionKey }: { section: PageSection; secti
305
269
  </SettingsSection>
306
270
 
307
271
  {/* Border */}
308
- <SettingsSection title="Border" icon={<BorderSectionIcon />}>
272
+ <SettingsSection title="Border" icon={<BorderIcon />}>
309
273
  <SettingsField label="Color">
310
274
  <ColorSwatchPicker
311
275
  value={parseColorField(getRowSettingValue<string>(section, activeViewport, "border_color", ""))}
@@ -11,6 +11,12 @@
11
11
 
12
12
  import { useState } from "react";
13
13
  import { useBuilderStore } from "../../../lib/builder/store";
14
+ import {
15
+ GeneralIcon,
16
+ AppearanceIcon,
17
+ NavigationIcon,
18
+ SEOIcon,
19
+ } from "../editors/section-icons";
14
20
  import {
15
21
  SettingsField,
16
22
  SettingsSection,
@@ -39,7 +45,7 @@ export default function PageSettings() {
39
45
 
40
46
  return (
41
47
  <>
42
- <SettingsSection title="General" defaultOpen>
48
+ <SettingsSection title="General" defaultOpen icon={<GeneralIcon />}>
43
49
  <SettingsField label="Title">
44
50
  <input
45
51
  type="text"
@@ -73,7 +79,7 @@ export default function PageSettings() {
73
79
  </SettingsField>
74
80
  </SettingsSection>
75
81
 
76
- <SettingsSection title="Appearance" defaultOpen>
82
+ <SettingsSection title="Appearance" defaultOpen icon={<AppearanceIcon />}>
77
83
  <SettingsField label="Background">
78
84
  <ColorSwatchPicker
79
85
  value={parseColorField(store.pageSettings.background_color || "")}
@@ -91,7 +97,7 @@ export default function PageSettings() {
91
97
  </SettingsField>
92
98
  </SettingsSection>
93
99
 
94
- <SettingsSection title="Navigation">
100
+ <SettingsSection title="Navigation" icon={<NavigationIcon />}>
95
101
  <SettingsField label="Nav Color">
96
102
  <ColorSwatchPicker
97
103
  value={store.pageSettings.nav_color || ""}
@@ -164,7 +170,7 @@ export function PageSeoSettings() {
164
170
 
165
171
  return (
166
172
  <>
167
- <SettingsSection title="SEO" defaultOpen>
173
+ <SettingsSection title="SEO" defaultOpen icon={<SEOIcon />}>
168
174
  <SettingsField label="SEO Title">
169
175
  <input
170
176
  type="text"
@@ -14,6 +14,10 @@
14
14
 
15
15
  import { useBuilderStore } from "../../../lib/builder/store";
16
16
  import type { ParallaxGroup } from "../../../lib/sanity/types";
17
+ import {
18
+ TransitionIcon,
19
+ InfoIcon,
20
+ } from "../editors/section-icons";
17
21
  import {
18
22
  SettingsSection,
19
23
  } from "../editors/shared";
@@ -56,7 +60,7 @@ export default function ParallaxGroupSettings({
56
60
 
57
61
  return (
58
62
  <>
59
- <SettingsSection title="Transition Effect" defaultOpen>
63
+ <SettingsSection title="Transition Effect" defaultOpen icon={<TransitionIcon />}>
60
64
  <div className="space-y-1.5">
61
65
  {TRANSITION_EFFECTS.map((effect) => {
62
66
  const isActive = activeEffect === effect.value;
@@ -103,7 +107,7 @@ export default function ParallaxGroupSettings({
103
107
  </SettingsSection>
104
108
 
105
109
  {/* Group info */}
106
- <SettingsSection title="Info">
110
+ <SettingsSection title="Info" icon={<InfoIcon />}>
107
111
  <div className="text-[11px] text-neutral-500 space-y-1">
108
112
  <p>
109
113
  {group.slides.length} slide{group.slides.length !== 1 ? "s" : ""}
@@ -19,6 +19,11 @@
19
19
 
20
20
  import { useBuilderStore } from "../../../lib/builder/store";
21
21
  import type { ParallaxSlideV2, ParallaxGroup, PageSectionV2 } from "../../../lib/sanity/types";
22
+ import {
23
+ BackgroundIcon,
24
+ NavbarColorIcon,
25
+ OverlayIcon,
26
+ } from "../editors/section-icons";
22
27
  import {
23
28
  SettingsField,
24
29
  SettingsSection,
@@ -70,7 +75,7 @@ export default function ParallaxSlideSettings({
70
75
  return (
71
76
  <>
72
77
  {/* Background Type */}
73
- <SettingsSection title="Background" defaultOpen>
78
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
74
79
  {/* Segmented control: Image / Video */}
75
80
  <SettingsField label="Type">
76
81
  <div className="flex rounded-lg bg-[#f0f0f0] p-[3px]">
@@ -123,7 +128,7 @@ export default function ParallaxSlideSettings({
123
128
  </SettingsSection>
124
129
 
125
130
  {/* Navbar Color Override */}
126
- <SettingsSection title="Navbar Color" defaultOpen={false}>
131
+ <SettingsSection title="Navbar Color" defaultOpen={false} icon={<NavbarColorIcon />}>
127
132
  <SettingsField label="Color">
128
133
  <div className="flex items-center gap-2">
129
134
  <ColorSwatchPicker
@@ -147,7 +152,7 @@ export default function ParallaxSlideSettings({
147
152
  </SettingsSection>
148
153
 
149
154
  {/* Overlay */}
150
- <SettingsSection title="Overlay" defaultOpen>
155
+ <SettingsSection title="Overlay" defaultOpen icon={<OverlayIcon />}>
151
156
  <SettingsField label="Color">
152
157
  <ColorSwatchPicker
153
158
  value={overlayColor}
@@ -25,49 +25,13 @@ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
25
25
  import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
26
26
  import { TRBLInputs } from "./TRBLInputs";
27
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
- }
28
+ // ── Section title icons (centralized colored icons — Session 163) ──
29
+ import {
30
+ SpacingIcon,
31
+ OffsetIcon,
32
+ BackgroundIcon,
33
+ BorderIcon,
34
+ } from "../editors/section-icons";
71
35
 
72
36
  // ── Override indicator badge (matching BlockLayoutTab pattern) ──
73
37
 
@@ -179,7 +143,7 @@ export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
179
143
  )}
180
144
 
181
145
  {/* Spacing (Padding) */}
182
- <SettingsSection title="Spacing" defaultOpen icon={<SpacingSectionIcon />}>
146
+ <SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
183
147
  <TRBLInputs
184
148
  top={getSettingValue<string>("spacing_top", "0")}
185
149
  right={getSettingValue<string>("spacing_right", "0")}
@@ -197,7 +161,7 @@ export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
197
161
  </SettingsSection>
198
162
 
199
163
  {/* Offset (Margin) */}
200
- <SettingsSection title="Offset" icon={<OffsetSectionIcon />}>
164
+ <SettingsSection title="Offset" icon={<OffsetIcon />}>
201
165
  <TRBLInputs
202
166
  top={getSettingValue<string>("offset_top", "0")}
203
167
  right={getSettingValue<string>("offset_right", "0")}
@@ -215,7 +179,7 @@ export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
215
179
  </SettingsSection>
216
180
 
217
181
  {/* Background */}
218
- <SettingsSection title="Background" defaultOpen icon={<BackgroundSectionIcon />}>
182
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
219
183
  <SettingsField label="Color">
220
184
  <ColorSwatchPicker
221
185
  value={parseColorField(getSettingValue<string>("background_color", ""))}
@@ -309,7 +273,7 @@ export function SectionV2LayoutTab({ section }: { section: PageSectionV2 }) {
309
273
  </SettingsSection>
310
274
 
311
275
  {/* Border */}
312
- <SettingsSection title="Border" icon={<BorderSectionIcon />}>
276
+ <SettingsSection title="Border" icon={<BorderIcon />}>
313
277
  <SettingsField label="Color">
314
278
  <ColorSwatchPicker
315
279
  value={parseColorField(getSettingValue<string>("border_color", ""))}
@@ -50,32 +50,11 @@ const PRESETS: PresetOption[] = [
50
50
 
51
51
  const CUSTOM_PRESET: PresetOption = { id: "custom", label: "Custom", cols: [], readonly: true };
52
52
 
53
- // ============================================
54
- // Section title icons (small, inline)
55
- // ============================================
56
-
57
- function LayoutPresetIcon() {
58
- return (
59
- <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
60
- <rect x="1.5" y="1.5" width="11" height="11" rx="1.5" stroke="currentColor" strokeWidth="0.8" fill="none" opacity="0.4" />
61
- <line x1="6" y1="1.5" x2="6" y2="12.5" stroke="currentColor" strokeWidth="0.8" opacity="0.6" />
62
- <rect x="1.5" y="1.5" width="4.5" height="11" rx="0" fill="currentColor" opacity="0.12" />
63
- </svg>
64
- );
65
- }
66
-
67
- function GridGapIcon() {
68
- return (
69
- <svg width={14} height={14} viewBox="0 0 14 14" fill="none">
70
- <rect x="1" y="1" width="5" height="5" rx="1" fill="currentColor" opacity="0.25" />
71
- <rect x="8" y="1" width="5" height="5" rx="1" fill="currentColor" opacity="0.25" />
72
- <rect x="1" y="8" width="5" height="5" rx="1" fill="currentColor" opacity="0.25" />
73
- <rect x="8" y="8" width="5" height="5" rx="1" fill="currentColor" opacity="0.25" />
74
- <line x1="7" y1="1" x2="7" y2="13" stroke="currentColor" strokeWidth="0.8" strokeDasharray="1.5 1" opacity="0.5" />
75
- <line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" strokeWidth="0.8" strokeDasharray="1.5 1" opacity="0.5" />
76
- </svg>
77
- );
78
- }
53
+ // ── Section title icons (centralized colored icons — Session 163) ──
54
+ import {
55
+ LayoutPresetIcon,
56
+ GridGapsIcon,
57
+ } from "../editors/section-icons";
79
58
 
80
59
  // ============================================
81
60
  // Preset Grid Component
@@ -278,7 +257,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
278
257
  )}
279
258
 
280
259
  {/* Gaps */}
281
- <SettingsSection title="Grid Gaps" defaultOpen icon={<GridGapIcon />}>
260
+ <SettingsSection title="Grid Gaps" defaultOpen icon={<GridGapsIcon />}>
282
261
  <SettingsField label={
283
262
  <span>
284
263
  Col Gap
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useCallback, useRef } from "react";
3
+ import { useState, useEffect, useCallback, useRef, useId, useMemo } from "react";
4
4
  import { usePathname } from "next/navigation";
5
5
  import Link from "next/link";
6
- import type { NavItem, NavDesign, NavEntrancePreset, MobileNavDesign } from "../../lib/sanity/types";
6
+ import type { NavItem, NavDesign, NavEntrancePreset, NavDesignResponsiveOverride, MobileNavDesign } from "../../lib/sanity/types";
7
7
  import { useNavColor } from "../../lib/contexts/NavColorContext";
8
8
  import { useNavAnimation } from "../../lib/contexts/NavAnimationContext";
9
9
  import { usePageExit } from "../../lib/contexts/PageExitContext";
@@ -23,6 +23,79 @@ const colorMap: Record<string, string> = {
23
23
  white: "text-brand-text",
24
24
  };
25
25
 
26
+ // ============================================
27
+ // Responsive CSS custom properties generator
28
+ // ============================================
29
+
30
+ /** Breakpoints matching the builder's responsive system */
31
+ const NAV_BREAKPOINTS = { tablet: 1024, phone: 640 } as const;
32
+
33
+ /** Generates a <style> block with CSS custom properties for responsive nav overrides.
34
+ * Desktop values are set on the scoping selector; tablet/phone overrides via media queries.
35
+ * Returns empty string if no responsive overrides exist. */
36
+ function buildResponsiveNavCSS(design: NavDesign | undefined, scopeSelector: string): string {
37
+ if (!design) return "";
38
+
39
+ // Desktop (base) custom properties
40
+ const vars: Record<string, string> = {
41
+ "--nav-font-size": `${design.font_size ?? 14}px`,
42
+ "--nav-font-weight": design.font_weight || "400",
43
+ "--nav-text-transform": design.text_transform || "uppercase",
44
+ "--nav-text-align": design.text_align || "left",
45
+ "--nav-vertical-align": (() => {
46
+ const v = design.vertical_align || "top";
47
+ return v === "bottom" ? "end" : v === "middle" ? "center" : "start";
48
+ })(),
49
+ "--nav-items-justify": (() => {
50
+ const a = design.text_align || "left";
51
+ return a === "center" ? "center" : a === "right" ? "flex-end" : "flex-start";
52
+ })(),
53
+ "--nav-padding-h": `${design.padding_h ?? 24}px`,
54
+ "--nav-padding-v": `${design.padding_v ?? 27}px`,
55
+ "--nav-margin-h": `${design.margin_h ?? 0}px`,
56
+ "--nav-margin-v": `${design.margin_v ?? 0}px`,
57
+ "--nav-items-gap": `${design.items_gap ?? 32}px`,
58
+ };
59
+
60
+ const responsive = design.responsive;
61
+ if (!responsive) {
62
+ // No responsive overrides — still emit base vars for consistency
63
+ const baseLines = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";");
64
+ return `${scopeSelector}{${baseLines}}`;
65
+ }
66
+
67
+ // Build media query blocks for tablet and phone
68
+ const mqBlocks: string[] = [];
69
+ for (const [viewport, bp] of [["tablet", NAV_BREAKPOINTS.tablet], ["phone", NAV_BREAKPOINTS.phone]] as const) {
70
+ const overrides = responsive[viewport] as NavDesignResponsiveOverride | undefined;
71
+ if (!overrides) continue;
72
+ const ovLines: string[] = [];
73
+ if (overrides.font_size != null) ovLines.push(`--nav-font-size:${overrides.font_size}px`);
74
+ if (overrides.font_weight != null) ovLines.push(`--nav-font-weight:${overrides.font_weight}`);
75
+ if (overrides.text_transform != null) ovLines.push(`--nav-text-transform:${overrides.text_transform}`);
76
+ if (overrides.text_align != null) {
77
+ ovLines.push(`--nav-text-align:${overrides.text_align}`);
78
+ const j = overrides.text_align === "center" ? "center" : overrides.text_align === "right" ? "flex-end" : "flex-start";
79
+ ovLines.push(`--nav-items-justify:${j}`);
80
+ }
81
+ if (overrides.vertical_align != null) {
82
+ const v = overrides.vertical_align === "bottom" ? "end" : overrides.vertical_align === "middle" ? "center" : "start";
83
+ ovLines.push(`--nav-vertical-align:${v}`);
84
+ }
85
+ if (overrides.padding_h != null) ovLines.push(`--nav-padding-h:${overrides.padding_h}px`);
86
+ if (overrides.padding_v != null) ovLines.push(`--nav-padding-v:${overrides.padding_v}px`);
87
+ if (overrides.margin_h != null) ovLines.push(`--nav-margin-h:${overrides.margin_h}px`);
88
+ if (overrides.margin_v != null) ovLines.push(`--nav-margin-v:${overrides.margin_v}px`);
89
+ if (overrides.items_gap != null) ovLines.push(`--nav-items-gap:${overrides.items_gap}px`);
90
+ if (ovLines.length > 0) {
91
+ mqBlocks.push(`@media(max-width:${bp}px){${scopeSelector}{${ovLines.join(";")}}}`);
92
+ }
93
+ }
94
+
95
+ const baseLines = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(";");
96
+ return `${scopeSelector}{${baseLines}}${mqBlocks.join("")}`;
97
+ }
98
+
26
99
  // ============================================
27
100
  // NavLink — shared link component (eliminates duplication)
28
101
  // ============================================
@@ -181,6 +254,18 @@ export default function Navbar({
181
254
  // Map vertical_align to CSS align-items value for the grid container
182
255
  const gridAlignItems = verticalAlign === "bottom" ? "end" : verticalAlign === "middle" ? "center" : "start";
183
256
 
257
+ // ── Responsive CSS custom properties (Session 164) ──
258
+ // Uses a stable ID scoped to the nav element. CSS vars + media queries
259
+ // ensure zero-flash SSR: the correct values apply before hydration.
260
+ const hasResponsive = !!(design?.responsive?.tablet || design?.responsive?.phone);
261
+ const reactId = useId();
262
+ const navScopeId = `nav-${reactId.replace(/:/g, "")}`;
263
+ const responsiveCSS = useMemo(
264
+ () => hasResponsive ? buildResponsiveNavCSS(design, `[data-nav-scope="${navScopeId}"]`) : "",
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
266
+ [hasResponsive, design?.responsive, navScopeId, fontSize, fontWeight, textTransformVal, textAlignVal, paddingH, paddingV, marginH, marginV, itemsGap, verticalAlign],
267
+ );
268
+
184
269
  // ── Mobile menu resolved values (Session 158) ──
185
270
  // Mobile styles are independent from page-level NavColorContext overrides.
186
271
  // They fall back to desktop design values when not explicitly set.
@@ -374,13 +459,23 @@ export default function Navbar({
374
459
  // When using hex color, we must set `color: inherit` on links because browser
375
460
  // user-agent stylesheet sets `a { color: ... }` which overrides CSS inheritance.
376
461
  const linkClassName = `tracking-normal ${textColorClass} transition-colors duration-200 hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`;
377
- const linkStyle: React.CSSProperties = {
378
- fontSize: `${fontSize}px`,
379
- fontWeight: fontWeight as React.CSSProperties["fontWeight"],
380
- textTransform: textTransformVal as React.CSSProperties["textTransform"],
381
- fontFamily: fontFamily || "var(--font-sans, Inter, system-ui, sans-serif)",
382
- ...(isHexColor ? { color: "inherit" } : {}),
383
- };
462
+ // When responsive overrides exist, link styles reference CSS custom properties
463
+ // so they respond to media queries without JS. Otherwise use direct values.
464
+ const linkStyle: React.CSSProperties = hasResponsive
465
+ ? {
466
+ fontSize: "var(--nav-font-size)",
467
+ fontWeight: "var(--nav-font-weight)" as React.CSSProperties["fontWeight"],
468
+ textTransform: "var(--nav-text-transform)" as React.CSSProperties["textTransform"],
469
+ fontFamily: fontFamily || "var(--font-sans, Inter, system-ui, sans-serif)",
470
+ ...(isHexColor ? { color: "inherit" } : {}),
471
+ }
472
+ : {
473
+ fontSize: `${fontSize}px`,
474
+ fontWeight: fontWeight as React.CSSProperties["fontWeight"],
475
+ textTransform: textTransformVal as React.CSSProperties["textTransform"],
476
+ fontFamily: fontFamily || "var(--font-sans, Inter, system-ui, sans-serif)",
477
+ ...(isHexColor ? { color: "inherit" } : {}),
478
+ };
384
479
 
385
480
  return (
386
481
  <>
@@ -388,6 +483,7 @@ export default function Navbar({
388
483
  <nav
389
484
  role="navigation"
390
485
  aria-label="Main navigation"
486
+ data-nav-scope={navScopeId}
391
487
  className={`${positionClass} top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out ${
392
488
  shouldHide ? "-translate-y-full" : "translate-y-0"
393
489
  }`}
@@ -397,14 +493,21 @@ export default function Navbar({
397
493
  style={{
398
494
  ...navBgStyle,
399
495
  ...textColorStyle,
400
- ...(marginH > 0 || marginV > 0
496
+ ...(hasResponsive
401
497
  ? {
402
- left: `${marginH}px`,
403
- right: `${marginH}px`,
404
- top: `${marginV}px`,
405
- borderRadius: "8px",
498
+ left: "var(--nav-margin-h)",
499
+ right: "var(--nav-margin-h)",
500
+ top: "var(--nav-margin-v)",
501
+ ...(marginH > 0 || marginV > 0 ? { borderRadius: "8px" } : {}),
406
502
  }
407
- : {}),
503
+ : marginH > 0 || marginV > 0
504
+ ? {
505
+ left: `${marginH}px`,
506
+ right: `${marginH}px`,
507
+ top: `${marginV}px`,
508
+ borderRadius: "8px",
509
+ }
510
+ : {}),
408
511
  // CSS custom properties for animation duration
409
512
  ...(entrancePreset && !entranceStagger ? {
410
513
  "--nav-entrance-duration": `${entranceDuration}ms`,
@@ -412,21 +515,39 @@ export default function Navbar({
412
515
  } as React.CSSProperties : {}),
413
516
  }}
414
517
  >
518
+ {/* Responsive nav CSS custom properties + media queries */}
519
+ {responsiveCSS && (
520
+ <style dangerouslySetInnerHTML={{ __html: responsiveCSS }} />
521
+ )}
415
522
  {/* Desktop: 12-column grid constrained to --grid-width */}
416
523
  <div
417
524
  className="hidden lg:grid"
418
- style={{
419
- gridTemplateColumns: "repeat(12, 1fr)",
420
- maxWidth: "var(--grid-width, 1445px)",
421
- marginLeft: "auto",
422
- marginRight: "auto",
423
- paddingLeft: `${paddingH}px`,
424
- paddingRight: `${paddingH}px`,
425
- paddingTop: `${paddingV}px`,
426
- paddingBottom: `${paddingV}px`,
427
- alignItems: gridAlignItems,
428
- columnGap: `${itemsGap}px`,
429
- }}
525
+ style={hasResponsive
526
+ ? {
527
+ gridTemplateColumns: "repeat(12, 1fr)",
528
+ maxWidth: "var(--grid-width, 1445px)",
529
+ marginLeft: "auto",
530
+ marginRight: "auto",
531
+ paddingLeft: "var(--nav-padding-h)",
532
+ paddingRight: "var(--nav-padding-h)",
533
+ paddingTop: "var(--nav-padding-v)",
534
+ paddingBottom: "var(--nav-padding-v)",
535
+ alignItems: "var(--nav-vertical-align)" as React.CSSProperties["alignItems"],
536
+ columnGap: "var(--nav-items-gap)",
537
+ }
538
+ : {
539
+ gridTemplateColumns: "repeat(12, 1fr)",
540
+ maxWidth: "var(--grid-width, 1445px)",
541
+ marginLeft: "auto",
542
+ marginRight: "auto",
543
+ paddingLeft: `${paddingH}px`,
544
+ paddingRight: `${paddingH}px`,
545
+ paddingTop: `${paddingV}px`,
546
+ paddingBottom: `${paddingV}px`,
547
+ alignItems: gridAlignItems,
548
+ columnGap: `${itemsGap}px`,
549
+ }
550
+ }
430
551
  >
431
552
  {/* Logo */}
432
553
  <div
@@ -480,7 +601,7 @@ export default function Navbar({
480
601
  gridColumn: `${item.grid_column} / span ${item.column_span || 1}`,
481
602
  gridRow: 1,
482
603
  display: "flex",
483
- justifyContent: itemsJustify,
604
+ justifyContent: hasResponsive ? "var(--nav-items-justify)" as React.CSSProperties["justifyContent"] : itemsJustify,
484
605
  alignItems: "center",
485
606
  minWidth: 0,
486
607
  overflow: "hidden",
@@ -518,9 +639,9 @@ export default function Navbar({
518
639
  style={{
519
640
  gridColumn: `${logoCols + 1} / -1`,
520
641
  display: "flex",
521
- justifyContent: itemsJustify,
642
+ justifyContent: hasResponsive ? "var(--nav-items-justify)" as React.CSSProperties["justifyContent"] : itemsJustify,
522
643
  alignItems: "center",
523
- gap: `${design?.items_gap ?? 32}px`,
644
+ gap: hasResponsive ? "var(--nav-items-gap)" : `${design?.items_gap ?? 32}px`,
524
645
  }}
525
646
  >
526
647
  {unplacedItems.map((item) => (
@@ -704,6 +704,28 @@ export interface NavDesign {
704
704
  entrance_delay?: number; // ms, default 0
705
705
  entrance_stagger?: boolean; // stagger items, default false
706
706
  entrance_stagger_delay?: number; // ms between items, default 80
707
+
708
+ /** Per-viewport overrides for responsive nav settings.
709
+ * Only typography + spacing fields are overridable.
710
+ * Missing fields = inherit from desktop. */
711
+ responsive?: {
712
+ tablet?: NavDesignResponsiveOverride;
713
+ phone?: NavDesignResponsiveOverride;
714
+ };
715
+ }
716
+
717
+ /** Subset of NavDesign fields that can be overridden per viewport */
718
+ export interface NavDesignResponsiveOverride {
719
+ font_size?: number;
720
+ font_weight?: string;
721
+ text_align?: "left" | "center" | "right";
722
+ vertical_align?: "top" | "middle" | "bottom";
723
+ text_transform?: "none" | "uppercase" | "lowercase" | "capitalize";
724
+ padding_h?: number;
725
+ padding_v?: number;
726
+ margin_h?: number;
727
+ margin_v?: number;
728
+ items_gap?: number;
707
729
  }
708
730
 
709
731
  // ============================================