cinematic-scroll-skill 2.1.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 (61) hide show
  1. package/COMPATIBILITY.md +244 -0
  2. package/LICENSE +21 -0
  3. package/MODELS.md +92 -0
  4. package/README.md +250 -0
  5. package/SKILL.md +1003 -0
  6. package/audit-mode.md +497 -0
  7. package/bin/install.mjs +91 -0
  8. package/compile-choreography.mjs +296 -0
  9. package/decision-log.md +241 -0
  10. package/examples/GETTING_STARTED.md +279 -0
  11. package/examples/KNOWN_ISSUES.md +50 -0
  12. package/examples/PROMPTS.md +166 -0
  13. package/examples/luxe/README.md +88 -0
  14. package/examples/luxe/index.html +662 -0
  15. package/examples/noir/README.md +72 -0
  16. package/examples/noir/index.html +634 -0
  17. package/examples/pop/README.md +81 -0
  18. package/examples/pop/index.html +711 -0
  19. package/examples/renaissance/README.md +39 -0
  20. package/examples/renaissance/index.html +648 -0
  21. package/examples/studio/README.md +77 -0
  22. package/examples/studio/chapters.js +105 -0
  23. package/examples/studio/index.html +520 -0
  24. package/manifest.json +92 -0
  25. package/manifest.md +136 -0
  26. package/package.json +56 -0
  27. package/references/film-archetypes.md +211 -0
  28. package/references/performance-budget.md +499 -0
  29. package/references/scroll-patterns.md +693 -0
  30. package/scroll-choreography-compilation.md +543 -0
  31. package/scroll-choreography.json +1512 -0
  32. package/taste-guardrails.md +164 -0
  33. package/templates/nextjs/.env.example +41 -0
  34. package/templates/nextjs/app/api/fal/proxy/route.ts +33 -0
  35. package/templates/nextjs/app/api/fal/webhook/route.ts +132 -0
  36. package/templates/nextjs/app/api/generate-edition-asset/route.ts +66 -0
  37. package/templates/nextjs/app/globals.css +80 -0
  38. package/templates/nextjs/app/layout.tsx +21 -0
  39. package/templates/nextjs/app/page.tsx +10 -0
  40. package/templates/nextjs/components/ChapterDemoVisual.tsx +212 -0
  41. package/templates/nextjs/components/ChapterScene.tsx +373 -0
  42. package/templates/nextjs/components/EditionsPage.tsx +116 -0
  43. package/templates/nextjs/components/SmoothScrollProvider.tsx +8 -0
  44. package/templates/nextjs/lib/api-guard.ts +110 -0
  45. package/templates/nextjs/lib/editions-manifest.ts +224 -0
  46. package/templates/nextjs/lib/fal-client.ts +12 -0
  47. package/templates/nextjs/lib/fal-generate.ts +86 -0
  48. package/templates/nextjs/lib/fal-models.ts +213 -0
  49. package/templates/nextjs/lib/prompt-contract.ts +97 -0
  50. package/templates/nextjs/lib/use-device.ts +42 -0
  51. package/templates/nextjs/lib/use-lenis.ts +35 -0
  52. package/templates/nextjs/next.config.ts +29 -0
  53. package/templates/nextjs/package-lock.json +6455 -0
  54. package/templates/nextjs/package.json +41 -0
  55. package/templates/nextjs/package.patch.json +28 -0
  56. package/templates/nextjs/postcss.config.js +6 -0
  57. package/templates/nextjs/scripts/generate-chapter-assets.mjs +243 -0
  58. package/templates/nextjs/scripts/setup.mjs +170 -0
  59. package/templates/nextjs/tailwind.config.ts +37 -0
  60. package/templates/nextjs/tsconfig.json +23 -0
  61. package/troubleshooting.md +1284 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * ChapterDemoVisual — CSS + SVG only.
3
+ *
4
+ * Renders a stunning chapter visual without any fal.ai image. Used as the
5
+ * default when `chapter.background` is undefined so the page looks good on
6
+ * first paint even before the user sets up fal credentials.
7
+ *
8
+ * Visual composition (5 layers, no raster images):
9
+ * 1. atmosphere gradient (from chapter.atmosphere.background)
10
+ * 2. soft mesh gradient built from chapter.visualPrompt.palette
11
+ * 3. historical-layer SVG silhouette (renaissance arch, baroque ribbon, etc.)
12
+ * 4. SVG film-grain via feTurbulence
13
+ * 5. radial vignette
14
+ *
15
+ * Replace by setting `chapter.background = '/generated/<id>.jpg'` after running
16
+ * `node scripts/generate-chapter-assets.mjs`.
17
+ */
18
+ import type { EditionChapter } from '@/lib/editions-manifest';
19
+
20
+ export function ChapterDemoVisual({ chapter, eager: _eager = false }: { chapter: EditionChapter; eager?: boolean }) {
21
+ const palette = chapter.visualPrompt?.palette ?? ['#1a1410', '#3a2a20', '#8a6a4a'];
22
+ // Resolve named palette tokens to actual hex (manifest uses words like "aged cream")
23
+ const accent = chapter.accent;
24
+ const [c1, c2, c3] = [paletteHex(palette[0], accent), paletteHex(palette[1], accent), paletteHex(palette[2] ?? accent, accent)];
25
+ const grainId = `grain-${chapter.id}`;
26
+ const meshId = `mesh-${chapter.id}`;
27
+
28
+ return (
29
+ <div className="absolute inset-0 overflow-hidden" aria-hidden>
30
+ {/* Layer 1 — atmosphere base */}
31
+ <div className="absolute inset-0" style={{ background: chapter.atmosphere.background }} />
32
+
33
+ {/* Layer 2 — soft mesh gradient from palette */}
34
+ <div
35
+ className="absolute inset-0 opacity-90 mix-blend-screen"
36
+ style={{
37
+ background: `
38
+ radial-gradient(60% 50% at 25% 30%, ${withAlpha(c1, 0.55)}, transparent 60%),
39
+ radial-gradient(50% 60% at 80% 60%, ${withAlpha(c2, 0.45)}, transparent 65%),
40
+ radial-gradient(40% 40% at 60% 85%, ${withAlpha(accent, 0.35)}, transparent 70%)
41
+ `,
42
+ }}
43
+ />
44
+
45
+ {/* Layer 3 — historical silhouette */}
46
+ <svg
47
+ className="absolute inset-0 h-full w-full opacity-[0.13]"
48
+ viewBox="0 0 1600 900"
49
+ preserveAspectRatio="xMidYMid slice"
50
+ >
51
+ <defs>
52
+ <linearGradient id={meshId} x1="0" y1="0" x2="1" y2="1">
53
+ <stop offset="0%" stopColor={c1} stopOpacity="0.9" />
54
+ <stop offset="100%" stopColor={c3} stopOpacity="0.4" />
55
+ </linearGradient>
56
+ </defs>
57
+ <HistoricalSilhouette
58
+ layer={chapter.visualPrompt?.historicalLayer ?? 'renaissance'}
59
+ fill={`url(#${meshId})`}
60
+ accent={accent}
61
+ />
62
+ </svg>
63
+
64
+ {/* Layer 4 — film grain */}
65
+ <svg className="absolute inset-0 h-full w-full opacity-[0.18] mix-blend-overlay">
66
+ <filter id={grainId}>
67
+ <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch" />
68
+ <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.55 0" />
69
+ </filter>
70
+ <rect width="100%" height="100%" filter={`url(#${grainId})`} />
71
+ </svg>
72
+
73
+ {/* Layer 5 — radial vignette */}
74
+ <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_45%,transparent_0%,rgba(0,0,0,0.18)_42%,rgba(0,0,0,0.82)_100%)]" />
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // ─── Historical SVG silhouettes ───────────────────────────────────────────
80
+
81
+ function HistoricalSilhouette({
82
+ layer,
83
+ fill,
84
+ accent,
85
+ }: {
86
+ layer: 'renaissance' | 'baroque' | 'atelier' | 'architectural' | 'industrial';
87
+ fill: string;
88
+ accent: string;
89
+ }) {
90
+ switch (layer) {
91
+ case 'renaissance':
92
+ // Classical arch with column outlines
93
+ return (
94
+ <g fill={fill} stroke={accent} strokeWidth="0.5" strokeOpacity="0.3">
95
+ <path d="M 600 900 L 600 380 Q 600 200 800 200 Q 1000 200 1000 380 L 1000 900 Z" />
96
+ <rect x="540" y="380" width="40" height="520" />
97
+ <rect x="1020" y="380" width="40" height="520" />
98
+ <rect x="520" y="360" width="80" height="30" />
99
+ <rect x="1000" y="360" width="80" height="30" />
100
+ </g>
101
+ );
102
+ case 'baroque':
103
+ // Sweeping ribbon / scroll flourish
104
+ return (
105
+ <g fill={fill}>
106
+ <path d="M 100 450 Q 400 200 800 500 T 1500 350 L 1500 700 Q 1100 900 700 650 T 100 750 Z" />
107
+ <path d="M 200 600 Q 500 400 900 650 T 1400 500" stroke={accent} strokeOpacity="0.4" strokeWidth="2" fill="none" />
108
+ </g>
109
+ );
110
+ case 'atelier':
111
+ // Easel + draped fabric
112
+ return (
113
+ <g fill={fill}>
114
+ <path d="M 650 250 L 950 250 L 950 700 L 650 700 Z" />
115
+ <path d="M 600 700 L 800 200 L 1000 700 Z" stroke={accent} strokeOpacity="0.3" strokeWidth="1.5" fill="none" />
116
+ <path d="M 400 850 Q 600 750 800 820 T 1200 800 L 1200 900 L 400 900 Z" opacity="0.6" />
117
+ </g>
118
+ );
119
+ case 'architectural':
120
+ // Blueprint grid + building outline
121
+ return (
122
+ <g fill="none" stroke={fill} strokeWidth="1.2" strokeOpacity="0.6">
123
+ {Array.from({ length: 20 }).map((_, i) => (
124
+ <line key={`v-${i}`} x1={i * 80} y1="0" x2={i * 80} y2="900" strokeOpacity="0.15" />
125
+ ))}
126
+ {Array.from({ length: 12 }).map((_, i) => (
127
+ <line key={`h-${i}`} x1="0" y1={i * 80} x2="1600" y2={i * 80} strokeOpacity="0.15" />
128
+ ))}
129
+ <rect x="500" y="300" width="600" height="500" stroke={accent} strokeOpacity="0.5" strokeWidth="2" />
130
+ <rect x="550" y="350" width="120" height="160" />
131
+ <rect x="730" y="350" width="120" height="160" />
132
+ <rect x="910" y="350" width="120" height="160" />
133
+ <rect x="550" y="540" width="120" height="160" />
134
+ <rect x="730" y="540" width="120" height="160" />
135
+ <rect x="910" y="540" width="120" height="160" />
136
+ </g>
137
+ );
138
+ case 'industrial':
139
+ // Concentric gear silhouettes
140
+ return (
141
+ <g fill={fill}>
142
+ <circle cx="500" cy="500" r="220" />
143
+ <circle cx="500" cy="500" r="120" fill={accent} fillOpacity="0.2" />
144
+ <circle cx="1100" cy="450" r="160" />
145
+ <circle cx="1100" cy="450" r="80" fill={accent} fillOpacity="0.2" />
146
+ {Array.from({ length: 12 }).map((_, i) => {
147
+ const angle = (i / 12) * Math.PI * 2;
148
+ const x1 = 500 + Math.cos(angle) * 230;
149
+ const y1 = 500 + Math.sin(angle) * 230;
150
+ const x2 = 500 + Math.cos(angle) * 280;
151
+ const y2 = 500 + Math.sin(angle) * 280;
152
+ return <rect key={i} x={x1 - 20} y={y1 - 10} width="50" height="20" transform={`rotate(${(angle * 180) / Math.PI} ${x1} ${y1})`} />;
153
+ })}
154
+ </g>
155
+ );
156
+ }
157
+ }
158
+
159
+ // ─── Colour helpers ───────────────────────────────────────────────────────
160
+
161
+ const NAMED_PALETTE: Record<string, string> = {
162
+ 'aged cream': '#e8dfc8',
163
+ 'deep umber': '#3a2418',
164
+ 'acid pink': '#ff4fc3',
165
+ 'soft sky blue': '#9bc4e2',
166
+ 'dark olive': '#3a3a1f',
167
+ 'gold leaf': '#c9a227',
168
+ 'blackened green': '#10221a',
169
+ 'electric lime': '#b4ff38',
170
+ 'warm canvas': '#e8dfc8',
171
+ sepia: '#7a4f2a',
172
+ cyan: '#37c7ff',
173
+ 'bone white': '#f0e8d8',
174
+ 'deep crimson': '#7a1f1f',
175
+ ivory: '#f0e8d8',
176
+ 'oxidised brass': '#8a6f3a',
177
+ 'soft rose': '#d8a8b0',
178
+ 'twilight purple': '#3a2a4f',
179
+ brass: '#8a6f3a',
180
+ 'steel blue': '#4f6a8a',
181
+ 'ember orange': '#e87e2a',
182
+ vellum: '#f0e8d8',
183
+ 'india ink': '#0a0a14',
184
+ amber: '#c9a227',
185
+ bone: '#f0e8d8',
186
+ 'midnight teal': '#1a3a47',
187
+ copper: '#8a4f2a',
188
+ sky: '#9bc4e2',
189
+ graphite: '#3a3a3a',
190
+ parchment: '#e8dfc8',
191
+ rust: '#8a3a1f',
192
+ ink: '#0a0a14',
193
+ stone: '#9a948a',
194
+ ecru: '#d8cfba',
195
+ bark: '#3a2a20',
196
+ };
197
+
198
+ function paletteHex(token: string | undefined, fallback: string): string {
199
+ if (!token) return fallback;
200
+ if (token.startsWith('#')) return token;
201
+ const key = token.toLowerCase().trim();
202
+ return NAMED_PALETTE[key] ?? fallback;
203
+ }
204
+
205
+ function withAlpha(hex: string, alpha: number): string {
206
+ const clean = hex.replace('#', '');
207
+ const h = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
208
+ const r = parseInt(h.slice(0, 2), 16);
209
+ const g = parseInt(h.slice(2, 4), 16);
210
+ const b = parseInt(h.slice(4, 6), 16);
211
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
212
+ }
@@ -0,0 +1,373 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import Image from 'next/image';
5
+ import { motion, useScroll, useTransform, useReducedMotion as useFMReducedMotion } from 'framer-motion';
6
+ import { ScrollChoreography, ScrollLayer } from 'choreo-3d';
7
+ import type { EditionChapter } from '@/lib/editions-manifest';
8
+ import { useIsMobile } from '@/lib/use-device';
9
+ import { ChapterDemoVisual } from './ChapterDemoVisual';
10
+
11
+ /**
12
+ * 7-layer cinematic chapter scene.
13
+ *
14
+ * Layers (depth multipliers — lower = slower = perceptually farther):
15
+ * 0.15 atmospheric gradient (sky)
16
+ * 0.30 mid-far texture (grid / haze)
17
+ * 0.50 main background image
18
+ * 0.75 foreground figure (chapter.foreground)
19
+ * 1.00 UI: title + summary + glass panel
20
+ * 1.20 oversized Roman-numeral watermark (foreground accent)
21
+ * 1.40 scroll cue / chapter badge (closest overlay)
22
+ *
23
+ * 3D camera: perspective: 1200px on wrapper, scroll-driven rotateX/Y + translateZ
24
+ * on the subject layer (disabled on touch + reduced motion).
25
+ *
26
+ * Title reveal: word stagger via Framer Motion (no plain opacity fade).
27
+ *
28
+ * Mobile (<768px): pinning is disabled. Layers stack vertically with
29
+ * IntersectionObserver fade-up to respect iOS Safari momentum scroll.
30
+ */
31
+ export function ChapterScene({ chapter, eager = false }: { chapter: EditionChapter; eager?: boolean }) {
32
+ const isMobile = useIsMobile();
33
+ const reduced = useFMReducedMotion() ?? false;
34
+
35
+ if (isMobile || reduced) {
36
+ return <MobileChapter chapter={chapter} eager={eager} />;
37
+ }
38
+ return <DesktopChapter chapter={chapter} eager={eager} />;
39
+ }
40
+
41
+ // ─── DESKTOP — 7 layers, perspective camera, word-stagger title ────────────
42
+
43
+ function DesktopChapter({ chapter, eager }: { chapter: EditionChapter; eager: boolean }) {
44
+ const sceneRef = React.useRef<HTMLDivElement>(null);
45
+ const { scrollYProgress } = useScroll({ target: sceneRef, offset: ['start end', 'end start'] });
46
+
47
+ // 3D camera — gentle pitch + dolly-back across the chapter
48
+ const rotateX = useTransform(scrollYProgress, [0, 0.5, 1], [3, 0, -3]);
49
+ const translateZ = useTransform(scrollYProgress, [0, 0.5, 1], [0, 0, -60]);
50
+
51
+ return (
52
+ <section
53
+ id={chapter.id}
54
+ ref={sceneRef}
55
+ className="relative min-h-screen"
56
+ style={{ perspective: '1200px' }}
57
+ >
58
+ <ScrollChoreography pinDistance="160vh" start="top top" scrub={0.9} className="relative h-screen overflow-hidden">
59
+ {/* ── Layer 1 — atmospheric far (depth 0.15) ─────────────────── */}
60
+ <ScrollLayer keyframes={layer1Atmosphere} depth={0.15} zIndex={1} style={{ position: 'absolute', inset: 0 }}>
61
+ <div
62
+ className="absolute inset-0"
63
+ style={{ background: chapter.atmosphere.background }}
64
+ aria-hidden
65
+ />
66
+ </ScrollLayer>
67
+
68
+ {/* ── Layer 2 — mid-far texture (depth 0.30) ─────────────────── */}
69
+ <ScrollLayer keyframes={layer2MidFar} depth={0.30} zIndex={2} style={{ position: 'absolute', inset: 0 }}>
70
+ <div
71
+ className="absolute inset-0 opacity-[0.12] mix-blend-screen [background-image:linear-gradient(rgba(255,255,255,.4)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.4)_1px,transparent_1px)] [background-size:120px_120px]"
72
+ aria-hidden
73
+ />
74
+ </ScrollLayer>
75
+
76
+ {/* ── Layer 3 — main background (depth 0.50) ─────────────────── */}
77
+ {/* If chapter.background is set, render the fal.ai-generated image. */}
78
+ {/* If undefined, render the CSS-only ChapterDemoVisual so the page */}
79
+ {/* looks stunning even without any fal setup. */}
80
+ <ScrollLayer keyframes={layer3Background} depth={0.50} zIndex={3} style={{ position: 'absolute', inset: 0 }}>
81
+ {chapter.background ? (
82
+ <>
83
+ <Image
84
+ src={chapter.background}
85
+ alt=""
86
+ fill
87
+ sizes="100vw"
88
+ priority={eager}
89
+ placeholder={chapter.backgroundBlur ? 'blur' : 'empty'}
90
+ blurDataURL={chapter.backgroundBlur}
91
+ className="object-cover opacity-80 saturate-[0.85]"
92
+ />
93
+ <div
94
+ className="absolute inset-0 bg-[radial-gradient(circle_at_50%_45%,transparent_0%,rgba(0,0,0,0.18)_42%,rgba(0,0,0,0.82)_100%)]"
95
+ aria-hidden
96
+ />
97
+ </>
98
+ ) : (
99
+ <ChapterDemoVisual chapter={chapter} eager={eager} />
100
+ )}
101
+ </ScrollLayer>
102
+
103
+ {/* ── Layer 4 — subject / foreground figure (depth 0.75) with 3D camera ── */}
104
+ {chapter.foreground ? (
105
+ <ScrollLayer keyframes={layer4Subject} depth={0.75} zIndex={4} style={{ position: 'absolute', inset: 0 }}>
106
+ <motion.div
107
+ style={{ rotateX, translateZ, transformStyle: 'preserve-3d' }}
108
+ className="pointer-events-none absolute right-[8%] top-[14%] hidden w-[28vw] max-w-[420px] md:block"
109
+ >
110
+ <Image
111
+ src={chapter.foreground}
112
+ alt=""
113
+ width={560}
114
+ height={700}
115
+ className="h-auto w-full object-contain drop-shadow-2xl"
116
+ />
117
+ </motion.div>
118
+ </ScrollLayer>
119
+ ) : null}
120
+
121
+ {/* ── Layer 5 — UI text + glass panel (depth 1.0) with word stagger ── */}
122
+ <ScrollLayer keyframes={layer5UI} depth={1.0} zIndex={5} style={{ position: 'absolute', inset: 0 }}>
123
+ <div className="grid h-screen grid-cols-1 items-center gap-8 pt-[max(env(safe-area-inset-top),5rem)] pl-[max(env(safe-area-inset-left),1.5rem)] pr-[max(env(safe-area-inset-right),1.5rem)] md:grid-cols-[1.05fr_0.95fr] md:px-16 lg:px-24">
124
+ <div className="max-w-3xl">
125
+ <p className="mb-4 font-mono text-[0.7rem] uppercase tracking-[0.32em] text-white/70">
126
+ {chapter.roman} / {chapter.eyebrow}
127
+ </p>
128
+ <WordStaggerTitle text={chapter.title} progress={scrollYProgress} />
129
+ <p className="mt-6 max-w-xl text-balance text-fluid-body leading-relaxed text-white/78">
130
+ {chapter.summary}
131
+ </p>
132
+ </div>
133
+ <GlassPanel accent={chapter.accent} chapter={chapter} />
134
+ </div>
135
+ </ScrollLayer>
136
+
137
+ {/* ── Layer 6 — oversized Roman numeral watermark (depth 1.20) ── */}
138
+ <ScrollLayer keyframes={layer6Accent} depth={1.20} zIndex={6} style={{ position: 'absolute', inset: 0 }}>
139
+ <div
140
+ aria-hidden
141
+ className="pointer-events-none absolute bottom-[6vh] left-[3vw] select-none font-mono text-[clamp(8rem,18vw,16rem)] font-black leading-none text-white/[0.06]"
142
+ >
143
+ {chapter.roman}
144
+ </div>
145
+ </ScrollLayer>
146
+
147
+ {/* ── Layer 7 — closest overlay: scroll cue / chapter badge (depth 1.40) ── */}
148
+ <ScrollLayer keyframes={layer7Closest} depth={1.40} zIndex={7} style={{ position: 'absolute', inset: 0 }}>
149
+ <div
150
+ className="pointer-events-none absolute right-[max(env(safe-area-inset-right),1.5rem)] top-[max(env(safe-area-inset-top),5rem)] flex items-center gap-2 font-mono text-[0.65rem] uppercase tracking-[0.28em] text-white/55"
151
+ style={{ color: chapter.accent }}
152
+ >
153
+ <span className="inline-block h-2 w-2 rounded-full" style={{ background: chapter.accent }} />
154
+ chapter {chapter.roman}
155
+ </div>
156
+ </ScrollLayer>
157
+ </ScrollChoreography>
158
+ </section>
159
+ );
160
+ }
161
+
162
+ // ─── MOBILE — stacked card with IntersectionObserver fade-up ───────────────
163
+
164
+ function MobileChapter({ chapter, eager }: { chapter: EditionChapter; eager: boolean }) {
165
+ const ref = React.useRef<HTMLDivElement>(null);
166
+ const [visible, setVisible] = React.useState(false);
167
+
168
+ React.useEffect(() => {
169
+ const el = ref.current;
170
+ if (!el) return;
171
+ const io = new IntersectionObserver(
172
+ ([entry]) => entry.isIntersecting && setVisible(true),
173
+ { threshold: 0.18 },
174
+ );
175
+ io.observe(el);
176
+ return () => io.disconnect();
177
+ }, []);
178
+
179
+ return (
180
+ <section
181
+ id={chapter.id}
182
+ ref={ref}
183
+ className="relative min-h-[100svh] overflow-hidden"
184
+ style={{ background: chapter.atmosphere.background }}
185
+ >
186
+ <div className="relative h-[55vh] w-full overflow-hidden">
187
+ {chapter.background ? (
188
+ <Image
189
+ src={chapter.background}
190
+ alt=""
191
+ fill
192
+ sizes="100vw"
193
+ priority={eager}
194
+ placeholder={chapter.backgroundBlur ? 'blur' : 'empty'}
195
+ blurDataURL={chapter.backgroundBlur}
196
+ className="object-cover opacity-90 saturate-[0.85]"
197
+ />
198
+ ) : (
199
+ <ChapterDemoVisual chapter={chapter} eager={eager} />
200
+ )}
201
+ <div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/30 to-black" />
202
+ <div
203
+ aria-hidden
204
+ className="pointer-events-none absolute bottom-3 left-3 font-mono text-7xl font-black text-white/15"
205
+ >
206
+ {chapter.roman}
207
+ </div>
208
+ </div>
209
+
210
+ <div
211
+ className="relative -mt-12 px-[max(env(safe-area-inset-left),1.25rem)] pr-[max(env(safe-area-inset-right),1.25rem)] pb-12 transition-all duration-700"
212
+ style={{
213
+ opacity: visible ? 1 : 0,
214
+ transform: visible ? 'translateY(0)' : 'translateY(24px)',
215
+ }}
216
+ >
217
+ <p className="mb-3 font-mono text-[0.65rem] uppercase tracking-[0.3em] text-white/70">
218
+ {chapter.roman} · {chapter.eyebrow}
219
+ </p>
220
+ <h2 className="text-balance text-fluid-display font-black leading-[0.92] tracking-[-0.04em]">
221
+ {chapter.title}
222
+ </h2>
223
+ <p className="mt-4 text-fluid-body leading-relaxed text-white/80">{chapter.summary}</p>
224
+
225
+ <div
226
+ className="mt-6 rounded-md border border-white/15 bg-black/35 p-5"
227
+ style={{ boxShadow: `inset 0 1px 0 ${chapter.accent}33` }}
228
+ >
229
+ <p className="font-mono text-[0.6rem] uppercase tracking-[0.24em] text-white/55">Technical claim</p>
230
+ <p className="mt-2 text-xl font-semibold leading-snug">{chapter.technicalDetail}</p>
231
+ <ul className="mt-5 grid gap-3">
232
+ {chapter.features.map((feature) => (
233
+ <li
234
+ key={feature}
235
+ className="flex items-center justify-between border-t border-white/10 py-2 font-mono text-[0.7rem] uppercase tracking-[0.18em] text-white/75"
236
+ >
237
+ <span>{feature}</span>
238
+ <span style={{ color: chapter.accent }}>active</span>
239
+ </li>
240
+ ))}
241
+ </ul>
242
+ </div>
243
+ </div>
244
+ </section>
245
+ );
246
+ }
247
+
248
+ // ─── Word-stagger title ────────────────────────────────────────────────────
249
+
250
+ function WordStaggerTitle({
251
+ text,
252
+ progress,
253
+ }: {
254
+ text: string;
255
+ progress: ReturnType<typeof useScroll>['scrollYProgress'];
256
+ }) {
257
+ const words = text.split(' ');
258
+ return (
259
+ <h1 className="max-w-5xl text-balance font-black leading-[0.88] tracking-[-0.045em] text-fluid-display">
260
+ {words.map((word, i) => {
261
+ const start = 0.05 + i * 0.035;
262
+ const end = start + 0.18;
263
+ return <Word key={`${word}-${i}`} word={word} start={start} end={end} progress={progress} />;
264
+ })}
265
+ </h1>
266
+ );
267
+ }
268
+
269
+ function Word({
270
+ word,
271
+ start,
272
+ end,
273
+ progress,
274
+ }: {
275
+ word: string;
276
+ start: number;
277
+ end: number;
278
+ progress: ReturnType<typeof useScroll>['scrollYProgress'];
279
+ }) {
280
+ const opacity = useTransform(progress, [start, end], [0, 1]);
281
+ const y = useTransform(progress, [start, end], ['0.6em', '0em']);
282
+ return (
283
+ <span style={{ display: 'inline-block', overflow: 'hidden', verticalAlign: 'bottom' }}>
284
+ <motion.span style={{ display: 'inline-block', opacity, y }}>
285
+ {word}
286
+ &nbsp;
287
+ </motion.span>
288
+ </span>
289
+ );
290
+ }
291
+
292
+ // ─── Glass panel (UI side card) ────────────────────────────────────────────
293
+
294
+ function GlassPanel({ accent, chapter }: { accent: string; chapter: EditionChapter }) {
295
+ const isMobile = useIsMobile();
296
+ // backdrop-blur destroys frame rate on low-end mobile — fall back to solid alpha
297
+ const blurClass = isMobile ? 'bg-black/55' : 'bg-black/30 backdrop-blur-xl';
298
+ return (
299
+ <motion.div
300
+ className={`relative mx-auto w-full max-w-[560px] overflow-hidden border border-white/20 p-6 shadow-2xl md:p-8 ${blurClass}`}
301
+ whileHover={{ y: -4 }}
302
+ transition={{ type: 'spring', stiffness: 180, damping: 22 }}
303
+ >
304
+ <div
305
+ className="pointer-events-none absolute inset-0 opacity-40"
306
+ style={{ background: `radial-gradient(circle at 80% 0%, ${accent}55, transparent 38%)` }}
307
+ />
308
+ <div className="relative">
309
+ <p className="font-mono text-[0.6rem] uppercase tracking-[0.24em] text-white/55">Technical claim</p>
310
+ <p className="mt-3 text-2xl font-semibold leading-tight md:text-3xl">{chapter.technicalDetail}</p>
311
+ <div className="mt-7 grid gap-2">
312
+ {chapter.features.map((feature) => (
313
+ <div
314
+ key={feature}
315
+ className="flex items-center justify-between border-t border-white/15 py-3 font-mono text-[0.7rem] uppercase tracking-[0.16em] text-white/70"
316
+ >
317
+ <span>{feature}</span>
318
+ <span style={{ color: accent }}>active</span>
319
+ </div>
320
+ ))}
321
+ </div>
322
+ </div>
323
+ </motion.div>
324
+ );
325
+ }
326
+
327
+ // ─── Keyframe library — depths align with the documented depth chart ──────
328
+
329
+ const layer1Atmosphere = [
330
+ { at: 0, y: '-2%', scale: 1.04, opacity: 0.95 },
331
+ { at: 0.5, y: '0%', scale: 1.02, opacity: 1 },
332
+ { at: 1, y: '2%', scale: 1.04, opacity: 0.95 },
333
+ ];
334
+
335
+ const layer2MidFar = [
336
+ { at: 0, y: '-4%', scale: 1.05, opacity: 0.85 },
337
+ { at: 0.5, y: '0%', scale: 1.02, opacity: 1 },
338
+ { at: 1, y: '4%', scale: 1.05, opacity: 0.85 },
339
+ ];
340
+
341
+ const layer3Background = [
342
+ { at: 0, y: '-6%', scale: 1.08, opacity: 0.9 },
343
+ { at: 0.5, y: '0%', scale: 1.02, opacity: 1 },
344
+ { at: 1, y: '6%', scale: 1.08, opacity: 0.9 },
345
+ ];
346
+
347
+ const layer4Subject = [
348
+ { at: 0, y: '10%', scale: 0.96, opacity: 0 },
349
+ { at: 0.22, y: '0%', scale: 1, opacity: 1 },
350
+ { at: 0.78, y: '0%', scale: 1, opacity: 1 },
351
+ { at: 1, y: '-8%', scale: 0.98, opacity: 0 },
352
+ ];
353
+
354
+ const layer5UI = [
355
+ { at: 0, y: '14%', scale: 0.96, opacity: 0 },
356
+ { at: 0.25, y: '0%', scale: 1, opacity: 1 },
357
+ { at: 0.78, y: '0%', scale: 1, opacity: 1 },
358
+ { at: 1, y: '-10%', scale: 0.98, opacity: 0 },
359
+ ];
360
+
361
+ const layer6Accent = [
362
+ { at: 0, y: '20%', scale: 0.94, opacity: 0 },
363
+ { at: 0.32, y: '0%', scale: 1, opacity: 1 },
364
+ { at: 0.7, y: '0%', scale: 1, opacity: 1 },
365
+ { at: 1, y: '-12%', scale: 0.98, opacity: 0 },
366
+ ];
367
+
368
+ const layer7Closest = [
369
+ { at: 0, y: '-4%', opacity: 0 },
370
+ { at: 0.2, y: '0%', opacity: 1 },
371
+ { at: 0.85, y: '0%', opacity: 1 },
372
+ { at: 1, y: '-4%', opacity: 0 },
373
+ ];