@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.
@@ -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
  },
@@ -17,13 +17,8 @@ import EnterAnimationWrapper from "./EnterAnimationWrapper";
17
17
  import HoverAnimationWrapper from "./HoverAnimationWrapper";
18
18
  import TypewriterWrapper from "./TypewriterWrapper";
19
19
 
20
- import TextBlockRenderer, { getTextBlockStyles } from "./TextBlockRenderer";
21
- import ImageBlockRenderer from "./ImageBlockRenderer";
22
- import ImageGridBlockRenderer from "./ImageGridBlockRenderer";
23
- import VideoBlockRenderer from "./VideoBlockRenderer";
24
- import SpacerBlockRenderer from "./SpacerBlockRenderer";
25
- import ButtonBlockRenderer from "./ButtonBlockRenderer";
26
- import ProjectGridBlockRenderer from "./ProjectGridBlockRenderer";
20
+ import { getTextBlockStyles } from "./TextBlockRenderer";
21
+ import { getBlockRegistration } from "../../lib/builder/registry";
27
22
 
28
23
  // ── BLK-003: Error Boundary for block renderers ──
29
24
  // Prevents a single broken block from crashing the entire page.
@@ -288,41 +283,25 @@ export default function BlockRenderer({
288
283
 
289
284
  let content: React.ReactNode;
290
285
 
291
- switch (resolved._type) {
292
- case "textBlock":
293
- content = <TextBlockRenderer block={resolved} />;
294
- break;
295
- case "imageBlock":
296
- content = <ImageBlockRenderer block={resolved} />;
297
- break;
298
- case "imageGridBlock":
299
- content = <ImageGridBlockRenderer block={resolved} />;
300
- break;
301
- case "videoBlock":
302
- content = <VideoBlockRenderer block={resolved} />;
303
- break;
304
- case "spacerBlock":
305
- content = <SpacerBlockRenderer block={resolved} />;
306
- break;
307
- case "buttonBlock":
308
- content = <ButtonBlockRenderer block={resolved} />;
309
- break;
310
- case "projectGridBlock":
311
- content = <ProjectGridBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectGridBlock} />;
312
- break;
313
- default: {
314
- const unknownBlock = resolved as ContentBlock;
315
- if (process.env.NODE_ENV === "development") {
316
- content = (
317
- <div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
318
- Unknown block type: {unknownBlock._type}
319
- </div>
320
- );
321
- } else {
322
- // BLK-004: Log unknown block types in production for debugging
323
- console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
324
- return null;
325
- }
286
+ // Registry-driven dispatch. Same result as the former switch-case — the
287
+ // Session B consistency tests guarantee registry.renderer === the imported
288
+ // component that was previously inlined in each case branch.
289
+ const registration = getBlockRegistration(resolved._type);
290
+ if (registration) {
291
+ const Renderer = registration.renderer as React.ComponentType<{ block: ContentBlock }>;
292
+ content = <Renderer block={resolved} />;
293
+ } else {
294
+ const unknownBlock = resolved as ContentBlock;
295
+ if (process.env.NODE_ENV === "development") {
296
+ content = (
297
+ <div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
298
+ Unknown block type: {unknownBlock._type}
299
+ </div>
300
+ );
301
+ } else {
302
+ // BLK-004: Log unknown block types in production for debugging
303
+ console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
304
+ return null;
326
305
  }
327
306
  }
328
307
 
@@ -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
+ `;