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.
- package/COMPATIBILITY.md +244 -0
- package/LICENSE +21 -0
- package/MODELS.md +92 -0
- package/README.md +250 -0
- package/SKILL.md +1003 -0
- package/audit-mode.md +497 -0
- package/bin/install.mjs +91 -0
- package/compile-choreography.mjs +296 -0
- package/decision-log.md +241 -0
- package/examples/GETTING_STARTED.md +279 -0
- package/examples/KNOWN_ISSUES.md +50 -0
- package/examples/PROMPTS.md +166 -0
- package/examples/luxe/README.md +88 -0
- package/examples/luxe/index.html +662 -0
- package/examples/noir/README.md +72 -0
- package/examples/noir/index.html +634 -0
- package/examples/pop/README.md +81 -0
- package/examples/pop/index.html +711 -0
- package/examples/renaissance/README.md +39 -0
- package/examples/renaissance/index.html +648 -0
- package/examples/studio/README.md +77 -0
- package/examples/studio/chapters.js +105 -0
- package/examples/studio/index.html +520 -0
- package/manifest.json +92 -0
- package/manifest.md +136 -0
- package/package.json +56 -0
- package/references/film-archetypes.md +211 -0
- package/references/performance-budget.md +499 -0
- package/references/scroll-patterns.md +693 -0
- package/scroll-choreography-compilation.md +543 -0
- package/scroll-choreography.json +1512 -0
- package/taste-guardrails.md +164 -0
- package/templates/nextjs/.env.example +41 -0
- package/templates/nextjs/app/api/fal/proxy/route.ts +33 -0
- package/templates/nextjs/app/api/fal/webhook/route.ts +132 -0
- package/templates/nextjs/app/api/generate-edition-asset/route.ts +66 -0
- package/templates/nextjs/app/globals.css +80 -0
- package/templates/nextjs/app/layout.tsx +21 -0
- package/templates/nextjs/app/page.tsx +10 -0
- package/templates/nextjs/components/ChapterDemoVisual.tsx +212 -0
- package/templates/nextjs/components/ChapterScene.tsx +373 -0
- package/templates/nextjs/components/EditionsPage.tsx +116 -0
- package/templates/nextjs/components/SmoothScrollProvider.tsx +8 -0
- package/templates/nextjs/lib/api-guard.ts +110 -0
- package/templates/nextjs/lib/editions-manifest.ts +224 -0
- package/templates/nextjs/lib/fal-client.ts +12 -0
- package/templates/nextjs/lib/fal-generate.ts +86 -0
- package/templates/nextjs/lib/fal-models.ts +213 -0
- package/templates/nextjs/lib/prompt-contract.ts +97 -0
- package/templates/nextjs/lib/use-device.ts +42 -0
- package/templates/nextjs/lib/use-lenis.ts +35 -0
- package/templates/nextjs/next.config.ts +29 -0
- package/templates/nextjs/package-lock.json +6455 -0
- package/templates/nextjs/package.json +41 -0
- package/templates/nextjs/package.patch.json +28 -0
- package/templates/nextjs/postcss.config.js +6 -0
- package/templates/nextjs/scripts/generate-chapter-assets.mjs +243 -0
- package/templates/nextjs/scripts/setup.mjs +170 -0
- package/templates/nextjs/tailwind.config.ts +37 -0
- package/templates/nextjs/tsconfig.json +23 -0
- 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
|
+
|
|
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
|
+
];
|