@morphika/andami 0.2.26 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/app/admin/pages/[slug]/page.tsx +41 -47
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +15 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockLivePreview.tsx +5 -0
  22. package/components/builder/BlockTypePicker.tsx +36 -63
  23. package/components/builder/BuilderCanvas.tsx +6 -2
  24. package/components/builder/ColumnDragOverlay.tsx +3 -3
  25. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  26. package/components/builder/CoverSectionCanvas.tsx +45 -52
  27. package/components/builder/DndWrapper.tsx +1 -1
  28. package/components/builder/InsertionLines.tsx +1 -1
  29. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  30. package/components/builder/ReadOnlyFrame.tsx +4 -23
  31. package/components/builder/SectionCardIcons.tsx +320 -0
  32. package/components/builder/SectionEditorBar.tsx +17 -12
  33. package/components/builder/SectionTypePicker.tsx +34 -138
  34. package/components/builder/SectionV2Canvas.tsx +1 -1
  35. package/components/builder/SectionV2Column.tsx +19 -30
  36. package/components/builder/SettingsPanel.tsx +8 -32
  37. package/components/builder/SortableBlock.tsx +42 -50
  38. package/components/builder/SortableRow.tsx +207 -19
  39. package/components/builder/blockStyles.tsx +59 -180
  40. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
  41. package/components/builder/editors/index.ts +1 -0
  42. package/components/builder/iconPrimitives.tsx +78 -0
  43. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  44. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
  45. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  46. package/components/builder/live-preview/index.ts +1 -0
  47. package/components/builder/settings-panel/BlockSettings.tsx +7 -0
  48. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  49. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  50. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  51. package/lib/animation/enter-types.ts +1 -0
  52. package/lib/animation/hover-effect-types.ts +1 -0
  53. package/lib/assets.ts +17 -2
  54. package/lib/builder/block-registrations.ts +268 -0
  55. package/lib/builder/block-registry.ts +195 -0
  56. package/lib/builder/constants.ts +22 -15
  57. package/lib/builder/defaults.ts +21 -0
  58. package/lib/builder/format.ts +25 -0
  59. package/lib/builder/history.ts +0 -3
  60. package/lib/builder/index.ts +16 -0
  61. package/lib/builder/layout-styles.ts +1 -1
  62. package/lib/builder/registry.ts +44 -0
  63. package/lib/builder/section-visibility.ts +36 -0
  64. package/lib/builder/serializer/normalizers.ts +15 -6
  65. package/lib/builder/serializer/serializers.ts +3 -3
  66. package/lib/builder/store-blocks.ts +16 -9
  67. package/lib/builder/store-cover.ts +76 -8
  68. package/lib/builder/store-sections.ts +1 -1
  69. package/lib/builder/store.ts +0 -2
  70. package/lib/builder/types.ts +9 -5
  71. package/lib/csrf.ts +31 -0
  72. package/lib/sanity/types.ts +54 -2
  73. package/lib/security.ts +50 -0
  74. package/lib/version.ts +1 -1
  75. package/package.json +1 -1
  76. package/sanity/schemas/blocks/index.ts +2 -1
  77. package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
  78. package/sanity/schemas/index.ts +4 -1
  79. package/sanity/schemas/objects/coverSection.ts +35 -3
  80. package/sanity/schemas/pageSectionV2.ts +1 -0
  81. package/components/builder/ParallaxSlideHeader.tsx +0 -113
@@ -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
+ `;
@@ -342,14 +342,18 @@ export default function ShaderCanvas({ src, config, className, style }: ShaderCa
342
342
  canvas.removeEventListener("mousemove", onMouseMove);
343
343
  canvas.removeEventListener("mouseenter", onMouseEnter);
344
344
  canvas.removeEventListener("mouseleave", onMouseLeave);
345
+ }
345
346
 
346
- // Attempt to lose context cleanly
347
- const gl = canvas.getContext("webgl");
348
- if (gl) {
349
- const ext = gl.getExtension("WEBGL_lose_context");
350
- if (ext) ext.loseContext();
351
- }
347
+ // Lose the ACTIVE context (the one OGL created), not a fresh one.
348
+ // Calling canvas.getContext("webgl") on a canvas that already has a
349
+ // context returns null or a noop, leaving the real context alive.
350
+ const renderer = rendererRef.current as { gl?: WebGLRenderingContext | WebGL2RenderingContext } | null;
351
+ const activeGl = renderer?.gl;
352
+ if (activeGl) {
353
+ const ext = activeGl.getExtension("WEBGL_lose_context");
354
+ if (ext) ext.loseContext();
352
355
  }
356
+ rendererRef.current = null;
353
357
 
354
358
  window.removeEventListener("scroll", onScroll);
355
359
  };