@morphika/andami 0.2.26 → 0.4.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 (81) hide show
  1. package/app/admin/pages/[slug]/page.tsx +41 -47
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +15 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockLivePreview.tsx +5 -0
  22. package/components/builder/BlockTypePicker.tsx +36 -63
  23. package/components/builder/BuilderCanvas.tsx +6 -2
  24. package/components/builder/ColumnDragOverlay.tsx +3 -3
  25. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  26. package/components/builder/CoverSectionCanvas.tsx +45 -52
  27. package/components/builder/DndWrapper.tsx +1 -1
  28. package/components/builder/InsertionLines.tsx +1 -1
  29. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  30. package/components/builder/ReadOnlyFrame.tsx +4 -23
  31. package/components/builder/SectionCardIcons.tsx +320 -0
  32. package/components/builder/SectionEditorBar.tsx +17 -12
  33. package/components/builder/SectionTypePicker.tsx +34 -138
  34. package/components/builder/SectionV2Canvas.tsx +1 -1
  35. package/components/builder/SectionV2Column.tsx +19 -30
  36. package/components/builder/SettingsPanel.tsx +8 -32
  37. package/components/builder/SortableBlock.tsx +42 -50
  38. package/components/builder/SortableRow.tsx +207 -19
  39. package/components/builder/blockStyles.tsx +59 -180
  40. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
  41. package/components/builder/editors/index.ts +1 -0
  42. package/components/builder/iconPrimitives.tsx +78 -0
  43. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  44. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
  45. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  46. package/components/builder/live-preview/index.ts +1 -0
  47. package/components/builder/settings-panel/BlockSettings.tsx +7 -0
  48. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  49. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  50. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  51. package/lib/animation/enter-types.ts +1 -0
  52. package/lib/animation/hover-effect-types.ts +1 -0
  53. package/lib/assets.ts +17 -2
  54. package/lib/builder/block-registrations.ts +268 -0
  55. package/lib/builder/block-registry.ts +195 -0
  56. package/lib/builder/constants.ts +22 -15
  57. package/lib/builder/defaults.ts +21 -0
  58. package/lib/builder/format.ts +25 -0
  59. package/lib/builder/history.ts +0 -3
  60. package/lib/builder/index.ts +16 -0
  61. package/lib/builder/layout-styles.ts +1 -1
  62. package/lib/builder/registry.ts +44 -0
  63. package/lib/builder/section-visibility.ts +36 -0
  64. package/lib/builder/serializer/normalizers.ts +15 -6
  65. package/lib/builder/serializer/serializers.ts +3 -3
  66. package/lib/builder/store-blocks.ts +16 -9
  67. package/lib/builder/store-cover.ts +76 -8
  68. package/lib/builder/store-sections.ts +1 -1
  69. package/lib/builder/store.ts +0 -2
  70. package/lib/builder/types.ts +9 -5
  71. package/lib/csrf.ts +31 -0
  72. package/lib/sanity/types.ts +54 -2
  73. package/lib/security.ts +50 -0
  74. package/lib/version.ts +1 -1
  75. package/package.json +1 -1
  76. package/sanity/schemas/blocks/index.ts +2 -1
  77. package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
  78. package/sanity/schemas/index.ts +4 -1
  79. package/sanity/schemas/objects/coverSection.ts +35 -3
  80. package/sanity/schemas/pageSectionV2.ts +1 -0
  81. package/components/builder/ParallaxSlideHeader.tsx +0 -113
@@ -15,74 +15,74 @@ const DEFAULT_GRID = {
15
15
 
16
16
  /** SVG illustration for the Column Gutter card */
17
17
  function GutterIcon() {
18
+ const ACCENT = "#4794E2";
18
19
  return (
19
- <svg viewBox="0 0 120 72" fill="none" className="w-full h-full" style={{ color: "#b0b5bd" }}>
20
- {/* 5 columns */}
21
- <rect x="6" y="8" width="16" height="56" rx="2" fill="currentColor" opacity="0.22" />
22
- <rect x="28" y="8" width="16" height="56" rx="2" fill="currentColor" opacity="0.22" />
23
- <rect x="50" y="8" width="16" height="56" rx="2" fill="currentColor" opacity="0.22" />
24
- <rect x="72" y="8" width="16" height="56" rx="2" fill="currentColor" opacity="0.22" />
25
- <rect x="94" y="8" width="16" height="56" rx="2" fill="currentColor" opacity="0.22" />
26
- {/* Gap arrows between columns */}
27
- <line x1="22" y1="36" x2="28" y2="36" stroke="currentColor" strokeWidth="1" opacity="0.55" />
28
- <path d="M26 34l2 2-2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
29
- <path d="M24 34l-2 2 2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
30
- <line x1="44" y1="36" x2="50" y2="36" stroke="currentColor" strokeWidth="1" opacity="0.55" />
31
- <path d="M48 34l2 2-2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
32
- <path d="M46 34l-2 2 2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
33
- <line x1="66" y1="36" x2="72" y2="36" stroke="currentColor" strokeWidth="1" opacity="0.55" />
34
- <path d="M70 34l2 2-2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
35
- <path d="M68 34l-2 2 2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
36
- <line x1="88" y1="36" x2="94" y2="36" stroke="currentColor" strokeWidth="1" opacity="0.55" />
37
- <path d="M92 34l2 2-2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
38
- <path d="M90 34l-2 2 2 2" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.55" />
20
+ <svg viewBox="0 0 120 72" fill="none" className="w-full h-full">
21
+ {/* 5 columns (ghost blue) */}
22
+ <rect x="6" y="8" width="16" height="56" rx="2" fill="#DDE6F5" />
23
+ <rect x="28" y="8" width="16" height="56" rx="2" fill="#DDE6F5" />
24
+ <rect x="50" y="8" width="16" height="56" rx="2" fill="#DDE6F5" />
25
+ <rect x="72" y="8" width="16" height="56" rx="2" fill="#DDE6F5" />
26
+ <rect x="94" y="8" width="16" height="56" rx="2" fill="#DDE6F5" />
27
+ {/* Gap arrows between columns (accent) */}
28
+ <line x1="22" y1="36" x2="28" y2="36" stroke={ACCENT} />
29
+ <path d="M26 34l2 2-2 2" stroke={ACCENT} fill="none" />
30
+ <path d="M24 34l-2 2 2 2" stroke={ACCENT} fill="none" />
31
+ <line x1="44" y1="36" x2="50" y2="36" stroke={ACCENT} />
32
+ <path d="M48 34l2 2-2 2" stroke={ACCENT} fill="none" />
33
+ <path d="M46 34l-2 2 2 2" stroke={ACCENT} fill="none" />
34
+ <line x1="66" y1="36" x2="72" y2="36" stroke={ACCENT} />
35
+ <path d="M70 34l2 2-2 2" stroke={ACCENT} fill="none" />
36
+ <path d="M68 34l-2 2 2 2" stroke={ACCENT} fill="none" />
37
+ <line x1="88" y1="36" x2="94" y2="36" stroke={ACCENT} />
38
+ <path d="M92 34l2 2-2 2" stroke={ACCENT} fill="none" />
39
+ <path d="M90 34l-2 2 2 2" stroke={ACCENT} fill="none" />
39
40
  </svg>
40
41
  );
41
42
  }
42
43
 
43
44
  /** SVG illustration for the Max Page Width card */
44
45
  function MaxWidthIcon() {
46
+ // Frame/dots/content stay neutral; arrows + dashed boundaries are the accent.
47
+ const ACCENT = "#4794E2";
45
48
  return (
46
- <svg viewBox="0 0 120 72" fill="none" className="w-full h-full" style={{ color: "#b0b5bd" }}>
49
+ <svg viewBox="0 0 120 72" fill="none" className="w-full h-full">
47
50
  {/* Browser frame */}
48
- <rect x="10" y="10" width="100" height="52" rx="4" stroke="currentColor" strokeWidth="1.5" opacity="0.3" />
51
+ <rect x="10" y="10" width="100" height="52" rx="4" stroke="#b0b5bd" strokeWidth="1.5" opacity="0.3" />
49
52
  {/* Title bar dots */}
50
- <circle cx="18" cy="17" r="1.5" fill="currentColor" opacity="0.25" />
51
- <circle cx="23" cy="17" r="1.5" fill="currentColor" opacity="0.25" />
52
- <circle cx="28" cy="17" r="1.5" fill="currentColor" opacity="0.25" />
53
+ <circle cx="18" cy="17" r="1.5" fill="#b0b5bd" opacity="0.25" />
54
+ <circle cx="23" cy="17" r="1.5" fill="#b0b5bd" opacity="0.25" />
55
+ <circle cx="28" cy="17" r="1.5" fill="#b0b5bd" opacity="0.25" />
53
56
  {/* Divider */}
54
- <line x1="10" y1="22" x2="110" y2="22" stroke="currentColor" strokeWidth="1" opacity="0.15" />
55
- {/* Content area */}
56
- <rect x="28" y="28" width="64" height="28" rx="2" fill="currentColor" opacity="0.1" />
57
+ <line x1="10" y1="22" x2="110" y2="22" stroke="#b0b5bd" strokeWidth="1" opacity="0.15" />
58
+ {/* Content area (ghost blue) */}
59
+ <rect x="28" y="28" width="64" height="28" rx="2" fill="#DDE6F5" />
57
60
  {/* Left arrow */}
58
- <line x1="15" y1="42" x2="28" y2="42" stroke="currentColor" strokeWidth="1.2" opacity="0.5" />
59
- <path d="M26 40l2 2-2 2" stroke="currentColor" strokeWidth="1.2" fill="none" opacity="0.5" />
61
+ <line x1="13" y1="42" x2="26" y2="42" stroke={ACCENT} strokeWidth="1.2" />
62
+ <path d="M24 40l2 2-2 2" stroke={ACCENT} strokeWidth="1.2" fill="none" />
60
63
  {/* Right arrow */}
61
- <line x1="92" y1="42" x2="105" y2="42" stroke="currentColor" strokeWidth="1.2" opacity="0.5" />
62
- <path d="M94 40l-2 2 2 2" stroke="currentColor" strokeWidth="1.2" fill="none" opacity="0.5" />
64
+ <line x1="94" y1="42" x2="107" y2="42" stroke={ACCENT} strokeWidth="1.2" />
65
+ <path d="M96 40l-2 2 2 2" stroke={ACCENT} strokeWidth="1.2" fill="none" />
63
66
  {/* Dashed boundary lines */}
64
- <line x1="28" y1="26" x2="28" y2="58" stroke="currentColor" strokeWidth="1" strokeDasharray="2 2" opacity="0.3" />
65
- <line x1="92" y1="26" x2="92" y2="58" stroke="currentColor" strokeWidth="1" strokeDasharray="2 2" opacity="0.3" />
67
+ <line x1="28" y1="26" x2="28" y2="58" stroke={ACCENT} strokeWidth="1" strokeDasharray="2 2" />
68
+ <line x1="92" y1="26" x2="92" y2="58" stroke={ACCENT} strokeWidth="1" strokeDasharray="2 2" />
66
69
  </svg>
67
70
  );
68
71
  }
69
72
 
70
73
  /** SVG illustration for the Scroll Animations card */
71
74
  function ScrollAnimIcon() {
75
+ // 3 blue layers with increasing opacity convey the fade-in effect; accent arrow below.
76
+ const ACCENT = "#4794E2";
72
77
  return (
73
- <svg viewBox="0 0 120 72" fill="none" className="w-full h-full" style={{ color: "#b0b5bd" }}>
74
- {/* 3 stacked layers with increasing opacity (fade-in effect) */}
75
- <rect x="30" y="6" width="60" height="14" rx="3" fill="currentColor" opacity="0.06" stroke="currentColor" strokeWidth="0.8" />
76
- <rect x="30" y="24" width="60" height="14" rx="3" fill="currentColor" opacity="0.14" stroke="currentColor" strokeWidth="0.8" />
77
- <rect x="30" y="42" width="60" height="14" rx="3" fill="currentColor" opacity="0.28" stroke="currentColor" strokeWidth="0.8" />
78
+ <svg viewBox="0 0 120 72" fill="none" className="w-full h-full">
79
+ {/* 3 stacked layers (fade-in effect) */}
80
+ <rect x="30" y="6" width="60" height="14" rx="3" fill={ACCENT} opacity="0.06" />
81
+ <rect x="30" y="24" width="60" height="14" rx="3" fill={ACCENT} opacity="0.13" />
82
+ <rect x="30" y="42" width="60" height="14" rx="3" fill={ACCENT} opacity="0.36" />
78
83
  {/* Scroll-down arrow */}
79
- <line x1="60" y1="60" x2="60" y2="70" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
80
- <path d="M56 67l4 4 4-4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
81
- {/* Motion lines */}
82
- <line x1="24" y1="46" x2="28" y2="49" stroke="currentColor" strokeWidth="1" opacity="0.3" />
83
- <line x1="24" y1="52" x2="28" y2="49" stroke="currentColor" strokeWidth="1" opacity="0.3" />
84
- <line x1="92" y1="46" x2="96" y2="49" stroke="currentColor" strokeWidth="1" opacity="0.3" />
85
- <line x1="92" y1="52" x2="96" y2="49" stroke="currentColor" strokeWidth="1" opacity="0.3" />
84
+ <line x1="60" y1="60" x2="60" y2="70" stroke={ACCENT} strokeWidth="1.5" />
85
+ <path d="M56 67l4 4 4-4" stroke={ACCENT} strokeWidth="1.5" fill="none" />
86
86
  </svg>
87
87
  );
88
88
  }
@@ -24,6 +24,7 @@ import VideoBlockRenderer from "./VideoBlockRenderer";
24
24
  import SpacerBlockRenderer from "./SpacerBlockRenderer";
25
25
  import ButtonBlockRenderer from "./ButtonBlockRenderer";
26
26
  import ProjectGridBlockRenderer from "./ProjectGridBlockRenderer";
27
+ import ProjectCarouselBlockRenderer from "./ProjectCarouselBlockRenderer";
27
28
 
28
29
  // ── BLK-003: Error Boundary for block renderers ──
29
30
  // Prevents a single broken block from crashing the entire page.
@@ -310,6 +311,9 @@ export default function BlockRenderer({
310
311
  case "projectGridBlock":
311
312
  content = <ProjectGridBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectGridBlock} />;
312
313
  break;
314
+ case "projectCarouselBlock":
315
+ content = <ProjectCarouselBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectCarouselBlock} />;
316
+ break;
313
317
  default: {
314
318
  const unknownBlock = resolved as ContentBlock;
315
319
  if (process.env.NODE_ENV === "development") {
@@ -418,13 +422,22 @@ export default function BlockRenderer({
418
422
  const isShader = isShaderPreset(blockHoverEffect.preset);
419
423
  if (isShader) {
420
424
  if (resolved._type === "imageBlock") {
421
- shaderSrc = resolveAsset((resolved as import("../../lib/sanity/types").ImageBlock).asset_path);
425
+ const imgPath = (resolved as import("../../lib/sanity/types").ImageBlock).asset_path;
426
+ if (imgPath) {
427
+ shaderSrc = resolveAsset(imgPath);
428
+ }
422
429
  const br = (resolved as import("../../lib/sanity/types").ImageBlock).border_radius;
423
430
  if (br) shaderBorderRadius = `${String(br).replace(/px$/i, "")}px`;
424
431
  }
425
432
  // Shader preset without image src: skip wrapper entirely
426
433
  if (!shaderSrc) {
427
- // No-op: can't apply shader without an image source
434
+ // Warn in development so the missing asset is debuggable — silent in prod
435
+ if (process.env.NODE_ENV === "development") {
436
+ // eslint-disable-next-line no-console
437
+ console.warn(
438
+ `[BlockRenderer] Shader hover preset "${blockHoverEffect.preset}" on block ${block._key} (${resolved._type}) has no asset_path — falling back to no hover effect.`
439
+ );
440
+ }
428
441
  } else {
429
442
  content = (
430
443
  <HoverAnimationWrapper
@@ -10,6 +10,7 @@
10
10
  * Session 176: Cover Sections — Phase 9 (Public Renderer).
11
11
  */
12
12
 
13
+ import { useEffect, useRef } from "react";
13
14
  import type {
14
15
  CoverSection,
15
16
  SectionColumn,
@@ -23,6 +24,13 @@ import EnterAnimationWrapper from "./EnterAnimationWrapper";
23
24
  import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign } from "../../lib/builder/layout-styles";
24
25
  import { assetUrl } from "../../lib/assets";
25
26
  import { BREAKPOINTS } from "../../lib/builder/constants";
27
+ import { normalizeRowHeights } from "../../lib/builder/store-cover";
28
+ import { useNavColor } from "../../lib/contexts/NavColorContext";
29
+ import { isValidHex } from "../../lib/color-utils";
30
+ import {
31
+ sectionVisibilityRatio,
32
+ NAV_COLOR_OVERRIDE_THRESHOLD,
33
+ } from "../../lib/builder/section-visibility";
26
34
 
27
35
  interface CoverSectionRendererProps {
28
36
  section: CoverSection;
@@ -51,10 +59,19 @@ function buildCoverResponsiveCss(section: CoverSection): string | null {
51
59
 
52
60
  if (override.cover_rows && override.cover_rows.length > 0) {
53
61
  const baseRows = section.cover_rows;
54
- const rowTemplate = baseRows.map((r, i) => {
62
+ // Partial overrides mix desktop values with override values — the
63
+ // merged sum is not guaranteed to be 100. Normalize so the CSS
64
+ // grid-template-rows at that viewport stays valid. (Cover Sections
65
+ // audit bug #3.)
66
+ const mergedPercents = baseRows.map((r) => {
55
67
  const rowOverride = override.cover_rows?.find((o) => o._key === r._key);
56
- return `${rowOverride?.height_percent ?? r.height_percent}%`;
57
- }).join(" ");
68
+ return rowOverride?.height_percent ?? r.height_percent;
69
+ });
70
+ const total = mergedPercents.reduce((a, b) => a + b, 0);
71
+ const finalPercents = Math.abs(total - 100) <= 0.5
72
+ ? mergedPercents
73
+ : normalizeRowHeights(mergedPercents);
74
+ const rowTemplate = finalPercents.map((p) => `${p}%`).join(" ");
58
75
  rules.push(`grid-template-rows:${rowTemplate}!important`);
59
76
  }
60
77
 
@@ -87,6 +104,60 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
87
104
  const colGap = s.col_gap ?? 20;
88
105
  const rowGap = s.row_gap ?? 20;
89
106
 
107
+ // ── Navbar color override ────────────────────────────────────────────
108
+ // While this cover section is significantly on-screen (>30% of the
109
+ // viewport), push `nav_color` up through NavColorContext so the top
110
+ // nav text adopts it. When the next section takes over (or the cover
111
+ // scrolls off screen), clear the override so the page default returns.
112
+ const sectionRef = useRef<HTMLElement | null>(null);
113
+ const { setNavColor } = useNavColor();
114
+ const navColor = section.nav_color || "";
115
+
116
+ useEffect(() => {
117
+ const hasValidNavColor = navColor && isValidHex(navColor);
118
+ if (!hasValidNavColor) return;
119
+
120
+ // rAF-throttled scroll listener — cheap: one getBoundingClientRect per tick.
121
+ let ticking = false;
122
+ let rafId: number | null = null;
123
+ let overrideActive = false;
124
+
125
+ const update = () => {
126
+ ticking = false;
127
+ const el = sectionRef.current;
128
+ if (!el) return;
129
+ const rect = el.getBoundingClientRect();
130
+ const ratio = sectionVisibilityRatio(rect.top, rect.bottom, window.innerHeight);
131
+ if (ratio > NAV_COLOR_OVERRIDE_THRESHOLD) {
132
+ if (!overrideActive) overrideActive = true;
133
+ setNavColor(navColor);
134
+ } else if (overrideActive) {
135
+ overrideActive = false;
136
+ setNavColor("");
137
+ }
138
+ };
139
+
140
+ const onScroll = () => {
141
+ if (ticking) return;
142
+ ticking = true;
143
+ rafId = requestAnimationFrame(update);
144
+ };
145
+
146
+ // Initial evaluation (e.g. cover is already the first section on mount)
147
+ update();
148
+ window.addEventListener("scroll", onScroll, { passive: true });
149
+ window.addEventListener("resize", onScroll, { passive: true });
150
+
151
+ return () => {
152
+ window.removeEventListener("scroll", onScroll);
153
+ window.removeEventListener("resize", onScroll);
154
+ if (rafId !== null) cancelAnimationFrame(rafId);
155
+ // Release any override we set so the next section's nav color (or the
156
+ // page default) takes over cleanly.
157
+ if (overrideActive) setNavColor("");
158
+ };
159
+ }, [navColor, setNavColor]);
160
+
90
161
  const rowTemplate = section.cover_rows
91
162
  .map((r) => `${r.height_percent}%`)
92
163
  .join(" ");
@@ -132,6 +203,7 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
132
203
 
133
204
  const sectionContent = (
134
205
  <section
206
+ ref={sectionRef}
135
207
  style={{
136
208
  position: "relative",
137
209
  height: section.height,
@@ -455,6 +455,20 @@ export default function ImageGridBlockRenderer({
455
455
  // Unique ID for phone responsive override — must be before early return (hooks rule)
456
456
  const gridId = useRef(`ig-${Math.random().toString(36).slice(2, 8)}`).current;
457
457
 
458
+ // Stable lightbox callbacks — inline versions rebuild every render and
459
+ // caused the lightbox keydown listener to add/remove on every parent render.
460
+ // Functional setState keeps them closure-safe regardless of prop changes.
461
+ const imagesLen = block.images?.length ?? 0;
462
+ const handleLightboxClose = useCallback(() => setLightboxIndex(null), []);
463
+ const handleLightboxPrev = useCallback(() => {
464
+ if (imagesLen <= 0) return;
465
+ setLightboxIndex((prev) => (prev !== null ? (prev - 1 + imagesLen) % imagesLen : 0));
466
+ }, [imagesLen]);
467
+ const handleLightboxNext = useCallback(() => {
468
+ if (imagesLen <= 0) return;
469
+ setLightboxIndex((prev) => (prev !== null ? (prev + 1) % imagesLen : 0));
470
+ }, [imagesLen]);
471
+
458
472
  // Block is already resolved for the current viewport by BlockRenderer
459
473
  if (!block.images?.length) return null;
460
474
 
@@ -534,17 +548,9 @@ export default function ImageGridBlockRenderer({
534
548
  images={images}
535
549
  currentIndex={lightboxIndex}
536
550
  resolveAsset={resolveAsset}
537
- onClose={() => setLightboxIndex(null)}
538
- onPrev={() =>
539
- setLightboxIndex((prev) =>
540
- prev !== null ? (prev - 1 + images.length) % images.length : 0
541
- )
542
- }
543
- onNext={() =>
544
- setLightboxIndex((prev) =>
545
- prev !== null ? (prev + 1) % images.length : 0
546
- )
547
- }
551
+ onClose={handleLightboxClose}
552
+ onPrev={handleLightboxPrev}
553
+ onNext={handleLightboxNext}
548
554
  />
549
555
  )}
550
556
  </>
@@ -109,6 +109,45 @@ export default function ParallaxGroupRenderer({
109
109
 
110
110
  // ── Nav color interpolation helper ──
111
111
 
112
+ /**
113
+ * Cache the per-slide {center, color} array. Building it reads offsetTop /
114
+ * offsetHeight for every slide — a layout read that's cheap on its own but
115
+ * was previously done on every rAF tick inside the scroll loop, adding up
116
+ * to measurable jank on groups with 10+ slides + nav color interpolation.
117
+ * The cache is invalidated on resize and when `group.slides` changes.
118
+ */
119
+ const slideDataCacheRef = useRef<{ center: number; color: string }[] | null>(null);
120
+ const slideDataDirtyRef = useRef(true);
121
+
122
+ const buildSlideData = useCallback((): { center: number; color: string }[] => {
123
+ const slideData: { center: number; color: string }[] = [];
124
+ const orderedSlides = group.slides || [];
125
+ for (let i = 0; i < orderedSlides.length; i++) {
126
+ const refs = slideRefsMap.current.get(orderedSlides[i]._key);
127
+ if (!refs?.slide) continue;
128
+ const el = refs.slide;
129
+ slideData.push({
130
+ center: el.offsetTop + el.offsetHeight / 2,
131
+ color: slideNavColors[i] || "",
132
+ });
133
+ }
134
+ return slideData;
135
+ }, [group.slides, slideNavColors]);
136
+
137
+ // Mark cache dirty when slides array or colors change
138
+ useEffect(() => {
139
+ slideDataDirtyRef.current = true;
140
+ }, [group.slides, slideNavColors]);
141
+
142
+ // Invalidate cache on resize (layout shifts)
143
+ useEffect(() => {
144
+ const onResize = () => {
145
+ slideDataDirtyRef.current = true;
146
+ };
147
+ window.addEventListener("resize", onResize, { passive: true });
148
+ return () => window.removeEventListener("resize", onResize);
149
+ }, []);
150
+
112
151
  /**
113
152
  * Compute the interpolated navbar color based on current scroll position.
114
153
  * Uses slide centers: the viewport center's position between two adjacent
@@ -124,16 +163,12 @@ export default function ParallaxGroupRenderer({
124
163
 
125
164
  const viewCenter = scrollY + vh / 2;
126
165
 
127
- // Build ordered array of {center, color} for each slide
128
- const slideData: { center: number; color: string }[] = [];
129
- const orderedSlides = group.slides || [];
130
- for (let i = 0; i < orderedSlides.length; i++) {
131
- const refs = slideRefsMap.current.get(orderedSlides[i]._key);
132
- if (!refs?.slide) continue;
133
- const el = refs.slide;
134
- const center = el.offsetTop + el.offsetHeight / 2;
135
- slideData.push({ center, color: slideNavColors[i] || "" });
166
+ // Build ordered array of {center, color} for each slide — from cache when fresh
167
+ if (slideDataDirtyRef.current || !slideDataCacheRef.current) {
168
+ slideDataCacheRef.current = buildSlideData();
169
+ slideDataDirtyRef.current = false;
136
170
  }
171
+ const slideData = slideDataCacheRef.current;
137
172
 
138
173
  if (slideData.length === 0) return null;
139
174
 
@@ -170,7 +205,7 @@ export default function ParallaxGroupRenderer({
170
205
  }
171
206
 
172
207
  return null;
173
- }, [slideNavColors, group.slides]);
208
+ }, [slideNavColors, group.slides, buildSlideData]);
174
209
 
175
210
  // ── Capture page-level nav color on mount, restore on unmount ──
176
211