@morphika/andami 0.2.12 → 0.2.14

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 (58) hide show
  1. package/README.md +2 -1
  2. package/app/admin/pages/[slug]/page.tsx +39 -2
  3. package/components/blocks/BlockRenderer.tsx +0 -7
  4. package/components/blocks/CoverSectionRenderer.tsx +295 -0
  5. package/components/blocks/ImageBlockRenderer.tsx +12 -10
  6. package/components/blocks/PageRenderer.tsx +13 -9
  7. package/components/blocks/VideoBlockRenderer.tsx +11 -6
  8. package/components/builder/BlockLivePreview.tsx +0 -5
  9. package/components/builder/BlockTypePicker.tsx +0 -1
  10. package/components/builder/ColorSwatchPicker.tsx +2 -2
  11. package/components/builder/CoverRowResizeHandle.tsx +180 -0
  12. package/components/builder/CoverSectionCanvas.tsx +260 -0
  13. package/components/builder/ReadOnlyFrame.tsx +127 -3
  14. package/components/builder/SectionTypePicker.tsx +29 -0
  15. package/components/builder/SectionV2Canvas.tsx +4 -1
  16. package/components/builder/SectionV2Column.tsx +15 -20
  17. package/components/builder/SettingsPanel.tsx +14 -0
  18. package/components/builder/SortableRow.tsx +7 -21
  19. package/components/builder/blockStyles.tsx +13 -14
  20. package/components/builder/editors/ImageBlockEditor.tsx +1 -0
  21. package/components/builder/editors/VideoBlockEditor.tsx +1 -0
  22. package/components/builder/editors/index.ts +0 -1
  23. package/components/builder/index.ts +1 -0
  24. package/components/builder/live-preview/LiveImagePreview.tsx +21 -2
  25. package/components/builder/live-preview/LiveVideoPreview.tsx +8 -3
  26. package/components/builder/live-preview/RichTextEditor.tsx +23 -2
  27. package/components/builder/live-preview/index.ts +0 -1
  28. package/components/builder/settings-panel/BlockSettings.tsx +0 -7
  29. package/components/builder/settings-panel/CoverSectionSettings.tsx +296 -0
  30. package/components/builder/settings-panel/index.ts +1 -0
  31. package/components/builder/settings-panel/useSettingsPanelSelection.ts +36 -2
  32. package/lib/animation/enter-types.ts +0 -1
  33. package/lib/animation/hover-effect-types.ts +0 -1
  34. package/lib/builder/defaults.ts +43 -22
  35. package/lib/builder/serializer/normalizers.ts +34 -1
  36. package/lib/builder/serializer/serializers.ts +39 -2
  37. package/lib/builder/store-blocks.ts +11 -3
  38. package/lib/builder/store-cover.ts +220 -0
  39. package/lib/builder/store-helpers.ts +81 -4
  40. package/lib/builder/store-sections.ts +12 -2
  41. package/lib/builder/store.ts +11 -2
  42. package/lib/builder/types.ts +15 -2
  43. package/lib/sanity/queries.ts +18 -4
  44. package/lib/sanity/types.ts +81 -45
  45. package/lib/version.ts +1 -1
  46. package/package.json +1 -1
  47. package/sanity/schemas/blocks/imageBlock.ts +1 -0
  48. package/sanity/schemas/blocks/index.ts +1 -2
  49. package/sanity/schemas/blocks/videoBlock.ts +1 -0
  50. package/sanity/schemas/index.ts +5 -3
  51. package/sanity/schemas/objects/coverSection.ts +317 -0
  52. package/sanity/schemas/objects/parallaxSlide.ts +0 -1
  53. package/sanity/schemas/page.ts +1 -1
  54. package/sanity/schemas/pageSectionV2.ts +0 -1
  55. package/components/blocks/CoverBlockRenderer.tsx +0 -261
  56. package/components/builder/editors/CoverBlockEditor.tsx +0 -550
  57. package/components/builder/live-preview/LiveCoverPreview.tsx +0 -146
  58. package/sanity/schemas/blocks/coverBlock.ts +0 -229
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef } from "react";
4
+ import { useBuilderStore } from "../../lib/builder/store";
5
+
6
+ /**
7
+ * CoverRowResizeHandle — horizontal drag handle between two adjacent cover rows.
8
+ *
9
+ * 3-state visual matching the column resize handles in SectionV2Column:
10
+ * - Idle: invisible (transparent hit area only)
11
+ * - Hover: teal pill line appears
12
+ * - Dragging: full-width line with glow + center dot
13
+ *
14
+ * Session 176: Cover Sections — Phase 5.
15
+ */
16
+
17
+ interface CoverRowResizeHandleProps {
18
+ sectionKey: string;
19
+ handleIndex: number;
20
+ abovePercent: number;
21
+ belowPercent: number;
22
+ containerHeight: number;
23
+ isSectionHovered: boolean;
24
+ }
25
+
26
+ const HANDLE_COLOR = "#0d9488";
27
+
28
+ export default function CoverRowResizeHandle({
29
+ sectionKey,
30
+ handleIndex,
31
+ abovePercent,
32
+ belowPercent,
33
+ containerHeight,
34
+ isSectionHovered,
35
+ }: CoverRowResizeHandleProps) {
36
+ const resizeCoverRow = useBuilderStore((s) => s.resizeCoverRow);
37
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
38
+
39
+ const [isHovered, setIsHovered] = useState(false);
40
+ const [isDragging, setIsDragging] = useState(false);
41
+
42
+ const startYRef = useRef(0);
43
+ const startAboveRef = useRef(0);
44
+ const startBelowRef = useRef(0);
45
+ const snapshotPushedRef = useRef(false);
46
+
47
+ const handleMouseDown = useCallback(
48
+ (e: React.MouseEvent) => {
49
+ e.stopPropagation();
50
+ e.preventDefault();
51
+
52
+ startYRef.current = e.clientY;
53
+ startAboveRef.current = abovePercent;
54
+ startBelowRef.current = belowPercent;
55
+ snapshotPushedRef.current = false;
56
+ setIsDragging(true);
57
+
58
+ const onMouseMove = (moveEvent: MouseEvent) => {
59
+ if (!snapshotPushedRef.current) {
60
+ _pushSnapshot();
61
+ snapshotPushedRef.current = true;
62
+ }
63
+ const deltaY = moveEvent.clientY - startYRef.current;
64
+ const deltaPercent = (deltaY / containerHeight) * 100;
65
+ resizeCoverRow(sectionKey, handleIndex, deltaPercent, startAboveRef.current, startBelowRef.current);
66
+ };
67
+
68
+ const onMouseUp = () => {
69
+ document.removeEventListener("mousemove", onMouseMove);
70
+ document.removeEventListener("mouseup", onMouseUp);
71
+ setIsDragging(false);
72
+ };
73
+
74
+ document.addEventListener("mousemove", onMouseMove);
75
+ document.addEventListener("mouseup", onMouseUp);
76
+ },
77
+ [sectionKey, handleIndex, containerHeight, resizeCoverRow, _pushSnapshot]
78
+ );
79
+
80
+ const isActive = isDragging;
81
+ const isVisible = isDragging || isHovered || isSectionHovered;
82
+
83
+ return (
84
+ <div
85
+ role="separator"
86
+ aria-orientation="horizontal"
87
+ aria-label={`Resize rows ${handleIndex + 1} and ${handleIndex + 2}`}
88
+ className="absolute left-0 right-0 z-10 flex items-center justify-center"
89
+ style={{
90
+ height: 20,
91
+ marginTop: -10,
92
+ cursor: "row-resize",
93
+ bottom: 0,
94
+ opacity: isVisible ? 1 : 0,
95
+ pointerEvents: isVisible ? "auto" : "none",
96
+ transition: isDragging ? "none" : "opacity 150ms ease-out",
97
+ }}
98
+ onMouseEnter={() => setIsHovered(true)}
99
+ onMouseLeave={() => { if (!isDragging) setIsHovered(false); }}
100
+ onMouseDown={handleMouseDown}
101
+ >
102
+ {/* Line */}
103
+ <div
104
+ className="pointer-events-none absolute left-0 right-0 flex items-center justify-center"
105
+ style={{ height: "100%" }}
106
+ >
107
+ <div
108
+ style={{
109
+ width: isActive ? "100%" : isHovered ? "80%" : "50%",
110
+ height: isActive ? 3 : isHovered ? 2 : 1,
111
+ borderRadius: 999,
112
+ backgroundColor: isActive
113
+ ? HANDLE_COLOR
114
+ : isHovered
115
+ ? `${HANDLE_COLOR}bb`
116
+ : `${HANDLE_COLOR}40`,
117
+ transition: isDragging ? "none" : "all 150ms ease-out",
118
+ boxShadow: isActive ? `0 0 8px ${HANDLE_COLOR}50` : undefined,
119
+ }}
120
+ />
121
+ </div>
122
+
123
+ {/* Center pill/dot */}
124
+ <div
125
+ className="pointer-events-none relative"
126
+ style={{
127
+ width: isActive ? 16 : isHovered ? 10 : 4,
128
+ height: isActive ? 16 : isHovered ? 10 : 4,
129
+ borderRadius: "50%",
130
+ backgroundColor: isActive
131
+ ? HANDLE_COLOR
132
+ : isHovered
133
+ ? `${HANDLE_COLOR}aa`
134
+ : `${HANDLE_COLOR}30`,
135
+ transition: isDragging ? "none" : "all 150ms ease-out",
136
+ boxShadow: isActive ? `0 0 10px ${HANDLE_COLOR}50` : undefined,
137
+ zIndex: 1,
138
+ }}
139
+ />
140
+
141
+ {/* Percentage labels during drag */}
142
+ {isDragging && (
143
+ <>
144
+ <div
145
+ className="absolute pointer-events-none"
146
+ style={{
147
+ top: -16,
148
+ left: "50%",
149
+ transform: "translateX(-50%)",
150
+ fontSize: 9,
151
+ fontWeight: 700,
152
+ color: HANDLE_COLOR,
153
+ background: "rgba(255,255,255,0.9)",
154
+ padding: "1px 5px",
155
+ borderRadius: 4,
156
+ }}
157
+ >
158
+ {Math.round(abovePercent)}%
159
+ </div>
160
+ <div
161
+ className="absolute pointer-events-none"
162
+ style={{
163
+ bottom: -16,
164
+ left: "50%",
165
+ transform: "translateX(-50%)",
166
+ fontSize: 9,
167
+ fontWeight: 700,
168
+ color: HANDLE_COLOR,
169
+ background: "rgba(255,255,255,0.9)",
170
+ padding: "1px 5px",
171
+ borderRadius: 4,
172
+ }}
173
+ >
174
+ {Math.round(belowPercent)}%
175
+ </div>
176
+ </>
177
+ )}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,260 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useBuilderStore } from "../../lib/builder/store";
5
+ import type { CoverSection, CoverRow, PageSectionV2 } from "../../lib/sanity/types";
6
+ import type { DeviceViewport } from "../../lib/builder/types";
7
+ import SectionV2Canvas from "./SectionV2Canvas";
8
+ import CoverRowResizeHandle from "./CoverRowResizeHandle";
9
+ import { DEVICE_HEIGHTS } from "../../lib/builder/types";
10
+ import { useAssetUrl } from "../../lib/contexts/AssetContext";
11
+
12
+ /**
13
+ * CoverSectionCanvas — renders a CoverSection in the builder canvas.
14
+ *
15
+ * Displays proportional rows (based on cover_rows height_percent) inside
16
+ * a fixed-height container that simulates the section's vh height.
17
+ * Each row contains a SectionV2Canvas (full V2 grid editor reuse) via
18
+ * a virtual PageSectionV2 scoped to that row's columns.
19
+ *
20
+ * Background image/video is shown as a faint preview behind all rows.
21
+ * Row resize handles are rendered between adjacent rows (Phase 5).
22
+ *
23
+ * Session 176: Cover Sections — Phase 4 (Builder Canvas).
24
+ */
25
+
26
+ interface CoverSectionCanvasProps {
27
+ section: CoverSection;
28
+ onAddBlockTarget: (sectionKey: string, colKey: string, insertIndex?: number) => void;
29
+ }
30
+
31
+ const COVER_ACCENT = "#0d9488";
32
+
33
+ function getEffectiveCoverRows(section: CoverSection, viewport: DeviceViewport): CoverRow[] {
34
+ if (viewport === "desktop") return section.cover_rows;
35
+ const vp = viewport as "tablet" | "phone";
36
+ const overrides = section.responsive?.[vp]?.cover_rows;
37
+ if (!overrides || overrides.length === 0) return section.cover_rows;
38
+ return section.cover_rows.map((row) => {
39
+ const override = overrides.find((o) => o._key === row._key);
40
+ if (override?.height_percent !== undefined) {
41
+ return { ...row, height_percent: override.height_percent };
42
+ }
43
+ return row;
44
+ });
45
+ }
46
+
47
+ export default function CoverSectionCanvas({
48
+ section,
49
+ onAddBlockTarget,
50
+ }: CoverSectionCanvasProps) {
51
+ const store = useBuilderStore();
52
+ const activeViewport = store.activeViewport || "desktop";
53
+ const previewMode = store.previewMode;
54
+ const assetUrl = useAssetUrl();
55
+ const [isSectionHovered, setIsSectionHovered] = useState(false);
56
+
57
+ const vhPixels = DEVICE_HEIGHTS[activeViewport];
58
+ const heightMultiplier = (parseInt(section.height || "100", 10) || 100) / 100;
59
+ const containerHeight = Math.round(vhPixels * heightMultiplier);
60
+
61
+ const bgImageUrl = section.background_type === "image" && section.background_image
62
+ ? assetUrl(section.background_image)
63
+ : null;
64
+
65
+ const effectiveRows = useMemo(
66
+ () => getEffectiveCoverRows(section, activeViewport),
67
+ [section, activeViewport]
68
+ );
69
+
70
+ const virtualSectionsPerRow = useMemo(() => {
71
+ return effectiveRows.map((row, rowIndex) => {
72
+ const rowNumber = rowIndex + 1;
73
+ const rowColumns = section.columns
74
+ .filter((c) => c.grid_row === rowNumber)
75
+ .map((c) => ({ ...c, grid_row: 1 }));
76
+ const virtualSection: PageSectionV2 = {
77
+ _type: "pageSectionV2",
78
+ _key: section._key,
79
+ section_type: "empty-v2",
80
+ columns: rowColumns,
81
+ settings: {
82
+ preset: "custom",
83
+ grid_columns: section.settings.grid_columns || 12,
84
+ col_gap: section.settings.col_gap ?? 20,
85
+ row_gap: section.settings.row_gap ?? 20,
86
+ },
87
+ };
88
+ return { row, rowNumber, virtualSection };
89
+ });
90
+ }, [effectiveRows, section]);
91
+
92
+ return (
93
+ <div
94
+ className="relative"
95
+ style={{
96
+ borderRadius: 12,
97
+ border: `1.5px solid ${COVER_ACCENT}40`,
98
+ overflow: "visible",
99
+ }}
100
+ onMouseEnter={() => setIsSectionHovered(true)}
101
+ onMouseLeave={() => setIsSectionHovered(false)}
102
+ >
103
+ {/* Header bar */}
104
+ <div
105
+ className="flex items-center gap-2 px-3 py-2 cursor-pointer"
106
+ style={{
107
+ background: store.selectedRowKey === section._key
108
+ ? `linear-gradient(135deg, #ccfbf1 0%, #b2f5ea 100%)`
109
+ : `linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)`,
110
+ borderBottom: `1px solid ${COVER_ACCENT}25`,
111
+ borderRadius: "12px 12px 0 0",
112
+ }}
113
+ onClick={(e) => {
114
+ e.stopPropagation();
115
+ store.selectRow(section._key);
116
+ }}
117
+ >
118
+ <span className="text-[11px] font-semibold" style={{ color: COVER_ACCENT }}>
119
+ ◆ Cover Section
120
+ </span>
121
+ <span
122
+ className="inline-flex items-center justify-center rounded-full text-[9px] font-bold text-white min-w-[18px] h-[18px] px-1"
123
+ style={{ background: COVER_ACCENT }}
124
+ >
125
+ {section.cover_rows.length}
126
+ </span>
127
+ <div className="flex-1" />
128
+ <span className="text-[9px] text-neutral-400 uppercase tracking-wider">
129
+ {section.height}
130
+ </span>
131
+ </div>
132
+
133
+ {/* Cover container — simulated viewport height */}
134
+ <div
135
+ className="relative"
136
+ style={{ height: containerHeight }}
137
+ >
138
+ {/* Background preview — clipped to container bounds */}
139
+ {bgImageUrl && (
140
+ <div
141
+ className="absolute inset-0 pointer-events-none"
142
+ style={{
143
+ backgroundImage: `url(${bgImageUrl})`,
144
+ backgroundSize: section.background_size || "cover",
145
+ backgroundPosition: section.background_position || "center center",
146
+ opacity: 0.15,
147
+ overflow: "hidden",
148
+ }}
149
+ />
150
+ )}
151
+
152
+ {/* Overlay preview */}
153
+ {(section.background_overlay_opacity ?? 0) > 0 && (
154
+ <div
155
+ className="absolute inset-0 pointer-events-none"
156
+ style={{
157
+ backgroundColor: section.background_overlay_color || "#000000",
158
+ opacity: (section.background_overlay_opacity || 0) / 100 * 0.3,
159
+ overflow: "hidden",
160
+ }}
161
+ />
162
+ )}
163
+
164
+ {/* Proportional rows */}
165
+ <div
166
+ className="relative flex flex-col h-full"
167
+ style={{ zIndex: 1 }}
168
+ >
169
+ {virtualSectionsPerRow.map(({ row, rowNumber, virtualSection }, rowIndex) => {
170
+ const alignMap = { start: "flex-start", center: "center", end: "flex-end" };
171
+ const isLastRow = rowIndex === effectiveRows.length - 1;
172
+ const hasColumns = virtualSection.columns.length > 0;
173
+
174
+ return (
175
+ <div
176
+ key={row._key}
177
+ className="relative"
178
+ style={{
179
+ height: `${row.height_percent}%`,
180
+ minHeight: 0,
181
+ display: "flex",
182
+ flexDirection: "column",
183
+ justifyContent: alignMap[row.vertical_align] || "flex-start",
184
+ }}
185
+ >
186
+ {/* Row label */}
187
+ <div
188
+ className="absolute top-1 right-2 pointer-events-none z-10"
189
+ style={{
190
+ fontSize: 9,
191
+ color: `${COVER_ACCENT}99`,
192
+ fontWeight: 600,
193
+ }}
194
+ >
195
+ Row {rowNumber} · {row.height_percent}%
196
+ {row.vertical_align !== "start" && ` · ${row.vertical_align}`}
197
+ </div>
198
+
199
+ {hasColumns ? (
200
+ /* V2 grid for this row's columns — overflow hidden clips content to row bounds */
201
+ <div className="flex-1 min-h-0 flex flex-col" style={{ overflow: "hidden" }}>
202
+ <SectionV2Canvas
203
+ section={virtualSection}
204
+ onAddBlockTarget={onAddBlockTarget}
205
+ fillHeight
206
+ gridRowOffset={rowNumber - 1}
207
+ />
208
+ </div>
209
+ ) : (
210
+ /* Empty row: direct + Add Column button with correct gridRow */
211
+ <div
212
+ className="flex-1 min-h-0 flex items-center justify-center"
213
+ style={{
214
+ border: isSectionHovered ? `1.5px dashed ${COVER_ACCENT}30` : "1.5px dashed transparent",
215
+ borderRadius: 6,
216
+ margin: 4,
217
+ transition: "border-color 150ms",
218
+ }}
219
+ >
220
+ <button
221
+ onClick={(e) => {
222
+ e.stopPropagation();
223
+ store.addColumnV2(section._key, rowNumber, 1, section.settings.grid_columns || 12);
224
+ }}
225
+ className="rounded-full text-[10px] font-medium transition-all hover:scale-105"
226
+ style={{
227
+ padding: "5px 16px",
228
+ background: `rgba(7, 107, 255, 0.10)`,
229
+ color: "#076bff",
230
+ border: "1px dashed rgba(7, 107, 255, 0.4)",
231
+ opacity: isSectionHovered ? 1 : 0,
232
+ pointerEvents: isSectionHovered ? "auto" : "none",
233
+ transition: "opacity 150ms",
234
+ }}
235
+ >
236
+ + Add Column
237
+ </button>
238
+ </div>
239
+ )}
240
+
241
+ {/* Resize handle between this row and the next */}
242
+ {!isLastRow && (
243
+ <CoverRowResizeHandle
244
+ sectionKey={section._key}
245
+ handleIndex={rowIndex}
246
+ abovePercent={row.height_percent}
247
+ belowPercent={effectiveRows[rowIndex + 1]?.height_percent ?? 0}
248
+ containerHeight={containerHeight}
249
+ isSectionHovered={isSectionHovered}
250
+ />
251
+ )}
252
+ </div>
253
+ );
254
+ })}
255
+
256
+ </div>
257
+ </div>
258
+ </div>
259
+ );
260
+ }
@@ -19,8 +19,8 @@
19
19
  import { memo, useMemo, useState, useEffect } from "react";
20
20
  import { useBuilderStore } from "../../lib/builder/store";
21
21
  import type { DeviceViewport } from "../../lib/builder/types";
22
- import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup, ParallaxSlideV2 } from "../../lib/sanity/types";
23
- import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
22
+ import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup, ParallaxSlideV2, CoverSection } from "../../lib/sanity/types";
23
+ import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
24
24
  import { DEVICE_HEIGHTS } from "../../lib/builder/types";
25
25
  import { getEffectiveColumnsV2, getSectionV2SettingValue } from "./settings-panel/responsive-helpers";
26
26
  import BlockLivePreview from "./BlockLivePreview";
@@ -331,6 +331,124 @@ const ReadOnlyCustomSection = memo(function ReadOnlyCustomSection({
331
331
  // ReadOnlyFrame — orchestrator
332
332
  // ============================================
333
333
 
334
+ // ============================================
335
+ // Read-only cover section — renders proportional rows with background
336
+ // ============================================
337
+
338
+ interface ReadOnlyCoverSectionProps {
339
+ section: CoverSection;
340
+ viewport: DeviceViewport;
341
+ }
342
+
343
+ const ReadOnlyCoverSection = memo(function ReadOnlyCoverSection({
344
+ section,
345
+ viewport,
346
+ }: ReadOnlyCoverSectionProps) {
347
+ const deviceHeight = DEVICE_HEIGHTS[viewport];
348
+ const heightMultiplier = parseInt(section.height, 10) / 100;
349
+ const containerHeight = Math.round(deviceHeight * heightMultiplier);
350
+
351
+ const gridColumns = section.settings.grid_columns || 12;
352
+ const colGap = section.settings.col_gap ?? 20;
353
+ const rowGap = section.settings.row_gap ?? 20;
354
+
355
+ const effectiveRows = (() => {
356
+ if (viewport === "desktop") return section.cover_rows;
357
+ const vp = viewport as "tablet" | "phone";
358
+ const overrides = section.responsive?.[vp]?.cover_rows;
359
+ if (!overrides || overrides.length === 0) return section.cover_rows;
360
+ return section.cover_rows.map((row) => {
361
+ const o = overrides.find((ov) => ov._key === row._key);
362
+ return o?.height_percent !== undefined ? { ...row, height_percent: o.height_percent } : row;
363
+ });
364
+ })();
365
+
366
+ const rowTemplate = effectiveRows.map((r) => `${r.height_percent}%`).join(" ");
367
+
368
+ return (
369
+ <div
370
+ style={{
371
+ borderRadius: 8,
372
+ border: "1px solid rgba(13, 148, 136, 0.15)",
373
+ overflow: "hidden",
374
+ }}
375
+ >
376
+ <div
377
+ style={{
378
+ background: "linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)",
379
+ padding: "4px 8px",
380
+ borderBottom: "1px solid rgba(13, 148, 136, 0.1)",
381
+ }}
382
+ >
383
+ <span style={{ fontSize: 8, fontWeight: 600, color: "#0d9488" }}>
384
+ ◆ Cover · {section.height} · {section.cover_rows.length} row{section.cover_rows.length !== 1 ? "s" : ""}
385
+ </span>
386
+ </div>
387
+ <div style={{ position: "relative", height: containerHeight, overflow: "hidden" }}>
388
+ {section.background_type === "image" && section.background_image && (
389
+ <div
390
+ className="absolute inset-0 pointer-events-none"
391
+ style={{
392
+ backgroundImage: `url(/api/assets/${section.background_image})`,
393
+ backgroundSize: section.background_size || "cover",
394
+ backgroundPosition: section.background_position || "center center",
395
+ opacity: 0.15,
396
+ }}
397
+ />
398
+ )}
399
+ {(section.background_overlay_opacity ?? 0) > 0 && (
400
+ <div
401
+ className="absolute inset-0 pointer-events-none"
402
+ style={{
403
+ backgroundColor: section.background_overlay_color || "#000000",
404
+ opacity: (section.background_overlay_opacity || 0) / 100 * 0.3,
405
+ }}
406
+ />
407
+ )}
408
+ <div
409
+ style={{
410
+ position: "relative",
411
+ zIndex: 1,
412
+ display: "grid",
413
+ gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
414
+ gridTemplateRows: rowTemplate,
415
+ height: "100%",
416
+ columnGap: `${colGap}px`,
417
+ rowGap: `${rowGap}px`,
418
+ }}
419
+ >
420
+ {section.columns.map((col) => {
421
+ const rowAlign = effectiveRows[col.grid_row - 1]?.vertical_align || "start";
422
+ const alignSelf = rowAlign === "center" ? "center" : rowAlign === "end" ? "end" : "start";
423
+ const colJustify = getColumnVerticalAlign(col.blocks || []);
424
+ return (
425
+ <div
426
+ key={col._key}
427
+ style={{
428
+ gridColumn: `${col.grid_column} / span ${col.span}`,
429
+ gridRow: col.grid_row,
430
+ alignSelf,
431
+ display: "flex",
432
+ flexDirection: "column",
433
+ ...(colJustify ? { justifyContent: colJustify } : {}),
434
+ minWidth: 0,
435
+ overflow: "hidden",
436
+ }}
437
+ >
438
+ {(col.blocks || []).map((block) => (
439
+ <div key={block._key} style={{ width: "100%", minWidth: 0 }}>
440
+ <BlockLivePreview block={block} viewport={viewport} />
441
+ </div>
442
+ ))}
443
+ </div>
444
+ );
445
+ })}
446
+ </div>
447
+ </div>
448
+ </div>
449
+ );
450
+ });
451
+
334
452
  interface ReadOnlyFrameProps {
335
453
  /** The device viewport this frame represents */
336
454
  viewport: DeviceViewport;
@@ -350,7 +468,13 @@ export default function ReadOnlyFrame({ viewport }: ReadOnlyFrameProps) {
350
468
  return (
351
469
  <div>
352
470
  {rows.map((item) =>
353
- isParallaxGroup(item) ? (
471
+ isCoverSection(item) ? (
472
+ <ReadOnlyCoverSection
473
+ key={item._key}
474
+ section={item as CoverSection}
475
+ viewport={viewport}
476
+ />
477
+ ) : isParallaxGroup(item) ? (
354
478
  <ReadOnlyParallaxGroup
355
479
  key={item._key}
356
480
  group={item as ParallaxGroup}
@@ -29,6 +29,28 @@ function EmptySectionV2Icon({ size = 28 }: { size?: number }) {
29
29
  );
30
30
  }
31
31
 
32
+ function CoverSectionIcon({ size = 28 }: { size?: number }) {
33
+ const accent = "#0d9488";
34
+ return (
35
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
36
+ <defs>
37
+ <linearGradient id="csGrad" x1="5" y1="5" x2="35" y2="35">
38
+ <stop offset="0%" stopColor={accent} />
39
+ <stop offset="100%" stopColor="#0f766e" />
40
+ </linearGradient>
41
+ </defs>
42
+ <rect x="3" y="3" width="34" height="34" rx="4" fill="url(#csGrad)" opacity="0.15" />
43
+ <rect x="3" y="3" width="34" height="34" rx="4" stroke="url(#csGrad)" strokeWidth="2" fill="none" opacity="0.5" />
44
+ {/* Top row (large) */}
45
+ <rect x="6" y="6" width="28" height="18" rx="2" fill="url(#csGrad)" opacity="0.25" />
46
+ {/* Bottom row (small) */}
47
+ <rect x="6" y="26" width="28" height="8" rx="2" fill="url(#csGrad)" opacity="0.4" />
48
+ {/* Divider line */}
49
+ <line x1="8" y1="25" x2="32" y2="25" stroke={accent} strokeWidth="1" opacity="0.5" strokeDasharray="2 2" />
50
+ </svg>
51
+ );
52
+ }
53
+
32
54
  function SavedSectionIcon({ size = 28 }: { size?: number }) {
33
55
  return (
34
56
  <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
@@ -51,12 +73,14 @@ function SavedSectionIcon({ size = 28 }: { size?: number }) {
51
73
 
52
74
  const SECTION_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>> = {
53
75
  "empty-v2": EmptySectionV2Icon,
76
+ coverSection: CoverSectionIcon,
54
77
  projectGridBlock: ProjectGridBlockIcon,
55
78
  parallaxGroup: ParallaxGroupIcon,
56
79
  };
57
80
 
58
81
  const SECTION_GRADIENTS: Record<string, string> = {
59
82
  "empty-v2": "linear-gradient(135deg, #d0e0f8 0%, #b8d0f0 50%, #a0c0e8 100%)",
83
+ coverSection: "linear-gradient(135deg, #b2f5ea 0%, #81e6d9 50%, #5eead4 100%)",
60
84
  projectGridBlock: BLOCK_GRADIENTS.projectGridBlock,
61
85
  parallaxGroup: BLOCK_GRADIENTS.parallaxGroup,
62
86
  };
@@ -160,6 +184,7 @@ interface SectionTypePickerProps {
160
184
  onSelectEmptyV2?: (preset: "full" | "halves" | "thirds" | "quarters" | "1/3+2/3" | "2/3+1/3") => void;
161
185
  onSelectSection: (blockType: "projectGridBlock") => void;
162
186
  onSelectParallaxGroup?: () => void;
187
+ onSelectCoverSection?: () => void;
163
188
  onSelectCustomSection?: (section: CustomSectionListItem) => void;
164
189
  onCreateCustomSection?: () => void;
165
190
  onClose: () => void;
@@ -171,6 +196,7 @@ export default function SectionTypePicker({
171
196
  onSelectEmptyV2,
172
197
  onSelectSection,
173
198
  onSelectParallaxGroup,
199
+ onSelectCoverSection,
174
200
  onSelectCustomSection,
175
201
  onCreateCustomSection,
176
202
  onClose,
@@ -327,6 +353,9 @@ export default function SectionTypePicker({
327
353
  onClick={() => {
328
354
  if (section.type === "empty-v2") {
329
355
  setStep("layout");
356
+ } else if (section.type === "coverSection") {
357
+ onSelectCoverSection?.();
358
+ onClose();
330
359
  } else if (section.type === "parallaxGroup") {
331
360
  onSelectParallaxGroup?.();
332
361
  onClose();
@@ -25,12 +25,15 @@ interface SectionV2CanvasProps {
25
25
  onAddBlockTarget: (sectionKey: string, colKey: string, insertIndex?: number) => void;
26
26
  /** When true, the grid stretches to fill its parent height (used inside parallax slides) */
27
27
  fillHeight?: boolean;
28
+ /** Offset added to grid_row when adding columns via gaps (used by cover sections where columns are normalized to grid_row 1) */
29
+ gridRowOffset?: number;
28
30
  }
29
31
 
30
32
  export default function SectionV2Canvas({
31
33
  section,
32
34
  onAddBlockTarget,
33
35
  fillHeight,
36
+ gridRowOffset = 0,
34
37
  }: SectionV2CanvasProps) {
35
38
  const previewMode = useBuilderStore((s) => s.previewMode);
36
39
  const canvasZoom = useBuilderStore((s) => s.canvasZoom);
@@ -269,7 +272,7 @@ export default function SectionV2Canvas({
269
272
  data-gap-span={gap.span}
270
273
  onClick={(e) => {
271
274
  e.stopPropagation();
272
- addColumnV2(section._key, gap.grid_row, gap.grid_column, gap.span);
275
+ addColumnV2(section._key, gap.grid_row + gridRowOffset, gap.grid_column, gap.span);
273
276
  }}
274
277
  style={{
275
278
  gridColumn: `${gap.grid_column} / span ${gap.span}`,