@morphika/andami 0.3.1 → 0.4.1

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.
@@ -0,0 +1,227 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LiveProjectCarouselPreview — Builder canvas preview for projectCarouselBlock.
5
+ *
6
+ * Renders N mockup cards (respecting `max_projects`, aspect_ratio, gap,
7
+ * cards-per-view per viewport, border_radius) so the designer can evaluate
8
+ * the layout inside the builder without hitting the projects API. Real
9
+ * thumbnails show up on the public site.
10
+ *
11
+ * Mock card visual matches the empty-state placeholder of the Image / Video
12
+ * blocks: soft grey background + centered landscape (or play) icon. Keeps
13
+ * the preview readable at any zoom level and communicates that the real
14
+ * content is image/video thumbnails without faking actual data.
15
+ *
16
+ * Intentionally independent from LiveProjectGridPreview — same design
17
+ * language, no shared code.
18
+ */
19
+
20
+ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
21
+ import type { ProjectCarouselBlock } from "../../../lib/sanity/types";
22
+ import type { DeviceViewport } from "../../../lib/builder/types";
23
+
24
+ // ─── Helpers (duplicated from public renderer on purpose — per user request
25
+ // the two carousels are not linked so changes on one side can't cascade). ──
26
+
27
+ function resolveCardsPerView(
28
+ block: Pick<ProjectCarouselBlock, "cards_per_view_desktop" | "cards_per_view_tablet" | "cards_per_view_phone">,
29
+ viewport: DeviceViewport,
30
+ ): number {
31
+ if (viewport === "phone") return block.cards_per_view_phone ?? 1.2;
32
+ if (viewport === "tablet") return block.cards_per_view_tablet ?? 2.2;
33
+ return block.cards_per_view_desktop ?? 3.5;
34
+ }
35
+
36
+ function computeCardWidth(
37
+ containerWidth: number,
38
+ cardsPerView: number,
39
+ gap: number,
40
+ ): number {
41
+ if (cardsPerView <= 0 || containerWidth <= 0) return 0;
42
+ return Math.max(0, (containerWidth - (cardsPerView - 1) * gap) / cardsPerView);
43
+ }
44
+
45
+ // ─── Mock card glyphs ─────────────────────────────────────────────
46
+
47
+ function LandscapeGlyph() {
48
+ return (
49
+ <svg width="48" height="48" viewBox="0 0 56 56" fill="none" aria-hidden="true">
50
+ <rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
51
+ <circle cx="18" cy="21" r="3" fill="#b0b5bd" />
52
+ <path d="M12 42 L22 28 L28 34 L38 22 L46 42 Z" fill="#b0b5bd" />
53
+ </svg>
54
+ );
55
+ }
56
+
57
+ function PlayGlyph() {
58
+ return (
59
+ <svg width="48" height="48" viewBox="0 0 56 56" fill="none" aria-hidden="true">
60
+ <circle cx="28" cy="28" r="22" fill="#FFFFFF" stroke="#b0b5bd" strokeWidth="1.5" />
61
+ <path d="M24 20 L37 28 L24 36 Z" fill="#b0b5bd" />
62
+ </svg>
63
+ );
64
+ }
65
+
66
+ // ─── Main component ──────────────────────────────────────────────
67
+
68
+ export default function LiveProjectCarouselPreview({
69
+ block,
70
+ viewport: frameViewport = "desktop",
71
+ }: {
72
+ block: ProjectCarouselBlock;
73
+ viewport?: DeviceViewport;
74
+ }) {
75
+ const containerRef = useRef<HTMLDivElement>(null);
76
+ const roRef = useRef<ResizeObserver | null>(null);
77
+ const [containerWidth, setContainerWidth] = useState(0);
78
+
79
+ // Config
80
+ const maxProjects = block.max_projects ?? 8;
81
+ const gap = block.gap ?? 16;
82
+ const aspectRatio = block.aspect_ratio ?? "4/3";
83
+ const showTitle = block.show_title !== false;
84
+ const showSubtitle = block.show_subtitle === true;
85
+ const borderRadius = block.border_radius ?? 0;
86
+ const snapScroll = block.snap_scroll !== false;
87
+ const videoMode = block.video_mode ?? "off";
88
+ const showDots = block.show_dots === true;
89
+
90
+ const cardsPerView = resolveCardsPerView(block, frameViewport);
91
+ const cardWidth = useMemo(
92
+ () => computeCardWidth(containerWidth, cardsPerView, gap),
93
+ [containerWidth, cardsPerView, gap],
94
+ );
95
+
96
+ // ResizeObserver
97
+ const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
98
+ if (roRef.current) {
99
+ roRef.current.disconnect();
100
+ roRef.current = null;
101
+ }
102
+ containerRef.current = node;
103
+ if (!node) return;
104
+
105
+ const ro = new ResizeObserver((entries) => {
106
+ for (const entry of entries) {
107
+ const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
108
+ if (w > 0) setContainerWidth(w);
109
+ }
110
+ });
111
+ roRef.current = ro;
112
+ ro.observe(node);
113
+
114
+ const measure = () => {
115
+ const w = node.clientWidth;
116
+ if (w > 0) setContainerWidth(w);
117
+ };
118
+ measure();
119
+ requestAnimationFrame(measure);
120
+ }, []);
121
+
122
+ useEffect(
123
+ () => () => {
124
+ if (roRef.current) {
125
+ roRef.current.disconnect();
126
+ roRef.current = null;
127
+ }
128
+ },
129
+ [],
130
+ );
131
+
132
+ // Build the mock project array — just indexes, each card shows its number.
133
+ const mockCards = useMemo(
134
+ () => Array.from({ length: Math.max(2, Math.min(20, maxProjects)) }, (_, i) => i),
135
+ [maxProjects],
136
+ );
137
+
138
+ const Glyph = videoMode !== "off" ? PlayGlyph : LandscapeGlyph;
139
+
140
+ return (
141
+ <div ref={containerCallbackRef} className="relative w-full">
142
+ {/* Scroll track — same mechanics as public renderer so layout is faithful */}
143
+ <div
144
+ className="flex overflow-x-auto"
145
+ style={{
146
+ gap: `${gap}px`,
147
+ scrollSnapType: snapScroll ? "x mandatory" : undefined,
148
+ scrollBehavior: "smooth",
149
+ WebkitOverflowScrolling: "touch",
150
+ // Hide native scrollbar
151
+ scrollbarWidth: "none",
152
+ msOverflowStyle: "none",
153
+ }}
154
+ >
155
+ {mockCards.map((i) => (
156
+ <div
157
+ key={i}
158
+ className="flex flex-col"
159
+ style={{
160
+ flex: `0 0 ${cardWidth}px`,
161
+ scrollSnapAlign: snapScroll ? "start" : "none",
162
+ minWidth: 0,
163
+ }}
164
+ >
165
+ {/* Thumbnail area — mock */}
166
+ <div
167
+ className="relative w-full flex items-center justify-center"
168
+ style={{
169
+ aspectRatio: aspectRatio.replace("/", " / "),
170
+ background: "#f4f4f4",
171
+ borderRadius: borderRadius ? `${borderRadius}px` : undefined,
172
+ }}
173
+ >
174
+ <Glyph />
175
+ </div>
176
+
177
+ {/* Placeholder title + subtitle */}
178
+ {(showTitle || showSubtitle) && (
179
+ <div className="mt-3 px-0.5">
180
+ {showTitle && (
181
+ <div
182
+ className="h-[10px] rounded-sm"
183
+ style={{ background: "#e0e0e0", width: "60%" }}
184
+ />
185
+ )}
186
+ {showSubtitle && (
187
+ <div
188
+ className="h-[8px] rounded-sm mt-1.5"
189
+ style={{ background: "#ededed", width: "40%" }}
190
+ />
191
+ )}
192
+ </div>
193
+ )}
194
+ </div>
195
+ ))}
196
+ </div>
197
+
198
+ {/* Dots preview — decorative only inside the builder */}
199
+ {showDots && mockCards.length > 1 && (
200
+ <div className="flex justify-center gap-1.5 mt-4">
201
+ {mockCards.slice(0, Math.min(6, mockCards.length)).map((i) => (
202
+ <span
203
+ key={i}
204
+ style={{
205
+ width: i === 0 ? 24 : 6,
206
+ height: 6,
207
+ borderRadius: 999,
208
+ background: i === 0 ? "#2b2f38" : "#c9c9c9",
209
+ display: "inline-block",
210
+ }}
211
+ />
212
+ ))}
213
+ </div>
214
+ )}
215
+
216
+ {/* Tiny info strip so the designer knows this is a preview */}
217
+ <div className="absolute top-1 right-1 pointer-events-none text-[9px] font-medium px-1.5 py-0.5 rounded bg-white/80 border border-black/10 text-neutral-500 shadow-sm">
218
+ Preview · {mockCards.length} mock cards
219
+ </div>
220
+
221
+ {/* Chrome-style inline CSS to hide the webkit scrollbar */}
222
+ <style>{`
223
+ .flex::-webkit-scrollbar { display: none; }
224
+ `}</style>
225
+ </div>
226
+ );
227
+ }
@@ -8,4 +8,5 @@ export { default as LiveVideoPreview } from "./LiveVideoPreview";
8
8
  export { default as LiveSpacerPreview } from "./LiveSpacerPreview";
9
9
  export { default as LiveButtonPreview } from "./LiveButtonPreview";
10
10
  export { default as LiveProjectGridPreview } from "./LiveProjectGridPreview";
11
+ export { default as LiveProjectCarouselPreview } from "./LiveProjectCarouselPreview";
11
12
  export { ThumbBadge, LivePlaceholder, useProjectThumbnails, ProjectGridCard } from "./shared";
@@ -4,18 +4,13 @@
4
4
  * BlockSettings — Delegates to type-specific block editors.
5
5
  *
6
6
  * Session 64: Extracted from SettingsPanel.tsx.
7
+ * Session 181 (C): Switched from explicit switch-case dispatch to a
8
+ * registry lookup — the editor component for each block type is
9
+ * registered in `lib/builder/block-registrations.ts`.
7
10
  */
8
11
 
9
12
  import type { ContentBlock } from "../../../lib/sanity/types";
10
- import {
11
- TextBlockEditor,
12
- ImageBlockEditor,
13
- ImageGridBlockEditor,
14
- VideoBlockEditor,
15
- SpacerBlockEditor,
16
- ButtonBlockEditor,
17
- ProjectGridEditor,
18
- } from "../editors";
13
+ import { getBlockRegistration } from "../../../lib/builder/registry";
19
14
 
20
15
  export default function BlockSettings({
21
16
  block,
@@ -26,62 +21,22 @@ export default function BlockSettings({
26
21
  }
27
22
 
28
23
  // ============================================
29
- // Block Type Editor Router
24
+ // Block Type Editor Router (registry-driven)
30
25
  // ============================================
31
26
 
32
27
  function BlockTypeEditor({ block }: { block: ContentBlock }) {
33
- switch (block._type) {
34
- case "textBlock":
35
- return (
36
- <TextBlockEditor
37
- block={block as import("../../../lib/sanity/types").TextBlock}
38
- />
39
- );
40
- case "imageBlock":
41
- return (
42
- <ImageBlockEditor
43
- block={block as import("../../../lib/sanity/types").ImageBlock}
44
- />
45
- );
46
- case "imageGridBlock":
47
- return (
48
- <ImageGridBlockEditor
49
- block={block as import("../../../lib/sanity/types").ImageGridBlock}
50
- />
51
- );
52
- case "videoBlock":
53
- return (
54
- <VideoBlockEditor
55
- block={block as import("../../../lib/sanity/types").VideoBlock}
56
- />
57
- );
58
- case "spacerBlock":
59
- return (
60
- <SpacerBlockEditor
61
- block={block as import("../../../lib/sanity/types").SpacerBlock}
62
- />
63
- );
64
- case "buttonBlock":
65
- return (
66
- <ButtonBlockEditor
67
- block={block as import("../../../lib/sanity/types").ButtonBlock}
68
- />
69
- );
70
- case "projectGridBlock":
71
- return (
72
- <ProjectGridEditor
73
- block={block as import("../../../lib/sanity/types").ProjectGridBlock}
74
- />
75
- );
76
- default:
77
- return (
78
- <div className="p-4">
79
- <div className="rounded-lg bg-[#f5f5f5] p-3">
80
- <p className="text-xs text-neutral-400">
81
- No editor available for this block type.
82
- </p>
83
- </div>
84
- </div>
85
- );
28
+ const registration = getBlockRegistration(block._type);
29
+ if (registration) {
30
+ const Editor = registration.editor as React.ComponentType<{ block: ContentBlock }>;
31
+ return <Editor block={block} />;
86
32
  }
33
+ return (
34
+ <div className="p-4">
35
+ <div className="rounded-lg bg-[#f5f5f5] p-3">
36
+ <p className="text-xs text-neutral-400">
37
+ No editor available for this block type.
38
+ </p>
39
+ </div>
40
+ </div>
41
+ );
87
42
  }
@@ -75,6 +75,7 @@ export const BLOCK_ENTER_PRESETS: Record<BlockType, readonly EnterPreset[]> = {
75
75
  buttonBlock: ["fade", "slide-up", "scale"],
76
76
  spacerBlock: [], // invisible — no animation
77
77
  projectGridBlock: [], // uses card_entrance system
78
+ projectCarouselBlock: [], // uses card_entrance system
78
79
  };
79
80
 
80
81
  // ── Enter animation config ─────────────────────────────────────────
@@ -68,6 +68,7 @@ export const BLOCK_HOVER_PRESETS: Record<BlockType, readonly HoverPreset[]> = {
68
68
  buttonBlock: ["scale-up", "lift", "border-glow"],
69
69
  spacerBlock: [], // invisible
70
70
  projectGridBlock: ["scale-up", "lift"], // per-card effect
71
+ projectCarouselBlock: [], // uses per-card hover_effect field directly
71
72
  };
72
73
 
73
74
  // ── Hover effect config ────────────────────────────────────────────
@@ -0,0 +1,335 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Block registrations — fills the registry defined in `./block-registry.ts`
5
+ * with the 8 built-in block types shipped by the framework.
6
+ *
7
+ * Importing this module has a side effect: every `registerBlockType()`
8
+ * call below runs synchronously and populates the module-level registry.
9
+ * Most of the codebase currently does NOT import this — the existing
10
+ * dispatch tables in `components/blocks/BlockRenderer.tsx`,
11
+ * `components/builder/BlockLivePreview.tsx`, etc. are still the source
12
+ * of truth at runtime. This file is the parallel structure we'll migrate
13
+ * to in a later session, behind the existing public API so instances
14
+ * don't break.
15
+ *
16
+ * Session A (2026-04-19): all 8 blocks registered; nothing consumes
17
+ * the registry yet.
18
+ */
19
+
20
+ import { registerBlockType } from "./block-registry";
21
+
22
+ import type {
23
+ TextBlock,
24
+ ImageBlock,
25
+ ImageGridBlock,
26
+ VideoBlock,
27
+ SpacerBlock,
28
+ ButtonBlock,
29
+ ProjectGridBlock,
30
+ ProjectCarouselBlock,
31
+ } from "../sanity/types";
32
+
33
+ // ── Sanity schemas ────────────────────────────────────────────────
34
+
35
+ import {
36
+ textBlock,
37
+ imageBlock,
38
+ imageGridBlock,
39
+ videoBlock,
40
+ spacerBlock,
41
+ buttonBlock,
42
+ projectGridBlock,
43
+ projectCarouselBlock,
44
+ } from "../../sanity/schemas/blocks";
45
+
46
+ // ── Public renderers ──────────────────────────────────────────────
47
+
48
+ import TextBlockRenderer from "../../components/blocks/TextBlockRenderer";
49
+ import ImageBlockRenderer from "../../components/blocks/ImageBlockRenderer";
50
+ import ImageGridBlockRenderer from "../../components/blocks/ImageGridBlockRenderer";
51
+ import VideoBlockRenderer from "../../components/blocks/VideoBlockRenderer";
52
+ import SpacerBlockRenderer from "../../components/blocks/SpacerBlockRenderer";
53
+ import ButtonBlockRenderer from "../../components/blocks/ButtonBlockRenderer";
54
+ import ProjectGridBlockRenderer from "../../components/blocks/ProjectGridBlockRenderer";
55
+ import ProjectCarouselBlockRenderer from "../../components/blocks/ProjectCarouselBlockRenderer";
56
+
57
+ // ── Builder live previews ─────────────────────────────────────────
58
+
59
+ import LiveTextEditor from "../../components/builder/live-preview/LiveTextEditor";
60
+ import LiveImagePreview from "../../components/builder/live-preview/LiveImagePreview";
61
+ import LiveImageGridPreview from "../../components/builder/live-preview/LiveImageGridPreview";
62
+ import LiveVideoPreview from "../../components/builder/live-preview/LiveVideoPreview";
63
+ import LiveSpacerPreview from "../../components/builder/live-preview/LiveSpacerPreview";
64
+ import LiveButtonPreview from "../../components/builder/live-preview/LiveButtonPreview";
65
+ import LiveProjectGridPreview from "../../components/builder/live-preview/LiveProjectGridPreview";
66
+ import LiveProjectCarouselPreview from "../../components/builder/live-preview/LiveProjectCarouselPreview";
67
+
68
+ // ── Settings-panel editors ────────────────────────────────────────
69
+
70
+ import TextBlockEditor from "../../components/builder/editors/TextBlockEditor";
71
+ import ImageBlockEditor from "../../components/builder/editors/ImageBlockEditor";
72
+ import ImageGridBlockEditor from "../../components/builder/editors/ImageGridBlockEditor";
73
+ import VideoBlockEditor from "../../components/builder/editors/VideoBlockEditor";
74
+ import SpacerBlockEditor from "../../components/builder/editors/SpacerBlockEditor";
75
+ import ButtonBlockEditor from "../../components/builder/editors/ButtonBlockEditor";
76
+ import ProjectGridEditor from "../../components/builder/editors/ProjectGridEditor";
77
+ import ProjectCarouselBlockEditor from "../../components/builder/editors/ProjectCarouselBlockEditor";
78
+
79
+ // ── Card icons (picker modal) ─────────────────────────────────────
80
+
81
+ import {
82
+ TextBlockCardIcon,
83
+ ImageBlockCardIcon,
84
+ ImageGridBlockCardIcon,
85
+ VideoBlockCardIcon,
86
+ SpacerBlockCardIcon,
87
+ ButtonBlockCardIcon,
88
+ } from "../../components/builder/BlockCardIcons";
89
+ import {
90
+ ProjectGridCardIcon,
91
+ ProjectCarouselCardIcon,
92
+ } from "../../components/builder/SectionCardIcons";
93
+
94
+ // ── Compact icons (settings panel header) ─────────────────────────
95
+
96
+ import {
97
+ TextBlockIcon,
98
+ ImageBlockIcon,
99
+ ImageGridBlockIcon,
100
+ VideoBlockIcon,
101
+ SpacerBlockIcon,
102
+ ButtonBlockIcon,
103
+ ProjectGridBlockIcon,
104
+ ProjectCarouselBlockIcon,
105
+ } from "../../components/builder/blockStyles";
106
+
107
+ // ────────────────────────────────────────────────────────────────────
108
+ // Registrations.
109
+ //
110
+ // Each `defaultFactory` is inlined here (rather than delegating to
111
+ // `createDefaultBlock` in `./defaults.ts`) to avoid a circular dependency:
112
+ // defaults.ts -> block-registry.getBlockRegistration
113
+ // defaults.ts -> side-effect: ./block-registrations
114
+ // block-registrations.ts -> (no longer imports ./defaults)
115
+ // Order matches the existing BLOCK_TYPE_REGISTRY + section blocks appended
116
+ // at the end, so iteration order is stable.
117
+ // ────────────────────────────────────────────────────────────────────
118
+
119
+ // ── Content blocks ──
120
+
121
+ registerBlockType<TextBlock>({
122
+ type: "textBlock",
123
+ label: "Text",
124
+ description: "Rich text content",
125
+ category: "content",
126
+ iconGlyph: "T",
127
+ schema: textBlock,
128
+ defaultFactory: (key) => ({
129
+ _type: "textBlock",
130
+ _key: key,
131
+ text: [],
132
+ style: { fontSize: 14, alignment: "left", fontWeight: "400" },
133
+ }),
134
+ renderer: TextBlockRenderer as React.ComponentType<{ block: TextBlock }>,
135
+ livePreview: LiveTextEditor as unknown as React.ComponentType<{ block: TextBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
136
+ editor: TextBlockEditor as React.ComponentType<{ block: TextBlock }>,
137
+ cardIcon: TextBlockCardIcon,
138
+ compactIcon: TextBlockIcon,
139
+ enterPresets: ["typewriter", "fade", "blur-in", "slide-up"],
140
+ hoverPresets: [],
141
+ });
142
+
143
+ registerBlockType<ImageBlock>({
144
+ type: "imageBlock",
145
+ label: "Image",
146
+ description: "Single image with caption",
147
+ category: "content",
148
+ iconGlyph: "🖼",
149
+ schema: imageBlock,
150
+ defaultFactory: (key) => ({
151
+ _type: "imageBlock",
152
+ _key: key,
153
+ asset_path: "",
154
+ alt: "",
155
+ width: "full",
156
+ aspect_ratio: "auto",
157
+ lazy: true,
158
+ shadow: false,
159
+ border_radius: "",
160
+ }),
161
+ renderer: ImageBlockRenderer as React.ComponentType<{ block: ImageBlock }>,
162
+ livePreview: LiveImagePreview as unknown as React.ComponentType<{ block: ImageBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
163
+ editor: ImageBlockEditor as React.ComponentType<{ block: ImageBlock }>,
164
+ cardIcon: ImageBlockCardIcon,
165
+ compactIcon: ImageBlockIcon,
166
+ enterPresets: ["fade", "blur", "reveal", "scale", "slide-up"],
167
+ hoverPresets: ["scale-up", "lift", "tilt-3d", "ripple", "rgb-shift", "pixelate"],
168
+ });
169
+
170
+ registerBlockType<ImageGridBlock>({
171
+ type: "imageGridBlock",
172
+ label: "Image Grid",
173
+ description: "Multiple images in grid",
174
+ category: "content",
175
+ iconGlyph: "⊞",
176
+ schema: imageGridBlock,
177
+ defaultFactory: (key) => ({
178
+ _type: "imageGridBlock",
179
+ _key: key,
180
+ images: [],
181
+ h_gutter: 10,
182
+ v_gutter: 10,
183
+ images_per_row: 2,
184
+ random_grid: "disabled",
185
+ random_seed: 1,
186
+ lightbox: false,
187
+ object_fit: "cover",
188
+ }),
189
+ renderer: ImageGridBlockRenderer as React.ComponentType<{ block: ImageGridBlock }>,
190
+ livePreview: LiveImageGridPreview as unknown as React.ComponentType<{ block: ImageGridBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
191
+ editor: ImageGridBlockEditor as React.ComponentType<{ block: ImageGridBlock }>,
192
+ cardIcon: ImageGridBlockCardIcon,
193
+ compactIcon: ImageGridBlockIcon,
194
+ enterPresets: ["fade", "scale", "slide-up"],
195
+ hoverPresets: ["tilt-3d"],
196
+ });
197
+
198
+ registerBlockType<VideoBlock>({
199
+ type: "videoBlock",
200
+ label: "Video",
201
+ description: "Vimeo, YouTube, or MP4",
202
+ category: "content",
203
+ iconGlyph: "▶",
204
+ schema: videoBlock,
205
+ defaultFactory: (key) => ({
206
+ _type: "videoBlock",
207
+ _key: key,
208
+ video_type: "vimeo",
209
+ url_or_path: "",
210
+ autoplay: false,
211
+ loop: false,
212
+ muted: true,
213
+ controls: true,
214
+ aspect_ratio: "16:9",
215
+ }),
216
+ renderer: VideoBlockRenderer as React.ComponentType<{ block: VideoBlock }>,
217
+ livePreview: LiveVideoPreview as unknown as React.ComponentType<{ block: VideoBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
218
+ editor: VideoBlockEditor as React.ComponentType<{ block: VideoBlock }>,
219
+ cardIcon: VideoBlockCardIcon,
220
+ compactIcon: VideoBlockIcon,
221
+ enterPresets: ["fade", "slide-up"],
222
+ hoverPresets: [],
223
+ });
224
+
225
+ registerBlockType<SpacerBlock>({
226
+ type: "spacerBlock",
227
+ label: "Spacer",
228
+ description: "Vertical spacing",
229
+ category: "content",
230
+ iconGlyph: "↕",
231
+ schema: spacerBlock,
232
+ defaultFactory: (key) => ({
233
+ _type: "spacerBlock",
234
+ _key: key,
235
+ height: "medium",
236
+ }),
237
+ renderer: SpacerBlockRenderer as React.ComponentType<{ block: SpacerBlock }>,
238
+ livePreview: LiveSpacerPreview as unknown as React.ComponentType<{ block: SpacerBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
239
+ editor: SpacerBlockEditor as React.ComponentType<{ block: SpacerBlock }>,
240
+ cardIcon: SpacerBlockCardIcon,
241
+ compactIcon: SpacerBlockIcon,
242
+ enterPresets: [],
243
+ hoverPresets: [],
244
+ });
245
+
246
+ registerBlockType<ButtonBlock>({
247
+ type: "buttonBlock",
248
+ label: "Button",
249
+ description: "Call-to-action button",
250
+ category: "content",
251
+ iconGlyph: "▣",
252
+ schema: buttonBlock,
253
+ defaultFactory: (key) => ({
254
+ _type: "buttonBlock",
255
+ _key: key,
256
+ text: "Button",
257
+ url: "#",
258
+ style: "primary",
259
+ size: "medium",
260
+ }),
261
+ renderer: ButtonBlockRenderer as React.ComponentType<{ block: ButtonBlock }>,
262
+ livePreview: LiveButtonPreview as unknown as React.ComponentType<{ block: ButtonBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
263
+ editor: ButtonBlockEditor as React.ComponentType<{ block: ButtonBlock }>,
264
+ cardIcon: ButtonBlockCardIcon,
265
+ compactIcon: ButtonBlockIcon,
266
+ enterPresets: ["fade", "slide-up", "scale"],
267
+ hoverPresets: ["scale-up", "lift", "border-glow"],
268
+ });
269
+
270
+ // ── Section-level blocks ──
271
+
272
+ registerBlockType<ProjectGridBlock>({
273
+ type: "projectGridBlock",
274
+ label: "Project Grid",
275
+ description: "Staggered project showcase grid",
276
+ category: "section",
277
+ iconGlyph: "⬡",
278
+ schema: projectGridBlock,
279
+ defaultFactory: (key) => ({
280
+ _type: "projectGridBlock",
281
+ _key: key,
282
+ columns: 3,
283
+ aspect_ratios: ["16/9"],
284
+ gap_v: 16,
285
+ gap_h: 16,
286
+ hover_effect: "scale",
287
+ show_subtitle: true,
288
+ border_radius: 0,
289
+ video_mode: "off",
290
+ projects: [],
291
+ }),
292
+ renderer: ProjectGridBlockRenderer as React.ComponentType<{ block: ProjectGridBlock }>,
293
+ livePreview: LiveProjectGridPreview as unknown as React.ComponentType<{ block: ProjectGridBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
294
+ editor: ProjectGridEditor as React.ComponentType<{ block: ProjectGridBlock }>,
295
+ cardIcon: ProjectGridCardIcon,
296
+ compactIcon: ProjectGridBlockIcon,
297
+ enterPresets: [],
298
+ hoverPresets: ["scale-up", "lift"],
299
+ });
300
+
301
+ registerBlockType<ProjectCarouselBlock>({
302
+ type: "projectCarouselBlock",
303
+ label: "Project Carousel",
304
+ description: "Horizontal carousel of projects — great for end-of-page 'keep browsing'",
305
+ category: "section",
306
+ iconGlyph: "▸",
307
+ schema: projectCarouselBlock,
308
+ defaultFactory: (key) => ({
309
+ _type: "projectCarouselBlock",
310
+ _key: key,
311
+ source_mode: "auto_latest",
312
+ max_projects: 8,
313
+ exclude_current: true,
314
+ cards_per_view_desktop: 3.5,
315
+ cards_per_view_tablet: 2.2,
316
+ cards_per_view_phone: 1.2,
317
+ gap: 16,
318
+ aspect_ratio: "4/3",
319
+ show_title: true,
320
+ show_subtitle: false,
321
+ border_radius: 0,
322
+ hover_effect: "scale",
323
+ video_mode: "off",
324
+ show_arrows: true,
325
+ show_dots: false,
326
+ snap_scroll: true,
327
+ }),
328
+ renderer: ProjectCarouselBlockRenderer as React.ComponentType<{ block: ProjectCarouselBlock }>,
329
+ livePreview: LiveProjectCarouselPreview as unknown as React.ComponentType<{ block: ProjectCarouselBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
330
+ editor: ProjectCarouselBlockEditor as React.ComponentType<{ block: ProjectCarouselBlock }>,
331
+ cardIcon: ProjectCarouselCardIcon,
332
+ compactIcon: ProjectCarouselBlockIcon,
333
+ enterPresets: [],
334
+ hoverPresets: [],
335
+ });