@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
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { NavItem, NavDesign, MobileNavDesign } from "../../../lib/sanity/types";
5
+ import NavMobileLivePreview from "./NavMobileLivePreview";
6
+ import ColorSwatchPicker, { usePaletteSwatches } from "../../builder/ColorSwatchPicker";
7
+ import {
8
+ Field,
9
+ TextInput,
10
+ SelectInput,
11
+ SegmentedControl,
12
+ RangeSlider,
13
+ Section,
14
+ } from "./NavSettingsFields";
15
+
16
+ // ============================================
17
+ // NavMobileSettings — standalone panel for mobile menu customization
18
+ // Session 158: Independent mobile menu styles that are NOT affected
19
+ // by page-level nav_color or parallax slide color overrides.
20
+ // ============================================
21
+
22
+ interface NavMobileSettingsProps {
23
+ design: MobileNavDesign;
24
+ /** Desktop nav design — needed for fallback values and preview */
25
+ desktopDesign: NavDesign;
26
+ /** Nav items — needed for preview */
27
+ items: NavItem[];
28
+ onChange: (design: MobileNavDesign) => void;
29
+ onSave: () => void;
30
+ saving: boolean;
31
+ hasChanges: boolean;
32
+ }
33
+
34
+ export default function NavMobileSettings({
35
+ design,
36
+ desktopDesign,
37
+ items,
38
+ onChange,
39
+ onSave,
40
+ saving,
41
+ hasChanges,
42
+ }: NavMobileSettingsProps) {
43
+ const swatches = usePaletteSwatches();
44
+ const update = useCallback(
45
+ (partial: Partial<MobileNavDesign>) => onChange({ ...design, ...partial }),
46
+ [design, onChange]
47
+ );
48
+
49
+ return (
50
+ <div className="bg-white rounded-2xl overflow-hidden border border-neutral-200">
51
+ {/* Header */}
52
+ <div className="px-5 py-3 border-b border-neutral-200 flex items-center justify-between">
53
+ <div>
54
+ <div className="text-sm font-semibold text-neutral-900">
55
+ Mobile Menu
56
+ </div>
57
+ <div className="text-[11px] text-neutral-400 mt-0.5">
58
+ Customize the hamburger menu independently from the desktop navbar
59
+ </div>
60
+ </div>
61
+ <div className="flex items-center gap-3">
62
+ {hasChanges && (
63
+ <span className="text-[11px] text-amber-500 font-medium">
64
+ Unsaved changes
65
+ </span>
66
+ )}
67
+ <button
68
+ onClick={onSave}
69
+ disabled={saving || !hasChanges}
70
+ className={`px-5 py-1.5 text-sm font-medium rounded-lg transition-all ${
71
+ saving || !hasChanges
72
+ ? "bg-neutral-100 text-neutral-400 cursor-not-allowed"
73
+ : "bg-[#076bff] text-white hover:bg-[#0559d4]"
74
+ }`}
75
+ >
76
+ {saving ? "Saving..." : "Save"}
77
+ </button>
78
+ </div>
79
+ </div>
80
+
81
+ {/* Side-by-side: settings left, preview right */}
82
+ <div className="flex">
83
+ {/* Settings content */}
84
+ <div className="flex-1 min-w-0 px-5 py-4 border-r border-neutral-200">
85
+ <div className="max-w-md">
86
+ {/* ── Menu Overlay (expanded fullscreen menu) ── */}
87
+ <Section title="MENU OVERLAY">
88
+ <Field label="Background">
89
+ <ColorSwatchPicker
90
+ value={design.overlay_bg || ""}
91
+ onChange={(v) => update({ overlay_bg: typeof v === "string" ? v : "" })}
92
+ swatches={swatches}
93
+ />
94
+ </Field>
95
+ <Field label="Text color">
96
+ <ColorSwatchPicker
97
+ value={design.text_color || ""}
98
+ onChange={(v) => update({ text_color: typeof v === "string" ? v : "" })}
99
+ swatches={swatches}
100
+ />
101
+ </Field>
102
+ <Field label="Font size">
103
+ <TextInput
104
+ value={design.font_size ?? 24}
105
+ onChange={(v) =>
106
+ update({
107
+ font_size: Math.max(12, Math.min(72, parseInt(v) || 24)),
108
+ })
109
+ }
110
+ type="number"
111
+ />
112
+ </Field>
113
+ <Field label="Transform">
114
+ <SelectInput
115
+ value={design.text_transform || "uppercase"}
116
+ onChange={(v) =>
117
+ update({
118
+ text_transform: v as MobileNavDesign["text_transform"],
119
+ })
120
+ }
121
+ options={[
122
+ { value: "uppercase", label: "UPPERCASE" },
123
+ { value: "none", label: "None" },
124
+ { value: "lowercase", label: "lowercase" },
125
+ { value: "capitalize", label: "Capitalize" },
126
+ ]}
127
+ />
128
+ </Field>
129
+ <Field label="Align">
130
+ <SegmentedControl
131
+ value={design.items_align || "center"}
132
+ onChange={(v) =>
133
+ update({ items_align: v as MobileNavDesign["items_align"] })
134
+ }
135
+ options={[
136
+ { value: "left", label: "Left" },
137
+ { value: "center", label: "Center" },
138
+ { value: "right", label: "Right" },
139
+ ]}
140
+ />
141
+ </Field>
142
+ <Field label="Items gap">
143
+ <RangeSlider
144
+ value={design.items_gap ?? 32}
145
+ onChange={(v) => update({ items_gap: v })}
146
+ min={8}
147
+ max={80}
148
+ suffix="px"
149
+ />
150
+ </Field>
151
+ </Section>
152
+
153
+ {/* ── Navbar Bar (logo + hamburger row) ── */}
154
+ <Section title="NAVBAR BAR">
155
+ <Field label="BG color">
156
+ <ColorSwatchPicker
157
+ value={design.navbar_bg || ""}
158
+ onChange={(v) => update({ navbar_bg: typeof v === "string" ? v : "" })}
159
+ swatches={swatches}
160
+ />
161
+ </Field>
162
+ {design.navbar_bg && (
163
+ <Field label="BG opacity">
164
+ <RangeSlider
165
+ value={design.navbar_bg_opacity ?? 100}
166
+ onChange={(v) => update({ navbar_bg_opacity: v })}
167
+ min={0}
168
+ max={100}
169
+ suffix="%"
170
+ />
171
+ </Field>
172
+ )}
173
+ <Field label="Icon color">
174
+ <ColorSwatchPicker
175
+ value={design.hamburger_color || ""}
176
+ onChange={(v) => update({ hamburger_color: typeof v === "string" ? v : "" })}
177
+ swatches={swatches}
178
+ />
179
+ </Field>
180
+ <Field label="Pad H">
181
+ <RangeSlider
182
+ value={design.padding_h ?? 24}
183
+ onChange={(v) => update({ padding_h: v })}
184
+ min={0}
185
+ max={60}
186
+ suffix="px"
187
+ />
188
+ </Field>
189
+ <Field label="Pad V">
190
+ <RangeSlider
191
+ value={design.padding_v ?? 27}
192
+ onChange={(v) => update({ padding_v: v })}
193
+ min={0}
194
+ max={60}
195
+ suffix="px"
196
+ />
197
+ </Field>
198
+ </Section>
199
+
200
+ {/* Info notice */}
201
+ <div className="mt-4 p-3 bg-blue-50 rounded-xl border border-blue-200">
202
+ <p className="text-[11px] text-blue-600 leading-relaxed">
203
+ <strong>Independent from page overrides:</strong> Page-level{" "}
204
+ <code className="bg-blue-100 px-1 rounded text-[10px]">nav_color</code>{" "}
205
+ and parallax slide color changes only affect the desktop navbar.
206
+ The mobile menu always uses these dedicated styles.
207
+ </p>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ {/* Live preview — sticky on the right */}
213
+ <div className="w-[340px] shrink-0 self-start sticky top-0">
214
+ <NavMobileLivePreview
215
+ items={items}
216
+ design={desktopDesign}
217
+ mobileDesign={design}
218
+ />
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
@@ -7,4 +7,6 @@ export { default as NavLivePreview } from "./NavLivePreview";
7
7
  export { default as NavSettingsPanel } from "./NavSettingsPanel";
8
8
  export { default as NavGeneralSettings } from "./NavGeneralSettings";
9
9
  export { default as NavItemSettings } from "./NavItemSettings";
10
+ export { default as NavMobileSettings } from "./NavMobileSettings";
11
+ export { default as NavMobileLivePreview } from "./NavMobileLivePreview";
10
12
  export * from "./nav-builder-utils";
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Component } from "react";
3
4
  import type { ContentBlock, BlockLayout } from "../../lib/sanity/types";
4
5
  import type { EnterAnimationConfig, TypewriterConfig } from "../../lib/animation/enter-types";
5
6
  import type { HoverEffectConfig } from "../../lib/animation/hover-effect-types";
@@ -9,7 +10,8 @@ import { useAssetUrl } from "../../lib/contexts/AssetContext";
9
10
  import { useViewport } from "../../lib/hooks/useViewport";
10
11
  import { resolveBlock } from "../../lib/builder/responsive";
11
12
  import { getBlockLayoutStyles, hasBlockLayout } from "../../lib/builder/layout-styles";
12
- import { hexToRgba } from "../../lib/color-utils";
13
+ import { hexToRgba, colorToOverrideRule, borderColorToOverrideRule, parseColorField } from "../../lib/color-utils";
14
+ import type { ColorField } from "../../lib/sanity/types";
13
15
  import { BREAKPOINTS } from "../../lib/builder/constants";
14
16
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
15
17
  import HoverAnimationWrapper from "./HoverAnimationWrapper";
@@ -24,6 +26,50 @@ import ButtonBlockRenderer from "./ButtonBlockRenderer";
24
26
  import CoverBlockRenderer from "./CoverBlockRenderer";
25
27
  import ProjectGridBlockRenderer from "./ProjectGridBlockRenderer";
26
28
 
29
+ // ── BLK-003: Error Boundary for block renderers ──
30
+ // Prevents a single broken block from crashing the entire page.
31
+ // Class component required — React error boundaries don't support hooks.
32
+ interface BlockErrorBoundaryProps {
33
+ blockType: string;
34
+ blockKey: string;
35
+ children: React.ReactNode;
36
+ }
37
+ interface BlockErrorBoundaryState {
38
+ hasError: boolean;
39
+ }
40
+ class BlockErrorBoundary extends Component<BlockErrorBoundaryProps, BlockErrorBoundaryState> {
41
+ constructor(props: BlockErrorBoundaryProps) {
42
+ super(props);
43
+ this.state = { hasError: false };
44
+ }
45
+
46
+ static getDerivedStateFromError(): BlockErrorBoundaryState {
47
+ return { hasError: true };
48
+ }
49
+
50
+ componentDidCatch(error: Error) {
51
+ console.error(
52
+ `[BlockRenderer] Error in ${this.props.blockType} (key: ${this.props.blockKey}):`,
53
+ error
54
+ );
55
+ }
56
+
57
+ render() {
58
+ if (this.state.hasError) {
59
+ if (process.env.NODE_ENV === "development") {
60
+ return (
61
+ <div className="border border-dashed border-red-400 bg-red-50 p-4 font-mono text-xs text-red-600">
62
+ Block crashed: {this.props.blockType} ({this.props.blockKey})
63
+ </div>
64
+ );
65
+ }
66
+ // Production: render nothing — block silently disappears instead of page crash
67
+ return null;
68
+ }
69
+ return this.props.children;
70
+ }
71
+ }
72
+
27
73
  /**
28
74
  * Central block dispatcher for the public site.
29
75
  *
@@ -111,9 +157,9 @@ function buildBlockLayoutOverrideRules(
111
157
  }
112
158
  }
113
159
 
114
- // Border color (no px)
160
+ // Border color (supports solid + gradients via ColorField bridge)
115
161
  if (overrides.border_color) {
116
- rules.push(`border-color:${overrides.border_color}!important`);
162
+ rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
117
163
  }
118
164
 
119
165
  // Border style (no px)
@@ -121,15 +167,13 @@ function buildBlockLayoutOverrideRules(
121
167
  rules.push(`border-style:${overrides.border_style}!important`);
122
168
  }
123
169
 
124
- // Background color (with opacity support)
170
+ // Background color (with opacity support, gradient-safe via ColorField bridge)
125
171
  if (overrides.background_color) {
126
172
  const opacity = overrides.background_opacity;
127
- if (opacity !== undefined && opacity < 100) {
128
- const rgba = hexToRgba(overrides.background_color, opacity / 100);
129
- rules.push(`background-color:${rgba}!important`);
130
- } else {
131
- rules.push(`background-color:${overrides.background_color}!important`);
132
- }
173
+ rules.push(colorToOverrideRule(
174
+ parseColorField(overrides.background_color),
175
+ opacity !== undefined ? opacity : undefined
176
+ ));
133
177
  } else if (overrides.background_opacity !== undefined) {
134
178
  // Opacity-only override (color inherited from desktop) — handled at render via resolveBlock
135
179
  }
@@ -269,16 +313,20 @@ export default function BlockRenderer({
269
313
  case "projectGridBlock":
270
314
  content = <ProjectGridBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectGridBlock} />;
271
315
  break;
272
- default:
316
+ default: {
317
+ const unknownBlock = resolved as ContentBlock;
273
318
  if (process.env.NODE_ENV === "development") {
274
319
  content = (
275
320
  <div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
276
- Unknown block type: {(resolved as ContentBlock)._type}
321
+ Unknown block type: {unknownBlock._type}
277
322
  </div>
278
323
  );
279
324
  } else {
325
+ // BLK-004: Log unknown block types in production for debugging
326
+ console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
280
327
  return null;
281
328
  }
329
+ }
282
330
  }
283
331
 
284
332
  // ── Resolve enter animation once (used both for typewriter early-wrap and normal path) ──
@@ -400,5 +448,9 @@ export default function BlockRenderer({
400
448
  }
401
449
  }
402
450
 
403
- return <>{content}</>;
451
+ return (
452
+ <BlockErrorBoundary blockType={resolved._type} blockKey={block._key}>
453
+ {content}
454
+ </BlockErrorBoundary>
455
+ );
404
456
  }
@@ -21,6 +21,18 @@ const alignmentMap: Record<string, string> = {
21
21
  right: "justify-end",
22
22
  };
23
23
 
24
+ // BLK-001: Validate URL — only allow safe protocols and non-empty values
25
+ function isValidUrl(url: unknown): url is string {
26
+ if (!url || typeof url !== "string") return false;
27
+ const trimmed = url.trim();
28
+ if (!trimmed) return false;
29
+ // Allow relative paths, http(s), mailto, tel — block javascript: and data:
30
+ if (/^(https?:\/\/|mailto:|tel:|\/)/i.test(trimmed)) return true;
31
+ // Allow fragment-only links (#section)
32
+ if (trimmed.startsWith("#")) return true;
33
+ return false;
34
+ }
35
+
24
36
  export default function ButtonBlockRenderer({
25
37
  block,
26
38
  }: {
@@ -30,23 +42,34 @@ export default function ButtonBlockRenderer({
30
42
  const size = sizeMap[block.size ?? "medium"];
31
43
  const alignment = alignmentMap[block.alignment ?? "left"];
32
44
 
45
+ const href = isValidUrl(block.url) ? block.url : undefined;
46
+
47
+ // Don't render a broken link — render as inert span if URL is missing/invalid
48
+ const Tag = href ? "a" : "span";
49
+ const linkProps = href
50
+ ? {
51
+ href,
52
+ target: block.target ? ("_blank" as const) : undefined,
53
+ rel: block.target ? "noopener noreferrer" : undefined,
54
+ }
55
+ : {};
56
+
33
57
  return (
34
58
  <div className={`flex ${alignment}`}>
35
- <a
36
- href={block.url}
37
- target={block.target ? "_blank" : undefined}
38
- rel={block.target ? "noopener noreferrer" : undefined}
59
+ <Tag
60
+ {...linkProps}
39
61
  className={[
40
- "inline-block font-mono uppercase tracking-wider transition-all",
62
+ "inline-block font-sans uppercase tracking-wider transition-all",
41
63
  variant,
42
64
  size,
43
65
  block.full_width ? "w-full text-center" : "",
66
+ !href ? "cursor-default opacity-60" : "",
44
67
  ]
45
68
  .filter(Boolean)
46
69
  .join(" ")}
47
70
  >
48
71
  {block.text}
49
- </a>
72
+ </Tag>
50
73
  </div>
51
74
  );
52
75
  }
@@ -3,6 +3,7 @@
3
3
  import type { CoverBlock } from "../../lib/sanity/types";
4
4
  import { useAssetUrl } from "../../lib/contexts/AssetContext";
5
5
  import { handleImageRetry, handleVideoRetry } from "../../lib/asset-retry";
6
+ import { parseColorField, colorToCSS, isGradient } from "../../lib/color-utils";
6
7
 
7
8
  function getOverlayStyle(
8
9
  overlay: CoverBlock["overlay"],
@@ -70,10 +71,18 @@ export default function CoverBlockRenderer({
70
71
  ? block.mobile_height
71
72
  : null;
72
73
 
73
- const overlayStyle = getOverlayStyle(
74
- block.overlay ?? "none",
75
- block.overlay_opacity ?? 50
76
- );
74
+ // Phase 4: custom overlay_gradient takes precedence over hardcoded presets
75
+ const overlayStyle: React.CSSProperties | null = (() => {
76
+ if (block.overlay_gradient) {
77
+ const parsed = parseColorField(block.overlay_gradient);
78
+ if (isGradient(parsed)) {
79
+ return { backgroundImage: colorToCSS(parsed) };
80
+ }
81
+ // Solid color overlay from gradient field
82
+ return { backgroundColor: colorToCSS(parsed) };
83
+ }
84
+ return getOverlayStyle(block.overlay ?? "none", block.overlay_opacity ?? 50);
85
+ })();
77
86
 
78
87
  const resolveAsset = useAssetUrl();
79
88
  const mediaSrc = block.media_path ? resolveAsset(block.media_path) : undefined;
@@ -82,10 +91,14 @@ export default function CoverBlockRenderer({
82
91
  : undefined;
83
92
  const isVideo = block.media_type === "video";
84
93
 
85
- const objectFit = block.background_size || "cover";
94
+ // BLK-010: Validate objectFit against allowed CSS values
95
+ const allowedObjectFit = new Set(["cover", "contain", "none", "fill", "scale-down"]);
96
+ const objectFit = allowedObjectFit.has(block.background_size || "") ? block.background_size! : "cover";
86
97
  const objectPosition = block.background_position || "center center";
87
98
 
88
- const textColor = block.text_color || "#ffffff";
99
+ // Guard: text color must be a solid hex string. Fallback if gradient slips through.
100
+ const rawTextColor = block.text_color || "#ffffff";
101
+ const textColor = typeof rawTextColor === "string" ? rawTextColor : "#ffffff";
89
102
 
90
103
  const ctaStyleClasses: Record<string, string> = {
91
104
  primary:
@@ -97,19 +110,28 @@ export default function CoverBlockRenderer({
97
110
  text: "underline underline-offset-4 hover:opacity-70",
98
111
  };
99
112
 
113
+ // BLK-002: Sanitize _key and mobileHeight before CSS interpolation
114
+ // _key: allow only alphanumeric, hyphens, underscores (strip anything else)
115
+ const safeKey = block._key?.replace(/[^a-zA-Z0-9_-]/g, "") || "";
116
+ // mobileHeight: must match valid CSS height (number+unit or CSS keyword)
117
+ const safeMobileHeight =
118
+ mobileHeight && /^(\d+(\.\d+)?(px|vh|vw|em|rem|%|svh|dvh)|auto|inherit)$/i.test(mobileHeight)
119
+ ? mobileHeight
120
+ : null;
121
+
100
122
  return (
101
123
  <>
102
124
  {/* Mobile height override via inline style tag */}
103
- {mobileHeight && (
125
+ {safeMobileHeight && safeKey && (
104
126
  <style
105
127
  dangerouslySetInnerHTML={{
106
- __html: `@media(max-width:767px){.cover-block-${block._key}{height:${mobileHeight}!important;min-height:${mobileHeight}!important;}}`,
128
+ __html: `@media(max-width:767px){.cover-block-${safeKey}{height:${safeMobileHeight}!important;min-height:${safeMobileHeight}!important;}}`,
107
129
  }}
108
130
  />
109
131
  )}
110
132
 
111
133
  <section
112
- className={`cover-block-${block._key} relative flex overflow-hidden`}
134
+ className={`cover-block-${safeKey} relative flex overflow-hidden`}
113
135
  style={{
114
136
  height,
115
137
  minHeight: height,
@@ -127,7 +149,7 @@ export default function CoverBlockRenderer({
127
149
  onError={handleImageRetry}
128
150
  className="absolute inset-0 h-full w-full"
129
151
  style={{
130
- objectFit: objectFit as "cover" | "contain" | "none",
152
+ objectFit: objectFit as React.CSSProperties["objectFit"],
131
153
  objectPosition,
132
154
  }}
133
155
  />
@@ -144,7 +166,7 @@ export default function CoverBlockRenderer({
144
166
  onError={handleVideoRetry}
145
167
  className="absolute inset-0 h-full w-full"
146
168
  style={{
147
- objectFit: objectFit as "cover" | "contain" | "none",
169
+ objectFit: objectFit as React.CSSProperties["objectFit"],
148
170
  objectPosition,
149
171
  }}
150
172
  />
@@ -182,13 +204,13 @@ export default function CoverBlockRenderer({
182
204
  }}
183
205
  >
184
206
  {block.headline && (
185
- <h1 className="font-mono text-4xl uppercase tracking-widest md:text-6xl lg:text-7xl">
207
+ <h1 className="font-sans text-4xl uppercase tracking-widest md:text-6xl lg:text-7xl">
186
208
  {block.headline}
187
209
  </h1>
188
210
  )}
189
211
 
190
212
  {block.subheadline && (
191
- <p className="font-mono text-sm uppercase tracking-wider opacity-80 md:text-base">
213
+ <p className="font-sans text-sm uppercase tracking-wider opacity-80 md:text-base">
192
214
  {block.subheadline}
193
215
  </p>
194
216
  )}
@@ -205,7 +227,7 @@ export default function CoverBlockRenderer({
205
227
  ? "noopener noreferrer"
206
228
  : undefined
207
229
  }
208
- className={`inline-block px-6 py-3 font-mono text-sm uppercase tracking-wider transition ${
230
+ className={`inline-block px-6 py-3 font-sans text-sm uppercase tracking-wider transition ${
209
231
  ctaStyleClasses[block.cta_button.style || "primary"]
210
232
  }`}
211
233
  >
@@ -28,7 +28,9 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
28
28
  const widthStyle = widthStyleMap[block.width ?? "full"] || widthStyleMap.full;
29
29
  const aspect = aspectMap[block.aspect_ratio ?? "auto"];
30
30
 
31
- const borderRadius = block.border_radius ? `${String(block.border_radius).replace(/px$/i, "")}px` : undefined;
31
+ // BLK-014: Strip any existing unit suffix, then validate as a number before appending px
32
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
33
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : undefined;
32
34
 
33
35
  const imgStyle: React.CSSProperties = {
34
36
  width: "100%",
@@ -44,7 +46,7 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
44
46
  {/* eslint-disable-next-line @next/next/no-img-element */}
45
47
  <img
46
48
  src={src}
47
- alt={block.alt ?? ""}
49
+ alt={block.alt || block.caption || ""}
48
50
  loading={block.lazy !== false ? "lazy" : "eager"}
49
51
  decoding="async"
50
52
  onError={handleImageRetry}
@@ -52,7 +54,7 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
52
54
  className={imgClassName}
53
55
  />
54
56
  {block.caption && (
55
- <figcaption className="mt-2 font-mono text-xs uppercase tracking-wider text-brand-muted">
57
+ <figcaption className="mt-2 font-sans text-xs uppercase tracking-wider text-brand-muted">
56
58
  {block.caption}
57
59
  </figcaption>
58
60
  )}
@@ -420,12 +420,19 @@ function GridLightbox({
420
420
  }
421
421
  >
422
422
  {/* eslint-disable-next-line @next/next/no-img-element */}
423
- <img
424
- src={resolveAsset(img.asset_path)}
425
- alt={img.alt ?? ""}
426
- className="max-w-full max-h-[85vh] object-contain"
427
- style={{ borderRadius: "2px", pointerEvents: "none" }}
428
- />
423
+ {/* BLK-009: Guard against missing asset_path from Sanity null fields */}
424
+ {img?.asset_path ? (
425
+ <img
426
+ src={resolveAsset(img.asset_path)}
427
+ alt={img.alt ?? ""}
428
+ className="max-w-full max-h-[85vh] object-contain"
429
+ style={{ borderRadius: "2px", pointerEvents: "none" }}
430
+ />
431
+ ) : (
432
+ <div className="flex h-40 w-60 items-center justify-center bg-neutral-900 text-sm text-neutral-500">
433
+ Image unavailable
434
+ </div>
435
+ )}
429
436
  </div>
430
437
  </div>
431
438
  </>,
@@ -8,6 +8,7 @@ import { PageNavColor } from "./PageNavColor";
8
8
  import { PageNavAnimation } from "./PageNavAnimation";
9
9
  import { PageBackground } from "./PageBackground";
10
10
  import { assetUrl } from "../../lib/assets";
11
+ import { parseColorField, colorToCSSProperty } from "../../lib/color-utils";
11
12
 
12
13
  /**
13
14
  * Find the first image-bearing block (CoverBlock or ImageBlock) in the page.
@@ -81,7 +82,7 @@ export default function PageRenderer({ page }: { page: Page }) {
81
82
  if (!page.content_rows?.length) {
82
83
  return (
83
84
  <div className="flex min-h-[50vh] items-center justify-center">
84
- <p className="font-mono text-sm text-brand-muted">
85
+ <p className="font-sans text-sm text-brand-muted">
85
86
  This page has no content yet.
86
87
  </p>
87
88
  </div>
@@ -95,7 +96,8 @@ export default function PageRenderer({ page }: { page: Page }) {
95
96
  const ps = page.page_settings;
96
97
  const pageStyle: React.CSSProperties = {};
97
98
  if (ps?.background_color && ps.background_color !== "transparent") {
98
- pageStyle.backgroundColor = ps.background_color;
99
+ const colorField = parseColorField(ps.background_color);
100
+ Object.assign(pageStyle, colorToCSSProperty(colorField));
99
101
  }
100
102
  if (ps?.text_color) {
101
103
  pageStyle.color = ps.text_color;