@morphika/andami 0.3.1 → 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.
@@ -415,10 +415,10 @@ export default function PageEditorPage() {
415
415
  [store]
416
416
  );
417
417
 
418
- // Handle add page section (project grid)
418
+ // Handle add page section (project grid / project carousel)
419
419
  // afterRowKey=null → always appends to end of page (bottom "Add Section" button)
420
420
  const handleAddSection = useCallback(
421
- (blockType: "projectGridBlock") => {
421
+ (blockType: "projectGridBlock" | "projectCarouselBlock") => {
422
422
  store.addSection(blockType, null);
423
423
  setShowSectionPicker(false);
424
424
  },
@@ -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") {
@@ -0,0 +1,527 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProjectCarouselBlockRenderer — Public site renderer for a horizontal
5
+ * carousel of projects ("keep browsing" pattern at the end of a project
6
+ * page, but usable anywhere).
7
+ *
8
+ * Selection is automatic (latest / random), with optional exclusion of the
9
+ * project currently being viewed (detected from the pathname on
10
+ * `/work/[slug]` pages). Fetch endpoint is shared with `ProjectGridBlock`
11
+ * (`/api/projects`) but no code is linked between the two — changing one
12
+ * renderer never breaks the other.
13
+ *
14
+ * Carousel is native CSS scroll-snap with JS-driven arrow / dot controls
15
+ * and a ResizeObserver-measured card width that respects a fractional
16
+ * `cards_per_view` (e.g. 3.5 shows half of the next card to hint "more").
17
+ */
18
+
19
+ import {
20
+ useState,
21
+ useEffect,
22
+ useRef,
23
+ useCallback,
24
+ useMemo,
25
+ memo,
26
+ } from "react";
27
+ import Link from "next/link";
28
+ import { usePathname } from "next/navigation";
29
+ import { useAssetUrl, useThumbUrl } from "../../lib/contexts/AssetContext";
30
+ import { useViewport } from "../../lib/hooks/useViewport";
31
+ import type { ProjectCarouselBlock } from "../../lib/sanity/types";
32
+ import type { DeviceViewport } from "../../lib/builder/types";
33
+
34
+ // ─── Types ───────────────────────────────────────────────────────────
35
+
36
+ interface ApiProject {
37
+ _id?: string;
38
+ slug: string | { current: string };
39
+ title?: string;
40
+ seo_description?: string;
41
+ thumbnail_path?: string;
42
+ cover_video?: string;
43
+ published_at?: string;
44
+ }
45
+
46
+ interface ResolvedProject {
47
+ slug: string;
48
+ title: string;
49
+ subtitle: string;
50
+ thumbnail_path?: string;
51
+ cover_video?: string;
52
+ }
53
+
54
+ // ─── Helpers ─────────────────────────────────────────────────────────
55
+
56
+ /** Fisher–Yates shuffle, seeded by Math.random. In-place for simplicity. */
57
+ function shuffleInPlace<T>(arr: T[]): T[] {
58
+ for (let i = arr.length - 1; i > 0; i--) {
59
+ const j = Math.floor(Math.random() * (i + 1));
60
+ [arr[i], arr[j]] = [arr[j], arr[i]];
61
+ }
62
+ return arr;
63
+ }
64
+
65
+ /** Normalize a slug that may come as `"foo"` or `{ current: "foo" }`. */
66
+ function extractSlug(slug: string | { current: string } | undefined | null): string | null {
67
+ if (!slug) return null;
68
+ return typeof slug === "string" ? slug : slug.current ?? null;
69
+ }
70
+
71
+ /** Extract the project slug from a /work/[slug] pathname (or return null). */
72
+ export function extractCurrentProjectSlug(pathname: string | null): string | null {
73
+ if (!pathname) return null;
74
+ const match = pathname.match(/^\/work\/([^/?#]+)/);
75
+ return match ? match[1] : null;
76
+ }
77
+
78
+ /** Resolve how many cards should fit in the viewport given the block config. */
79
+ export function resolveCardsPerView(
80
+ block: Pick<ProjectCarouselBlock, "cards_per_view_desktop" | "cards_per_view_tablet" | "cards_per_view_phone">,
81
+ viewport: DeviceViewport,
82
+ ): number {
83
+ if (viewport === "phone") return block.cards_per_view_phone ?? 1.2;
84
+ if (viewport === "tablet") return block.cards_per_view_tablet ?? 2.2;
85
+ return block.cards_per_view_desktop ?? 3.5;
86
+ }
87
+
88
+ /** Compute card pixel width given container width, cards-per-view, and gap. */
89
+ export function computeCardWidth(
90
+ containerWidth: number,
91
+ cardsPerView: number,
92
+ gap: number,
93
+ ): number {
94
+ if (cardsPerView <= 0 || containerWidth <= 0) return 0;
95
+ // cpv cards + (cpv - 1) gaps = containerWidth → W = (cw - (cpv-1)*gap) / cpv
96
+ const w = (containerWidth - (cardsPerView - 1) * gap) / cardsPerView;
97
+ return Math.max(0, w);
98
+ }
99
+
100
+ /** Pick the final list of projects to render based on the block's source config. */
101
+ export function selectCarouselProjects(
102
+ all: ResolvedProject[],
103
+ block: Pick<ProjectCarouselBlock, "source_mode" | "max_projects" | "exclude_current">,
104
+ currentProjectSlug: string | null,
105
+ ): ResolvedProject[] {
106
+ const filtered =
107
+ block.exclude_current !== false && currentProjectSlug
108
+ ? all.filter((p) => p.slug !== currentProjectSlug)
109
+ : all;
110
+
111
+ const ordered =
112
+ block.source_mode === "auto_random"
113
+ ? shuffleInPlace([...filtered])
114
+ : filtered;
115
+
116
+ const count = Math.max(1, Math.min(20, block.max_projects ?? 8));
117
+ return ordered.slice(0, count);
118
+ }
119
+
120
+ // ─── Card component ──────────────────────────────────────────────────
121
+
122
+ const ProjectCard = memo(function ProjectCard({
123
+ project,
124
+ aspectRatio,
125
+ showTitle,
126
+ showSubtitle,
127
+ borderRadius,
128
+ hoverEffect,
129
+ videoMode,
130
+ cardWidth,
131
+ entranceStyle,
132
+ assetUrl,
133
+ thumbUrl,
134
+ }: {
135
+ project: ResolvedProject;
136
+ aspectRatio: string;
137
+ showTitle: boolean;
138
+ showSubtitle: boolean;
139
+ borderRadius: number;
140
+ hoverEffect: "scale" | "none";
141
+ videoMode: "off" | "hover" | "autoloop";
142
+ cardWidth: number;
143
+ entranceStyle: React.CSSProperties;
144
+ assetUrl: (p: string) => string;
145
+ thumbUrl: (p: string) => string;
146
+ }) {
147
+ const [isHovered, setIsHovered] = useState(false);
148
+ const videoRef = useRef<HTMLVideoElement>(null);
149
+
150
+ const thumbSrc = project.thumbnail_path ? thumbUrl(project.thumbnail_path) : null;
151
+ const videoSrc = project.cover_video ? assetUrl(project.cover_video) : null;
152
+
153
+ // Play/pause video according to mode
154
+ useEffect(() => {
155
+ const video = videoRef.current;
156
+ if (!video || videoMode === "off" || !videoSrc) return;
157
+ if (videoMode === "autoloop") {
158
+ video.play().catch(() => {});
159
+ } else if (videoMode === "hover") {
160
+ if (isHovered) video.play().catch(() => {});
161
+ else { video.pause(); video.currentTime = 0; }
162
+ }
163
+ }, [isHovered, videoMode, videoSrc]);
164
+
165
+ return (
166
+ <Link
167
+ href={`/work/${project.slug}`}
168
+ className="flex flex-col no-underline group"
169
+ style={{
170
+ flex: `0 0 ${cardWidth}px`,
171
+ scrollSnapAlign: "start",
172
+ ...entranceStyle,
173
+ }}
174
+ onMouseEnter={() => setIsHovered(true)}
175
+ onMouseLeave={() => setIsHovered(false)}
176
+ >
177
+ {/* Thumbnail area */}
178
+ <div
179
+ className="relative w-full overflow-hidden bg-black/5"
180
+ style={{
181
+ aspectRatio: aspectRatio.replace("/", " / "),
182
+ borderRadius: borderRadius ? `${borderRadius}px` : undefined,
183
+ transform: hoverEffect === "scale" && isHovered ? "scale(1.02)" : "scale(1)",
184
+ transition: "transform 300ms cubic-bezier(0.22, 1, 0.36, 1)",
185
+ }}
186
+ >
187
+ {thumbSrc && (
188
+ /* eslint-disable-next-line @next/next/no-img-element */
189
+ <img
190
+ src={thumbSrc}
191
+ alt={project.title || ""}
192
+ loading="lazy"
193
+ className="absolute inset-0 w-full h-full object-cover"
194
+ />
195
+ )}
196
+ {videoSrc && videoMode !== "off" && (
197
+ <video
198
+ ref={videoRef}
199
+ src={videoSrc}
200
+ muted
201
+ loop
202
+ playsInline
203
+ className="absolute inset-0 w-full h-full object-cover"
204
+ style={{
205
+ opacity: videoMode === "autoloop" || (videoMode === "hover" && isHovered) ? 1 : 0,
206
+ transition: "opacity 200ms ease",
207
+ }}
208
+ />
209
+ )}
210
+ </div>
211
+
212
+ {/* Title + subtitle */}
213
+ {(showTitle || showSubtitle) && (
214
+ <div className="mt-3 px-0.5">
215
+ {showTitle && project.title && (
216
+ <p className="text-[14px] font-medium text-[var(--brand-text,#111)] leading-tight truncate">
217
+ {project.title}
218
+ </p>
219
+ )}
220
+ {showSubtitle && project.subtitle && (
221
+ <p className="text-[12px] text-[var(--brand-text-muted,#888)] leading-snug mt-0.5 truncate">
222
+ {project.subtitle}
223
+ </p>
224
+ )}
225
+ </div>
226
+ )}
227
+ </Link>
228
+ );
229
+ });
230
+
231
+ // ─── Main component ──────────────────────────────────────────────────
232
+
233
+ export default function ProjectCarouselBlockRenderer({
234
+ block,
235
+ }: {
236
+ block: ProjectCarouselBlock;
237
+ }) {
238
+ const assetUrl = useAssetUrl();
239
+ const thumbUrl = useThumbUrl();
240
+ const viewport = useViewport();
241
+ const pathname = usePathname();
242
+
243
+ const trackRef = useRef<HTMLDivElement>(null);
244
+ const containerRef = useRef<HTMLDivElement>(null);
245
+ const roRef = useRef<ResizeObserver | null>(null);
246
+
247
+ const [containerWidth, setContainerWidth] = useState(0);
248
+ const [allProjects, setAllProjects] = useState<ResolvedProject[]>([]);
249
+ const [fetchError, setFetchError] = useState(false);
250
+ const [activeIndex, setActiveIndex] = useState(0);
251
+ const [isHoveringTrack, setIsHoveringTrack] = useState(false);
252
+
253
+ // ── Config resolved ────────────────────────────────────────────
254
+ const gap = block.gap ?? 16;
255
+ const aspectRatio = block.aspect_ratio ?? "4/3";
256
+ const showTitle = block.show_title !== false;
257
+ const showSubtitle = block.show_subtitle === true;
258
+ const borderRadius = block.border_radius ?? 0;
259
+ const hoverEffect = block.hover_effect ?? "scale";
260
+ const videoMode = block.video_mode ?? "off";
261
+ const showArrows = block.show_arrows !== false;
262
+ const showDots = block.show_dots === true;
263
+ const snapScroll = block.snap_scroll !== false;
264
+ const cardEntrance = block.card_entrance;
265
+
266
+ const cardsPerView = resolveCardsPerView(block, viewport);
267
+ const cardWidth = useMemo(
268
+ () => computeCardWidth(containerWidth, cardsPerView, gap),
269
+ [containerWidth, cardsPerView, gap],
270
+ );
271
+
272
+ const currentSlug = extractCurrentProjectSlug(pathname);
273
+
274
+ const projects = useMemo(
275
+ () => selectCarouselProjects(allProjects, block, currentSlug),
276
+ [allProjects, block, currentSlug],
277
+ );
278
+
279
+ // ── Measure container width via ResizeObserver ─────────────────
280
+ const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
281
+ if (roRef.current) {
282
+ roRef.current.disconnect();
283
+ roRef.current = null;
284
+ }
285
+ containerRef.current = node;
286
+ if (!node) return;
287
+
288
+ const ro = new ResizeObserver((entries) => {
289
+ for (const entry of entries) {
290
+ const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
291
+ if (w > 0) setContainerWidth(w);
292
+ }
293
+ });
294
+ roRef.current = ro;
295
+ ro.observe(node);
296
+
297
+ const measure = () => {
298
+ const w = node.clientWidth;
299
+ if (w > 0) setContainerWidth(w);
300
+ };
301
+ measure();
302
+ requestAnimationFrame(measure);
303
+ }, []);
304
+
305
+ useEffect(
306
+ () => () => {
307
+ if (roRef.current) {
308
+ roRef.current.disconnect();
309
+ roRef.current = null;
310
+ }
311
+ },
312
+ [],
313
+ );
314
+
315
+ // ── Fetch project data ─────────────────────────────────────────
316
+ useEffect(() => {
317
+ setFetchError(false);
318
+ fetch("/api/projects")
319
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`API ${r.status}`))))
320
+ .then((data: { projects?: ApiProject[] }) => {
321
+ const resolved: ResolvedProject[] = [];
322
+ for (const proj of data.projects || []) {
323
+ const slug = extractSlug(proj.slug);
324
+ if (!slug) continue;
325
+ resolved.push({
326
+ slug,
327
+ title: proj.title || slug,
328
+ subtitle: proj.seo_description || "",
329
+ thumbnail_path: proj.thumbnail_path,
330
+ cover_video: proj.cover_video,
331
+ });
332
+ }
333
+ setAllProjects(resolved);
334
+ })
335
+ .catch(() => setFetchError(true));
336
+ }, []);
337
+
338
+ // ── Track scroll position → active index (for dots + arrow disabling) ──
339
+ const handleScroll = useCallback(() => {
340
+ const track = trackRef.current;
341
+ if (!track || cardWidth === 0) return;
342
+ const idx = Math.round(track.scrollLeft / (cardWidth + gap));
343
+ setActiveIndex(Math.max(0, Math.min(projects.length - 1, idx)));
344
+ }, [cardWidth, gap, projects.length]);
345
+
346
+ // ── Arrow controls ─────────────────────────────────────────────
347
+ const scrollByPage = useCallback((dir: -1 | 1) => {
348
+ const track = trackRef.current;
349
+ if (!track) return;
350
+ const step = (cardWidth + gap) * Math.max(1, Math.floor(cardsPerView));
351
+ track.scrollBy({ left: step * dir, behavior: "smooth" });
352
+ }, [cardWidth, gap, cardsPerView]);
353
+
354
+ const scrollToIndex = useCallback((idx: number) => {
355
+ const track = trackRef.current;
356
+ if (!track) return;
357
+ track.scrollTo({ left: (cardWidth + gap) * idx, behavior: "smooth" });
358
+ }, [cardWidth, gap]);
359
+
360
+ // ── Error state ────────────────────────────────────────────────
361
+ if (fetchError) {
362
+ return (
363
+ <div className="py-8 text-center text-sm text-neutral-500">
364
+ Unable to load projects
365
+ </div>
366
+ );
367
+ }
368
+
369
+ if (projects.length === 0) {
370
+ // Either still loading (transient) or no projects after exclusion
371
+ return (
372
+ <div ref={containerCallbackRef} className="relative w-full">
373
+ {/* Reserve space so the carousel doesn't jump once projects load */}
374
+ <div style={{ aspectRatio: `${aspectRatio.replace("/", " / ")}` }} />
375
+ </div>
376
+ );
377
+ }
378
+
379
+ // ── Arrow disabled states ──────────────────────────────────────
380
+ const canScrollPrev = activeIndex > 0;
381
+ const canScrollNext = activeIndex < projects.length - Math.floor(cardsPerView);
382
+
383
+ return (
384
+ <div
385
+ ref={containerCallbackRef}
386
+ className="relative w-full"
387
+ onMouseEnter={() => setIsHoveringTrack(true)}
388
+ onMouseLeave={() => setIsHoveringTrack(false)}
389
+ >
390
+ {/* Scroll track */}
391
+ <div
392
+ ref={trackRef}
393
+ onScroll={handleScroll}
394
+ className="flex overflow-x-auto no-scrollbar"
395
+ style={{
396
+ gap: `${gap}px`,
397
+ scrollSnapType: snapScroll ? "x mandatory" : undefined,
398
+ scrollBehavior: "smooth",
399
+ WebkitOverflowScrolling: "touch",
400
+ }}
401
+ >
402
+ {projects.map((project, idx) => {
403
+ const enabled = cardEntrance?.enabled;
404
+ const preset = cardEntrance?.preset ?? "slide-up";
405
+ const delay = (cardEntrance?.stagger_delay ?? 80) * idx;
406
+ const duration = cardEntrance?.duration ?? 500;
407
+ const entranceStyle: React.CSSProperties = enabled
408
+ ? {
409
+ animation: `carousel-card-${preset} ${duration}ms cubic-bezier(0.22,1,0.36,1) ${delay}ms both`,
410
+ }
411
+ : {};
412
+ return (
413
+ <ProjectCard
414
+ key={project.slug}
415
+ project={project}
416
+ aspectRatio={aspectRatio}
417
+ showTitle={showTitle}
418
+ showSubtitle={showSubtitle}
419
+ borderRadius={borderRadius}
420
+ hoverEffect={hoverEffect}
421
+ videoMode={videoMode}
422
+ cardWidth={cardWidth}
423
+ entranceStyle={entranceStyle}
424
+ assetUrl={assetUrl}
425
+ thumbUrl={thumbUrl}
426
+ />
427
+ );
428
+ })}
429
+ </div>
430
+
431
+ {/* Arrow controls — visible on hover (desktop), hidden on touch */}
432
+ {showArrows && projects.length > Math.floor(cardsPerView) && (
433
+ <>
434
+ <button
435
+ type="button"
436
+ onClick={() => scrollByPage(-1)}
437
+ disabled={!canScrollPrev}
438
+ aria-label="Previous projects"
439
+ className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 backdrop-blur-sm border border-black/10 shadow-md flex items-center justify-center transition-opacity disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white"
440
+ style={{
441
+ opacity: isHoveringTrack ? 1 : 0,
442
+ pointerEvents: isHoveringTrack ? "auto" : "none",
443
+ transition: "opacity 200ms ease",
444
+ }}
445
+ >
446
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
447
+ <polyline points="15 18 9 12 15 6" />
448
+ </svg>
449
+ </button>
450
+ <button
451
+ type="button"
452
+ onClick={() => scrollByPage(1)}
453
+ disabled={!canScrollNext}
454
+ aria-label="Next projects"
455
+ className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white/90 backdrop-blur-sm border border-black/10 shadow-md flex items-center justify-center transition-opacity disabled:opacity-30 disabled:cursor-not-allowed hover:bg-white"
456
+ style={{
457
+ opacity: isHoveringTrack ? 1 : 0,
458
+ pointerEvents: isHoveringTrack ? "auto" : "none",
459
+ transition: "opacity 200ms ease",
460
+ }}
461
+ >
462
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
463
+ <polyline points="9 18 15 12 9 6" />
464
+ </svg>
465
+ </button>
466
+ </>
467
+ )}
468
+
469
+ {/* Dots */}
470
+ {showDots && projects.length > 1 && (
471
+ <div className="flex justify-center gap-1.5 mt-4">
472
+ {projects.map((p, idx) => (
473
+ <button
474
+ key={p.slug}
475
+ type="button"
476
+ onClick={() => scrollToIndex(idx)}
477
+ aria-label={`Go to project ${idx + 1}`}
478
+ className="transition-all"
479
+ style={{
480
+ width: activeIndex === idx ? 24 : 6,
481
+ height: 6,
482
+ borderRadius: 999,
483
+ background:
484
+ activeIndex === idx
485
+ ? "var(--brand-text, #111)"
486
+ : "var(--brand-text-muted, #888)",
487
+ opacity: activeIndex === idx ? 0.9 : 0.3,
488
+ }}
489
+ />
490
+ ))}
491
+ </div>
492
+ )}
493
+
494
+ {/* Inline keyframes for card entrance presets — scoped per-render
495
+ through unique animation names so multiple carousels co-exist. */}
496
+ <style>{CAROUSEL_KEYFRAMES}</style>
497
+
498
+ {/* Hide native scrollbar without breaking scrollability */}
499
+ <style>{SCROLLBAR_HIDE_CSS}</style>
500
+ </div>
501
+ );
502
+ }
503
+
504
+ // ─── Scoped styles ───────────────────────────────────────────────────
505
+
506
+ const CAROUSEL_KEYFRAMES = `
507
+ @keyframes carousel-card-fade {
508
+ from { opacity: 0; }
509
+ to { opacity: 1; }
510
+ }
511
+ @keyframes carousel-card-slide-up {
512
+ from { opacity: 0; transform: translateY(24px); }
513
+ to { opacity: 1; transform: translateY(0); }
514
+ }
515
+ @keyframes carousel-card-scale {
516
+ from { opacity: 0; transform: scale(0.94); }
517
+ to { opacity: 1; transform: scale(1); }
518
+ }
519
+ @media (prefers-reduced-motion: reduce) {
520
+ [class*="carousel-card-"] { animation: none !important; }
521
+ }
522
+ `;
523
+
524
+ const SCROLLBAR_HIDE_CSS = `
525
+ .no-scrollbar::-webkit-scrollbar { display: none; }
526
+ .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
527
+ `;
@@ -23,6 +23,7 @@ import type {
23
23
  SpacerBlock,
24
24
  ButtonBlock,
25
25
  ProjectGridBlock,
26
+ ProjectCarouselBlock,
26
27
  } from "../../lib/sanity/types";
27
28
 
28
29
  import { LiveTextEditor } from "./live-preview";
@@ -32,6 +33,7 @@ import { LiveVideoPreview } from "./live-preview";
32
33
  import { LiveSpacerPreview } from "./live-preview";
33
34
  import { LiveButtonPreview } from "./live-preview";
34
35
  import { LiveProjectGridPreview } from "./live-preview";
36
+ import { LiveProjectCarouselPreview } from "./live-preview";
35
37
  import { LivePlaceholder } from "./live-preview";
36
38
 
37
39
  // ============================================
@@ -74,6 +76,9 @@ function BlockLivePreviewInner({ block, viewport = "desktop", editable = false }
74
76
  case "projectGridBlock":
75
77
  content = <LiveProjectGridPreview block={resolved as ProjectGridBlock} viewport={viewport} />;
76
78
  break;
79
+ case "projectCarouselBlock":
80
+ content = <LiveProjectCarouselPreview block={resolved as ProjectCarouselBlock} viewport={viewport} />;
81
+ break;
77
82
  default:
78
83
  content = <LivePlaceholder type={(resolved as ContentBlock)._type} />;
79
84
  }
@@ -255,18 +255,8 @@ const ReadOnlyParallaxGroup = memo(function ReadOnlyParallaxGroup({
255
255
  overflow: "hidden",
256
256
  }}
257
257
  >
258
- {/* Compact header */}
259
- <div
260
- style={{
261
- background: "linear-gradient(135deg, #f3f0ff 0%, #ede5ff 100%)",
262
- padding: "4px 8px",
263
- borderBottom: "1px solid rgba(139, 92, 246, 0.1)",
264
- }}
265
- >
266
- <span style={{ fontSize: 8, fontWeight: 600, color: "#8b5cf6" }}>
267
- ▽ Parallax · {group.slides.length} slides
268
- </span>
269
- </div>
258
+ {/* Header banner removed — matches the active-viewport ParallaxGroupCanvas
259
+ cleanup. Read-only mirror stays unlabelled. */}
270
260
  {/* Slides */}
271
261
  {group.slides.map((slide, i) => (
272
262
  <div
@@ -389,17 +379,8 @@ const ReadOnlyCoverSection = memo(function ReadOnlyCoverSection({
389
379
  overflow: "hidden",
390
380
  }}
391
381
  >
392
- <div
393
- style={{
394
- background: "linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)",
395
- padding: "4px 8px",
396
- borderBottom: "1px solid rgba(13, 148, 136, 0.1)",
397
- }}
398
- >
399
- <span style={{ fontSize: 8, fontWeight: 600, color: "#0d9488" }}>
400
- ◆ Cover · {section.height} · {section.cover_rows.length} row{section.cover_rows.length !== 1 ? "s" : ""}
401
- </span>
402
- </div>
382
+ {/* Header banner removed — matches the active-viewport CoverSectionCanvas
383
+ cleanup. Read-only mirror stays unlabelled. */}
403
384
  <div style={{ position: "relative", height: containerHeight, overflow: "hidden" }}>
404
385
  {/* Background preview — image */}
405
386
  {section.background_type === "image" && section.background_image && (