@morphika/andami 0.1.3 → 0.1.6

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 (105) hide show
  1. package/app/(site)/[slug]/page.tsx +7 -4
  2. package/app/(site)/layout.tsx +5 -2
  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 +7 -4
  6. package/app/admin/layout.tsx +3 -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/health/route.ts +1 -1
  10. package/app/api/admin/assets/register/route.ts +1 -1
  11. package/app/api/admin/assets/registry/route.ts +1 -1
  12. package/app/api/admin/assets/relink/confirm/route.ts +2 -2
  13. package/app/api/admin/assets/relink/route.ts +1 -1
  14. package/app/api/admin/assets/scan/route.ts +1 -1
  15. package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
  16. package/app/api/admin/custom-sections/route.ts +1 -1
  17. package/app/api/admin/database/route.ts +1 -1
  18. package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
  19. package/app/api/admin/pages/[slug]/route.ts +2 -2
  20. package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
  21. package/app/api/admin/pages/route.ts +1 -1
  22. package/app/api/admin/preview/route.ts +1 -1
  23. package/app/api/admin/r2/delete/route.ts +1 -1
  24. package/app/api/admin/r2/rename/route.ts +1 -1
  25. package/app/api/admin/r2/status/route.ts +1 -1
  26. package/app/api/admin/r2/upload-url/route.ts +1 -1
  27. package/app/api/admin/settings/route.ts +41 -16
  28. package/app/api/admin/setup/complete/route.ts +2 -2
  29. package/app/api/admin/setup/route.ts +7 -4
  30. package/app/api/admin/storage/switch/route.ts +1 -1
  31. package/app/api/admin/styles/route.ts +1 -1
  32. package/components/admin/index.ts +7 -0
  33. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  34. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  35. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  36. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  37. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  38. package/components/admin/nav-builder/index.ts +2 -0
  39. package/components/blocks/BlockRenderer.tsx +65 -13
  40. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  41. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  42. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  43. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  44. package/components/blocks/PageRenderer.tsx +4 -2
  45. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  46. package/components/blocks/SectionRenderer.tsx +9 -8
  47. package/components/blocks/SectionV2Renderer.tsx +8 -8
  48. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  49. package/components/blocks/TextBlockRenderer.tsx +9 -4
  50. package/components/builder/BuilderCanvas.tsx +10 -4
  51. package/components/builder/ColorPicker.tsx +51 -243
  52. package/components/builder/ColorSwatchPicker.tsx +214 -274
  53. package/components/builder/DndWrapper.tsx +5 -2
  54. package/components/builder/SectionV2Canvas.tsx +15 -4
  55. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  57. package/components/builder/color-picker/AngleControl.tsx +138 -0
  58. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  59. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  60. package/components/builder/color-picker/GradientBar.tsx +222 -0
  61. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  62. package/components/builder/color-picker/HueSlider.tsx +124 -0
  63. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  64. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  65. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  66. package/components/builder/color-picker/PositionControl.tsx +158 -0
  67. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  68. package/components/builder/color-picker/StopEditor.tsx +178 -0
  69. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  70. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  71. package/components/builder/color-picker/index.ts +62 -0
  72. package/components/builder/color-picker/types.ts +115 -0
  73. package/components/builder/color-picker/utils.ts +138 -0
  74. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  75. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  76. package/components/builder/hooks/useColumnDrag.ts +25 -27
  77. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  78. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  79. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  80. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  81. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  82. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  83. package/components/ui/Navbar.tsx +97 -25
  84. package/components/ui/PortfolioTracker.tsx +3 -3
  85. package/lib/assets.ts +1 -1
  86. package/lib/auth.ts +1 -1
  87. package/lib/builder/gradient-presets.ts +128 -0
  88. package/lib/builder/layout-styles.ts +16 -10
  89. package/lib/builder/serializer.ts +1 -0
  90. package/lib/builder/store-blocks.ts +48 -61
  91. package/lib/builder/store-helpers.ts +31 -14
  92. package/lib/builder/store.ts +59 -41
  93. package/lib/builder/types.ts +14 -0
  94. package/lib/color-utils.ts +200 -0
  95. package/lib/revalidate.ts +2 -2
  96. package/lib/sanity/client.ts +16 -0
  97. package/lib/sanity/queries.ts +4 -3
  98. package/lib/sanity/types.ts +76 -1
  99. package/lib/setup/detect.ts +1 -1
  100. package/lib/storage/index.ts +22 -4
  101. package/lib/version.ts +6 -0
  102. package/package.json +8 -2
  103. package/sanity/schemas/siteSettings.ts +34 -0
  104. package/styles/base.css +3 -3
  105. package/app/globals.css +0 -7
@@ -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;
@@ -66,6 +66,8 @@ export default function ProjectGridBlockRenderer({
66
66
  const containerTopRef = useRef<number | null>(null);
67
67
  const [containerWidth, setContainerWidth] = useState(0);
68
68
  const [resolvedProjects, setResolvedProjects] = useState<ResolvedProject[]>([]);
69
+ // BLK-005: Track fetch errors so we can provide user feedback
70
+ const [fetchError, setFetchError] = useState(false);
69
71
 
70
72
  // ─── Measure container width via ResizeObserver ───
71
73
  // Uses callback ref so the observer is set up the instant the DOM node
@@ -116,8 +118,9 @@ export default function ProjectGridBlockRenderer({
116
118
 
117
119
  const slugs = block.projects.map((p) => p.project_slug);
118
120
 
121
+ setFetchError(false);
119
122
  fetch("/api/projects")
120
- .then((r) => (r.ok ? r.json() : { projects: [] }))
123
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`API ${r.status}`))))
121
124
  .then((data) => {
122
125
  const projectMap = new Map<string, { title: string; subtitle: string; thumbnail_path?: string; cover_video?: string }>();
123
126
  for (const proj of data.projects || []) {
@@ -151,8 +154,10 @@ export default function ProjectGridBlockRenderer({
151
154
 
152
155
  setResolvedProjects(resolved);
153
156
  })
154
- .catch(() => {
157
+ .catch((err) => {
158
+ console.error("[ProjectGridBlock] Failed to fetch projects:", err);
155
159
  setResolvedProjects([]);
160
+ setFetchError(true);
156
161
  });
157
162
  }, [block.projects]);
158
163
 
@@ -270,7 +275,17 @@ export default function ProjectGridBlockRenderer({
270
275
  return indices;
271
276
  }, [entranceEnabled, masonry.items, aboveFoldKeys, gapV]);
272
277
 
273
- if (resolvedProjects.length === 0) return null;
278
+ if (resolvedProjects.length === 0) {
279
+ // BLK-005: Show subtle error message if fetch failed (not just empty data)
280
+ if (fetchError) {
281
+ return (
282
+ <div className="py-8 text-center font-sans text-sm text-brand-muted opacity-60">
283
+ Unable to load projects. Please try refreshing the page.
284
+ </div>
285
+ );
286
+ }
287
+ return null;
288
+ }
274
289
 
275
290
  return (
276
291
  <div
@@ -16,6 +16,8 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
16
16
  import BlockRenderer from "./BlockRenderer";
17
17
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
18
18
  import { getRowLayoutStyles, hexToRgba } from "../../lib/builder/layout-styles";
19
+ import { colorToOverrideRule, borderColorToOverrideRule, parseColorField } from "../../lib/color-utils";
20
+ import type { ColorField } from "../../lib/sanity/types";
19
21
  import { BREAKPOINTS } from "../../lib/builder/constants";
20
22
 
21
23
  /**
@@ -81,9 +83,9 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
81
83
  }
82
84
  }
83
85
 
84
- // Border color
86
+ // Border color (supports solid + gradients via ColorField bridge)
85
87
  if (overrides.border_color) {
86
- rules.push(`border-color:${overrides.border_color}!important`);
88
+ rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
87
89
  }
88
90
 
89
91
  // Border style
@@ -91,14 +93,13 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
91
93
  rules.push(`border-style:${overrides.border_style}!important`);
92
94
  }
93
95
 
94
- // Background color + opacity rgba()
96
+ // Background color + opacity (gradient-safe via ColorField bridge)
95
97
  if (overrides.background_color) {
96
98
  const opacity = overrides.background_opacity as number | undefined;
97
- if (opacity !== undefined && opacity < 100) {
98
- rules.push(`background-color:${hexToRgba(overrides.background_color as string, opacity / 100)}!important`);
99
- } else {
100
- rules.push(`background-color:${overrides.background_color}!important`);
101
- }
99
+ rules.push(colorToOverrideRule(
100
+ parseColorField(overrides.background_color),
101
+ opacity
102
+ ));
102
103
  }
103
104
 
104
105
  // Background image + sub-properties
@@ -25,6 +25,7 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
25
25
  import BlockRenderer from "./BlockRenderer";
26
26
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
27
27
  import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, hexToRgba } from "../../lib/builder/layout-styles";
28
+ import { parseColorField, colorToOverrideRule, borderColorToOverrideRule } from "../../lib/color-utils";
28
29
  import { BREAKPOINTS } from "../../lib/builder/constants";
29
30
 
30
31
  // ── Responsive CSS generation ──
@@ -99,9 +100,9 @@ function buildSettingsOverrideRules(
99
100
  }
100
101
  }
101
102
 
102
- // Border color
103
+ // Border color (supports solid + gradients via ColorField bridge)
103
104
  if (overrides.border_color) {
104
- rules.push(`border-color:${overrides.border_color}!important`);
105
+ rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
105
106
  }
106
107
 
107
108
  // Border style
@@ -109,14 +110,13 @@ function buildSettingsOverrideRules(
109
110
  rules.push(`border-style:${overrides.border_style}!important`);
110
111
  }
111
112
 
112
- // Background color + opacity
113
+ // Background color + opacity (supports solid + gradients via ColorField bridge)
113
114
  if (overrides.background_color) {
114
115
  const opacity = overrides.background_opacity;
115
- if (opacity !== undefined && opacity < 100) {
116
- rules.push(`background-color:${hexToRgba(overrides.background_color, opacity / 100)}!important`);
117
- } else {
118
- rules.push(`background-color:${overrides.background_color}!important`);
119
- }
116
+ rules.push(colorToOverrideRule(
117
+ parseColorField(overrides.background_color),
118
+ opacity
119
+ ));
120
120
  }
121
121
 
122
122
  return rules;
@@ -8,8 +8,10 @@ const heightMap: Record<string, string> = {
8
8
  };
9
9
 
10
10
  export default function SpacerBlockRenderer({ block }: { block: SpacerBlock }) {
11
- if (block.height === "custom" && block.custom_height) {
12
- return <div style={{ height: `${block.custom_height}px` }} aria-hidden />;
11
+ // BLK-007: Use != null instead of truthiness to allow custom_height of 0
12
+ if (block.height === "custom" && block.custom_height != null) {
13
+ const h = Math.max(0, block.custom_height);
14
+ return <div style={{ height: `${h}px` }} aria-hidden />;
13
15
  }
14
16
  return (
15
17
  <div className={heightMap[block.height ?? "medium"]} aria-hidden />
@@ -3,7 +3,8 @@ import { PortableText } from "next-sanity";
3
3
 
4
4
  /** Resolve fontSize: supports numeric px and legacy string enum */
5
5
  function resolvePublicFontSize(fontSize?: number | string): string | undefined {
6
- if (typeof fontSize === "number") return `${fontSize}px`;
6
+ // BLK-015: Guard against negative or zero font sizes
7
+ if (typeof fontSize === "number") return fontSize > 0 ? `${fontSize}px` : undefined;
7
8
  // Legacy Tailwind class mapping
8
9
  const tailwindMap: Record<string, string> = {
9
10
  small: "text-sm",
@@ -49,7 +50,7 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
49
50
  const isNumericWeight = s?.fontWeight && !isNaN(parseInt(s.fontWeight, 10));
50
51
 
51
52
  const classes = [
52
- "font-mono",
53
+ "font-sans",
53
54
  !isNumericFontSize ? resolvePublicFontSize(s?.fontSize) : undefined,
54
55
  alignmentMap[s?.alignment ?? "left"],
55
56
  !isNumericWeight ? resolvePublicFontWeight(s?.fontWeight) : undefined,
@@ -58,9 +59,13 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
58
59
  .join(" ");
59
60
 
60
61
  const inlineStyle: React.CSSProperties = {};
61
- if (isNumericFontSize) inlineStyle.fontSize = `${s!.fontSize}px`;
62
+ if (isNumericFontSize && (s!.fontSize as number) > 0) inlineStyle.fontSize = `${s!.fontSize}px`;
62
63
  if (isNumericWeight) inlineStyle.fontWeight = parseInt(s!.fontWeight!, 10);
63
- if (s?.color) inlineStyle.color = s.color;
64
+ // Guard: text color must be a solid hex string, not a gradient.
65
+ // resolveColorHex extracts a representative hex if a gradient slips through.
66
+ if (s?.color) {
67
+ inlineStyle.color = typeof s.color === "string" ? s.color : "#000000";
68
+ }
64
69
  if (s?.lineHeight) inlineStyle.lineHeight = s.lineHeight;
65
70
  if (s?.letterSpacing) inlineStyle.letterSpacing = s.letterSpacing;
66
71
  if (s?.maxWidth) inlineStyle.maxWidth = s.maxWidth;
@@ -70,9 +70,15 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
70
70
  const zoomRef = useRef(zoom);
71
71
  const panXRef = useRef(panX);
72
72
  const panYRef = useRef(panY);
73
+ // LEAK-002 fix: Also ref action functions so flushWheel/handleWheel
74
+ // callbacks are truly stable and never cause wheel listener resubscription.
75
+ const zoomToPointRef = useRef(zoomToPoint);
76
+ const setCanvasPanRef = useRef(setCanvasPan);
73
77
  useEffect(() => { zoomRef.current = zoom; }, [zoom]);
74
78
  useEffect(() => { panXRef.current = panX; }, [panX]);
75
79
  useEffect(() => { panYRef.current = panY; }, [panY]);
80
+ useEffect(() => { zoomToPointRef.current = zoomToPoint; }, [zoomToPoint]);
81
+ useEffect(() => { setCanvasPanRef.current = setCanvasPan; }, [setCanvasPan]);
76
82
 
77
83
  // ---- Trigger smooth animation for programmatic zoom/pan ----
78
84
  const triggerAnimation = useCallback(() => {
@@ -128,7 +134,7 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
128
134
  // Throttled via requestAnimationFrame to prevent jank on rapid scroll.
129
135
  // Deltas accumulate between frames so no input is lost.
130
136
 
131
- // Stable flush — reads current values from refs, never re-created
137
+ // Stable flush — reads current values from refs, never re-created (LEAK-002)
132
138
  const flushWheel = useCallback(() => {
133
139
  wheelRafRef.current = null;
134
140
  const accum = wheelAccumRef.current;
@@ -138,11 +144,11 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
138
144
  if (accum.isZoom) {
139
145
  const zoomDelta = -accum.deltaY * 0.005;
140
146
  const newZoom = zoomRef.current * (1 + zoomDelta);
141
- zoomToPoint(newZoom, accum.cursorX, accum.cursorY);
147
+ zoomToPointRef.current(newZoom, accum.cursorX, accum.cursorY);
142
148
  } else {
143
- setCanvasPan(panXRef.current - accum.deltaX, panYRef.current - accum.deltaY);
149
+ setCanvasPanRef.current(panXRef.current - accum.deltaX, panYRef.current - accum.deltaY);
144
150
  }
145
- }, [zoomToPoint, setCanvasPan]);
151
+ }, []); // No deps — all values read from refs
146
152
 
147
153
  // Stable wheel handler — never re-subscribed during zoom/pan
148
154
  const handleWheel = useCallback(