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,634 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>VANTASCOPE — HOLLOW STAR · cinematic-scroll example</title>
7
+ <meta name="description" content="A cinematic sci-fi noir scroll page — near-black + deep teal atmosphere, crimson edge-light, heavy film grain, a figure-in-fog parallax still with a scroll-driven 3D camera. A flagship example for the cinematic-scroll Agent Skill." />
8
+
9
+ <!-- Open Graph -->
10
+ <meta property="og:title" content="VANTASCOPE — HOLLOW STAR" />
11
+ <meta property="og:description" content="Cinematic sci-fi noir scroll — teal fog, crimson edge-light, film grain, 3D camera. Built with the cinematic-scroll Agent Skill." />
12
+ <meta property="og:type" content="website" />
13
+ <meta name="theme-color" content="#0A0C0F" />
14
+
15
+ <link rel="preconnect" href="https://fonts.googleapis.com">
16
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
17
+ <!-- Oswald (condensed film-poster display) + Archivo (UI) + JetBrains Mono (labels) -->
18
+ <link href="https://fonts.googleapis.com/css2?family=Oswald:wght@300;500;600;700&family=Archivo:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
19
+
20
+ <!-- GSAP + ScrollTrigger (now 100% free) — powers the dedicated "Approach" showcase:
21
+ a DOLLY-ZOOM (Vertigo) + a SCRUBBED SIGNAL DRAW. Deferred + feature-detected:
22
+ if the CDN fails, the hand-rolled rAF cinematic engine below still runs. -->
23
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script>
24
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js"></script>
25
+
26
+ <style>
27
+ /* ───────────────────────────────────────────────────────────────
28
+ cinematic-scroll · CINEMATIC SCI-FI NOIR example
29
+ Visual system: Atmospheric Sublime — vast negative space, atmospheric
30
+ haze, slow revelation — with Temporal Monument chiaroscuro contrast.
31
+ Single-file, no build, GitHub-Pages friendly. Only external resource:
32
+ Google Fonts. Motion is COMPOSITOR-ONLY (transform + opacity).
33
+ Title treatment = vertical clip-path MASK WIPE (not a cross-fade).
34
+ Reduced-motion + touch + mobile all degrade to a clean static mid-state.
35
+ ─────────────────────────────────────────────────────────────── */
36
+
37
+ :root{
38
+ --black:#0A0C0F; /* near-black base */
39
+ --teal-deep:#0B1A1F; /* deep teal atmosphere floor */
40
+ --teal:#14343C; /* teal mid */
41
+ --teal-lit:#1E5460; /* teal highlight */
42
+ --crimson:#E23A4E; /* the single accent — edge-light */
43
+ --crimson-2:#FF566B; /* brighter crimson for glow cores */
44
+ --ink:#E9ECEE; /* off-white type */
45
+ --muted:#7E8C92; /* muted teal-grey body */
46
+ --line:rgba(233,236,238,.14);
47
+
48
+ --display:'Oswald',Impact,'Haettenschweiler','Arial Narrow Bold',sans-serif;
49
+ --ui:'Archivo',system-ui,sans-serif;
50
+ --mono:'JetBrains Mono',ui-monospace,Menlo,monospace;
51
+
52
+ --bg:var(--black);
53
+ --fg:var(--ink);
54
+ --fg-muted:var(--muted);
55
+ }
56
+ *{margin:0;padding:0;box-sizing:border-box}
57
+ html{scroll-behavior:smooth}
58
+ body{
59
+ background:var(--bg); color:var(--fg);
60
+ font-family:var(--ui); font-weight:400;
61
+ -webkit-font-smoothing:antialiased; overflow-x:hidden;
62
+ transition:background .9s cubic-bezier(.22,1,.36,1);
63
+ }
64
+ a{color:inherit}
65
+ ::selection{ background:var(--crimson); color:#fff; }
66
+
67
+ /* ── fixed atmosphere: deep-teal vignette + grain (lowest depth) ──── */
68
+ .atmos{
69
+ position:fixed; inset:0; z-index:-2; pointer-events:none;
70
+ background:
71
+ radial-gradient(130% 100% at 78% 8%, rgba(30,84,96,.34), rgba(30,84,96,0) 55%),
72
+ radial-gradient(120% 90% at 18% 100%, rgba(20,52,60,.42), rgba(11,26,31,0) 60%),
73
+ radial-gradient(80% 60% at 92% 88%, rgba(226,58,78,.10), rgba(226,58,78,0) 55%),
74
+ linear-gradient(180deg, var(--black) 0%, var(--teal-deep) 58%, var(--black) 100%);
75
+ transition:background .9s cubic-bezier(.22,1,.36,1);
76
+ }
77
+ /* heavy film grain overlay (inline SVG noise — animation-free, GPU-cheap) */
78
+ .grain{
79
+ position:fixed; inset:-50%; z-index:-1; pointer-events:none; opacity:.16;
80
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
81
+ animation:grainshift 5.2s steps(6) infinite;
82
+ }
83
+ @keyframes grainshift{
84
+ 0%{transform:translate(0,0)} 16%{transform:translate(-3%,2%)}
85
+ 33%{transform:translate(2%,-3%)} 50%{transform:translate(-2%,-2%)}
86
+ 66%{transform:translate(3%,1%)} 83%{transform:translate(-1%,3%)} 100%{transform:translate(0,0)}
87
+ }
88
+ /* thin crimson edge-light hairline framing the whole viewport */
89
+ .edge{ position:fixed; inset:0; z-index:45; pointer-events:none;
90
+ box-shadow:inset 0 0 0 1px rgba(226,58,78,.10), inset 0 0 120px rgba(8,10,14,.7); }
91
+
92
+ /* ── fixed masthead ───────────────────────────────────────────── */
93
+ .masthead{
94
+ position:fixed; inset:0 0 auto 0; z-index:50;
95
+ display:flex; align-items:center; justify-content:space-between;
96
+ padding:max(20px,env(safe-area-inset-top)) clamp(18px,4vw,56px) 20px;
97
+ }
98
+ .masthead .mark{ display:flex; align-items:baseline; gap:12px; pointer-events:auto; }
99
+ .masthead .mark b{ font-family:var(--display); font-weight:700; font-size:18px; letter-spacing:.26em; color:var(--ink); }
100
+ .masthead .mark span{ font-family:var(--mono); font-size:9px; letter-spacing:.3em; text-transform:uppercase; color:var(--crimson); }
101
+ .masthead nav{ display:flex; gap:clamp(14px,2.4vw,34px); font-family:var(--mono); font-size:10px; letter-spacing:.22em; text-transform:uppercase; pointer-events:auto; }
102
+ .masthead nav a{ color:var(--muted); text-decoration:none; transition:color .3s; }
103
+ .masthead nav a:hover{ color:var(--ink); }
104
+ @media (max-width:680px){ .masthead nav{ display:none; } }
105
+
106
+ /* ── progress + index rail ────────────────────────────────────── */
107
+ .rail{
108
+ position:fixed; right:clamp(12px,2vw,28px); top:50%; transform:translateY(-50%);
109
+ z-index:40; display:flex; flex-direction:column; gap:14px;
110
+ font-family:var(--mono); font-size:10px; color:var(--muted);
111
+ }
112
+ .rail .r{ display:flex; align-items:center; gap:10px; justify-content:flex-end; opacity:.55; transition:opacity .35s, color .35s; }
113
+ .rail .r .num{ letter-spacing:.12em; }
114
+ .rail .r .ln{ width:20px; height:1px; background:currentColor; transition:width .35s, background .35s; }
115
+ .rail .r.on{ opacity:1; color:var(--ink); }
116
+ .rail .r.on .ln{ width:46px; height:2px; background:var(--crimson); }
117
+ /* top-of-page scroll progress bar */
118
+ .scrollbar{ position:fixed; top:0; left:0; height:2px; width:0%; z-index:60;
119
+ background:linear-gradient(90deg, var(--crimson), var(--crimson-2)); box-shadow:0 0 12px rgba(226,58,78,.6); }
120
+ @media (max-width:680px){ .rail{ display:none; } }
121
+
122
+ /* ── chapter section ──────────────────────────────────────────── */
123
+ section{ position:relative; min-height:230vh; } /* pin room */
124
+ .stage{
125
+ position:sticky; top:0; height:100vh; overflow:hidden;
126
+ display:grid; grid-template-columns:1fr; align-content:center;
127
+ padding:0 clamp(18px,5vw,80px);
128
+ perspective:1500px; perspective-origin:62% 48%; /* scroll-driven 3D camera */
129
+ }
130
+ .figwrap,.copytext,.cuewrap{ transform-style:preserve-3d; }
131
+ .figwrap .frame{ transform-style:preserve-3d; }
132
+
133
+ /* oversized Roman-numeral watermark — deepest ghost layer */
134
+ .roman{
135
+ position:absolute; z-index:1; font-family:var(--display); font-weight:700;
136
+ font-size:clamp(14rem,52vw,46rem); line-height:.7; letter-spacing:-.02em;
137
+ color:var(--ink); opacity:.045; white-space:nowrap; pointer-events:none;
138
+ top:50%; left:46%; transform:translate(-50%,-50%); will-change:transform;
139
+ -webkit-user-select:none; user-select:none;
140
+ }
141
+
142
+ /* figure-in-fog framed parallax still */
143
+ .figwrap{ position:absolute; z-index:3; right:clamp(18px,5vw,80px); top:50%;
144
+ width:clamp(280px,40vw,580px); aspect-ratio:4/5; transform:translateY(-50%);
145
+ will-change:transform,opacity; }
146
+ .frame{ position:relative; width:100%; height:100%; }
147
+ .pic{
148
+ position:absolute; inset:0; background-size:cover; background-position:center 35%;
149
+ background-color:var(--teal-deep);
150
+ box-shadow:0 40px 90px rgba(0,0,0,.6), 0 0 1px rgba(226,58,78,.5);
151
+ transition:transform .25s ease-out;
152
+ }
153
+ /* CSS-only placeholder visual — figure-in-fog, teal + crimson edge-light */
154
+ .pic.placeholder{
155
+ background-image:
156
+ /* crimson rim along the right edge of the "figure" */
157
+ radial-gradient(60% 120% at 88% 50%, rgba(226,58,78,.55) 0%, rgba(226,58,78,0) 42%),
158
+ /* the silhouetted figure mass, lit from teal behind */
159
+ radial-gradient(46% 78% at 50% 64%, rgba(8,10,13,.96) 0%, rgba(8,10,13,.7) 40%, rgba(8,10,13,0) 70%),
160
+ /* teal atmospheric backlight / fog */
161
+ radial-gradient(90% 80% at 38% 22%, rgba(30,84,96,.7) 0%, rgba(20,52,60,.3) 45%, rgba(11,26,31,0) 75%),
162
+ /* fog banding */
163
+ repeating-linear-gradient(8deg, rgba(20,52,60,.10) 0 7px, rgba(11,26,31,.04) 7px 20px),
164
+ linear-gradient(160deg, var(--teal) 0%, var(--teal-deep) 55%, var(--black) 100%);
165
+ }
166
+ .pic::after{ /* grain locked to the still */
167
+ content:""; position:absolute; inset:0; opacity:.4; mix-blend-mode:overlay;
168
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)' opacity='0.4'/%3E%3C/svg%3E");
169
+ pointer-events:none;
170
+ }
171
+ /* crimson edge-light streak baked over the frame */
172
+ .frame .rim{ position:absolute; top:8%; right:-1px; width:2px; height:84%;
173
+ background:linear-gradient(180deg, rgba(226,58,78,0), var(--crimson) 40%, var(--crimson-2) 55%, rgba(226,58,78,0));
174
+ box-shadow:0 0 18px rgba(226,58,78,.8); pointer-events:none; }
175
+ .figcap{ position:absolute; left:0; bottom:-26px; display:flex; justify-content:space-between; width:100%;
176
+ font-family:var(--mono); font-size:10px; letter-spacing:.18em; color:var(--muted); }
177
+ .figcap .n{ color:var(--crimson); }
178
+ /* corner brackets */
179
+ .frame .br{ position:absolute; width:26px; height:26px; border:1.5px solid var(--crimson); opacity:.85; }
180
+ .frame .br.tl{ top:-1px; left:-1px; border-right:0; border-bottom:0; }
181
+ .frame .br.tr{ top:-1px; right:-1px; border-left:0; border-bottom:0; }
182
+ .frame .br.bl{ bottom:-1px; left:-1px; border-right:0; border-top:0; }
183
+ .frame .br.brc{ bottom:-1px; right:-1px; border-left:0; border-top:0; }
184
+
185
+ /* copy / title — the film-poster type */
186
+ .copytext{ position:relative; z-index:6; max-width:min(92vw,1180px); will-change:transform,opacity; }
187
+ .eyebrow{ font-family:var(--mono); font-size:clamp(10px,1vw,12px); letter-spacing:.36em; text-transform:uppercase; color:var(--crimson); margin-bottom:clamp(16px,2.4vw,28px); display:flex; align-items:center; gap:12px; }
188
+ .eyebrow::before{ content:""; width:34px; height:1px; background:var(--crimson); display:inline-block; }
189
+ h2.title{
190
+ font-family:var(--display); font-weight:700; line-height:.88;
191
+ letter-spacing:.005em; text-transform:uppercase; color:var(--ink);
192
+ font-size:clamp(3rem,12vw,11rem);
193
+ }
194
+ /* each line wears a vertical clip-path MASK WIPE (top→bottom reveal) */
195
+ h2.title .ln{ display:block; overflow:hidden; }
196
+ h2.title .ln .t{ display:inline-block; will-change:transform,clip-path; }
197
+ h2.title .accent{ color:var(--crimson); }
198
+ .lede{ margin-top:clamp(22px,2.8vw,36px); max-width:48ch; font-size:clamp(1rem,1.5vw,1.28rem);
199
+ line-height:1.62; color:var(--fg-muted); font-weight:400; }
200
+ .lede .reveal{ display:inline; }
201
+ .meta{ margin-top:clamp(18px,2.4vw,30px); font-family:var(--mono); font-size:10px; letter-spacing:.2em;
202
+ text-transform:uppercase; color:var(--muted); }
203
+ .cta{ margin-top:clamp(26px,3vw,40px); display:inline-flex; align-items:center; gap:12px;
204
+ padding:14px 28px; background:var(--crimson); color:#0A0C0F;
205
+ font-family:var(--mono); font-size:11px; letter-spacing:.2em; text-transform:uppercase;
206
+ text-decoration:none; box-shadow:0 0 30px rgba(226,58,78,.45); transition:transform .3s, box-shadow .3s; }
207
+ .cta:hover{ transform:translateY(-2px); box-shadow:0 0 44px rgba(226,58,78,.7); }
208
+
209
+ /* scroll-cue badge (hero only) */
210
+ .cuewrap{ position:absolute; z-index:7; left:50%; bottom:clamp(26px,5vh,52px); transform:translateX(-50%);
211
+ display:flex; flex-direction:column; align-items:center; gap:9px; will-change:transform,opacity; }
212
+ .cue{ font-family:var(--mono); font-size:9px; letter-spacing:.3em; text-transform:uppercase; color:var(--muted);
213
+ border:1px solid var(--line); border-radius:999px; padding:8px 16px;
214
+ display:flex; align-items:center; gap:9px; background:rgba(10,12,15,.4); backdrop-filter:blur(4px); }
215
+ .cue .dot{ width:6px; height:6px; border-radius:50%; background:var(--crimson); box-shadow:0 0 10px var(--crimson); animation:pulse 1.9s ease-in-out infinite; }
216
+ .cuewrap .bar{ width:1px; height:34px; background:linear-gradient(var(--crimson), transparent); transform-origin:top; animation:cuebar 2.2s ease-in-out infinite; }
217
+ @keyframes pulse{ 0%,100%{opacity:1} 50%{opacity:.3} }
218
+ @keyframes cuebar{ 0%,100%{transform:scaleY(1)} 50%{transform:scaleY(.4)} }
219
+
220
+ /* ── footer / colophon ────────────────────────────────────────── */
221
+ footer{ position:relative; z-index:2; padding:16vh clamp(18px,5vw,80px) 14vh; }
222
+ footer .vine{ width:120px; height:1px; margin-bottom:30px; background:linear-gradient(90deg, var(--crimson), transparent); }
223
+ footer .big{ font-family:var(--display); font-weight:600; text-transform:uppercase;
224
+ font-size:clamp(2rem,8vw,5.6rem); line-height:.92; letter-spacing:.01em; color:var(--ink); }
225
+ footer .big a{ color:var(--crimson); text-decoration:none; }
226
+ footer .meta{ margin-top:28px; font-family:var(--mono); font-size:11px; letter-spacing:.16em; color:var(--muted); text-transform:uppercase; max-width:62ch; line-height:1.8; }
227
+ footer .meta a{ color:var(--crimson); text-decoration:none; border-bottom:1px solid rgba(226,58,78,.3); }
228
+
229
+ /* ── mobile (<=680px): clean static mid-state, no pin / no scrolljack ── */
230
+ @media (max-width:680px){
231
+ .grain{ display:none; }
232
+ section{ min-height:auto; padding:13vh 0; }
233
+ .stage{ position:static; height:auto; display:block; padding:0 22px; perspective:none; }
234
+ .roman{ position:static; transform:none!important; opacity:.06; font-size:32vw; line-height:.8; margin:0 0 -6vh -2vw; display:block; }
235
+ .figwrap{ position:static; width:100%; transform:none!important; margin:30px 0 44px; aspect-ratio:16/11; opacity:1!important; }
236
+ .copytext{ transform:none!important; opacity:1!important; }
237
+ h2.title{ font-size:15vw; }
238
+ h2.title .ln .t{ transform:none!important; clip-path:none!important; opacity:1!important; }
239
+ .cuewrap{ position:static; transform:none!important; margin:34px auto 0; opacity:1!important; }
240
+ .reveal{ opacity:0; transform:translateY(22px); transition:opacity .7s ease, transform .7s cubic-bezier(.16,1,.3,1); }
241
+ .reveal.in{ opacity:1; transform:none; }
242
+ }
243
+
244
+ /* ── reduced motion: snap to a readable mid-state, no scroll-driven motion ── */
245
+ @media (prefers-reduced-motion:reduce){
246
+ html{ scroll-behavior:auto; }
247
+ .grain,.cue .dot,.cuewrap .bar{ animation:none; }
248
+ section{ min-height:auto; padding:11vh 0; }
249
+ .stage{ position:static; height:auto; display:block; perspective:none; }
250
+ .roman{ position:static; transform:none!important; opacity:.06; font-size:clamp(8rem,26vw,18rem); line-height:.8; display:block; margin-bottom:-4vh; }
251
+ .figwrap{ position:static; width:min(560px,92vw); transform:none!important; margin:30px 0 44px; opacity:1!important; }
252
+ .copytext{ transform:none!important; opacity:1!important; }
253
+ h2.title .ln .t{ transform:none!important; clip-path:none!important; opacity:1!important; }
254
+ .cuewrap{ position:static; transform:none!important; margin-top:30px; opacity:1!important; }
255
+ .reveal{ opacity:1!important; transform:none!important; }
256
+ }
257
+
258
+ /* ── GSAP SHOWCASE — "THE APPROACH" ─────────────────────────────────
259
+ A dedicated viewport that demonstrates two techniques the hand-rolled
260
+ engine doesn't: a DOLLY-ZOOM (Vertigo — taste-guardrails §2) on the
261
+ backdrop and a SCRUBBED SIGNAL DRAW (stroke-dashoffset, pattern #2).
262
+ GSAP pins .show-stage; static defaults below keep it whole when GSAP is
263
+ absent / reduced-motion / mobile (dolly at rest, waveform fully drawn). */
264
+ section.showcase{ min-height:0; } /* GSAP pin owns the scroll room */
265
+ .show-stage{ position:relative; height:100vh; overflow:hidden; display:grid; place-items:center; }
266
+ .show-stage .dolly{
267
+ position:absolute; inset:-12%; z-index:0; transform-origin:60% 46%; will-change:transform;
268
+ background:
269
+ radial-gradient(56% 88% at 50% 60%, rgba(8,10,13,0) 0%, rgba(8,10,13,.55) 72%),
270
+ radial-gradient(72% 60% at 50% 28%, rgba(30,84,96,.50), rgba(11,26,31,0) 70%),
271
+ radial-gradient(40% 78% at 50% 54%, rgba(226,58,78,.16), rgba(226,58,78,0) 60%),
272
+ linear-gradient(180deg, var(--black) 0%, var(--teal-deep) 60%, var(--black) 100%);
273
+ }
274
+ .show-stage .waveform{ position:relative; z-index:2; width:min(80vw,1120px); height:140px; overflow:visible; }
275
+ .show-stage .waveform path{ fill:none; stroke:var(--crimson); stroke-width:2; stroke-linecap:round;
276
+ filter:drop-shadow(0 0 6px rgba(226,58,78,.5)); } /* static (non-animated) glow — allowed */
277
+ .show-cap{ position:absolute; z-index:3; bottom:14vh; left:0; right:0; text-align:center;
278
+ font-family:var(--mono); color:var(--muted); }
279
+ .show-cap b{ display:block; font-family:var(--display); font-weight:700; letter-spacing:.28em;
280
+ color:var(--ink); font-size:clamp(1.4rem,4vw,2.4rem); margin-bottom:10px; }
281
+ .show-cap span{ font-size:11px; letter-spacing:.22em; text-transform:uppercase; }
282
+ @media (max-width:680px){
283
+ .show-stage{ height:auto; padding:16vh 22px; }
284
+ .show-stage .dolly{ inset:0; }
285
+ .show-cap{ position:static; margin-top:30px; }
286
+ }
287
+ </style>
288
+ </head>
289
+ <body>
290
+
291
+ <div class="atmos" id="atmos" aria-hidden="true"></div>
292
+ <div class="grain" aria-hidden="true"></div>
293
+ <div class="edge" aria-hidden="true"></div>
294
+ <div class="scrollbar" id="scrollbar" aria-hidden="true"></div>
295
+
296
+ <header class="masthead">
297
+ <div class="mark">
298
+ <b>VANTASCOPE</b>
299
+ <span>Pictures · Interactive</span>
300
+ </div>
301
+ <nav aria-label="Primary">
302
+ <a href="#signal">Signal</a>
303
+ <a href="#descent">Descent</a>
304
+ <a href="#witness">Witness</a>
305
+ <a href="#access">Request Access</a>
306
+ </nav>
307
+ </header>
308
+
309
+ <nav class="rail" id="rail" aria-label="Chapters"></nav>
310
+
311
+ <main id="reel"></main>
312
+
313
+ <footer id="access">
314
+ <div class="vine"></div>
315
+ <div class="big">A signal was sent.<br>Something <a href="mailto:transmission@example.com">answered →</a></div>
316
+ <div class="meta">
317
+ Built with the <a href="https://github.com/MustBeSimo/cinematic-scroll-skill">cinematic-scroll</a> Agent Skill ·
318
+ VANTASCOPE is a fictional studio — no real titles, people, or brands ·
319
+ one of many possible aesthetics this skill can direct.
320
+ </div>
321
+ </footer>
322
+
323
+ <script>
324
+ /* ───────────────────────────────────────────────────────────────
325
+ Manifest — single source of truth. Edit this array to retheme;
326
+ the DOM and the motion are generated from it. Fictional studio.
327
+ Each chapter:
328
+ id slug → asset filename (assets/<id>.jpg) + section anchor
329
+ roman oversized watermark numeral (ghost layer)
330
+ eyebrow small mono label
331
+ fig/figLabel bottom caption on the framed still
332
+ title array of LINES; each line = [text, accent?] (accent → crimson)
333
+ lede supporting paragraph (sci-fi noir voice)
334
+ morph teal-atmosphere gradient this chapter morphs TO
335
+ cue true → render the scroll-cue badge (hero only)
336
+ cta optional CTA label (final chapter)
337
+ prompt image-generation prompt (noir still; NO baked-in text)
338
+ ─────────────────────────────────────────────────────────────── */
339
+ const CHAPTERS = [
340
+ {
341
+ id:'0-signal', roman:'I', eyebrow:'VANTASCOPE · TRANSMISSION MMXXVI',
342
+ fig:'PLATE I', figLabel:'STANDING FIGURE, IN FOG',
343
+ title:[['HOLLOW',0],['STAR',1]],
344
+ lede:'Forty years after the last carrier went dark, a single coherent pulse climbs out of the noise floor. It is not a beacon. It is a name — and it is calling one of us back.',
345
+ morph:'radial-gradient(130% 100% at 78% 8%, rgba(30,84,96,.40), rgba(30,84,96,0) 55%), radial-gradient(120% 90% at 18% 100%, rgba(20,52,60,.46), rgba(11,26,31,0) 60%), linear-gradient(180deg, #0A0C0F 0%, #0B1A1F 58%, #0A0C0F 100%)',
346
+ cue:true,
347
+ prompt:'Cinematic sci-fi noir still: a lone figure in a long coat seen from behind, standing in thick teal volumetric fog, lit by a single hard crimson rim-light along one edge, vast empty hangar receding into darkness, anamorphic lens, heavy film grain, near-black shadows, no text, no logos, Atmospheric Sublime mood, 4:5'
348
+ },
349
+ {
350
+ id:'1-descent', roman:'II', eyebrow:'CHAPTER II · THE DESCENT',
351
+ fig:'PLATE II', figLabel:'CORRIDOR, EMERGENCY AMBER',
352
+ title:[['INTO THE',0],['QUIET DARK',1]],
353
+ lede:'The derelict has no power, no heat, no record of ever having launched. Yet the corridors are warm where a body has just passed. We go down because the pulse is below us — and because turning back was never an option that survived the briefing.',
354
+ morph:'radial-gradient(120% 100% at 20% 12%, rgba(20,52,60,.50), rgba(11,26,31,0) 58%), radial-gradient(90% 70% at 88% 92%, rgba(226,58,78,.14), rgba(226,58,78,0) 55%), linear-gradient(180deg, #090B0E 0%, #0A1418 60%, #08090C 100%)',
355
+ prompt:'Cinematic sci-fi noir still: a narrow derelict-spaceship corridor descending into blackness, faint emergency crimson strip-lighting, teal cold haze drifting through the frame, a silhouetted figure small at the far end, wet metal reflections, extreme contrast, heavy grain, no text, no logos, anamorphic, 4:5'
356
+ },
357
+ {
358
+ id:'2-witness', roman:'III', eyebrow:'CHAPTER III · THE WITNESS',
359
+ fig:'PLATE III', figLabel:'FACE, HALF-LIT',
360
+ title:[['IT REMEMBERS',0],['YOUR NAME',1]],
361
+ lede:'Whatever answered the signal was not built and was not born. It wears the architecture like a held breath, and when it finally turns toward the light it is wearing a face you buried four decades ago — patient, unhurried, almost kind.',
362
+ morph:'radial-gradient(130% 100% at 70% 10%, rgba(30,84,96,.30), rgba(30,84,96,0) 52%), radial-gradient(100% 80% at 30% 96%, rgba(226,58,78,.22), rgba(226,58,78,0) 58%), linear-gradient(180deg, #0A0C0F 0%, #120A0E 64%, #08090C 100%)',
363
+ prompt:'Cinematic sci-fi noir still: extreme close portrait of a face half-lit, one side in deep teal shadow, the other catching a thin crimson edge-light, fog particles suspended in the air, unreadable expression, anamorphic, ultra high contrast chiaroscuro, heavy film grain, no text, no logos, 4:5'
364
+ },
365
+ {
366
+ id:'3-access', roman:'IV', eyebrow:'CHAPTER IV · THE OFFER',
367
+ fig:'PLATE IV', figLabel:'OPEN AIRLOCK, BACKLIT',
368
+ title:[['STAY OR',0],['ANSWER IT',1]],
369
+ lede:'The airlock is open. On one side, the long cold ride home and a report no one will believe. On the other, the thing wearing your past, holding the door, asking only that you walk through it of your own accord. VANTASCOPE invites you to decide on the descent.',
370
+ morph:'radial-gradient(130% 100% at 80% 8%, rgba(30,84,96,.36), rgba(30,84,96,0) 55%), radial-gradient(110% 90% at 22% 100%, rgba(226,58,78,.18), rgba(226,58,78,0) 58%), linear-gradient(180deg, #0A0C0F 0%, #0B1A1F 56%, #0A0C0F 100%)',
371
+ cta:'Request early access',
372
+ prompt:'Cinematic sci-fi noir still: a backlit open airlock doorway, blinding teal-white light pouring through fog, a silhouetted figure standing in the threshold framed by crimson edge-light, vast darkness around the frame, anamorphic flares, heavy grain, ultra contrast, no text, no logos, 4:5'
373
+ },
374
+ ];
375
+
376
+ const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
377
+ const smooth=(e0,e1,x)=>{ if(x<=e0)return 0; if(x>=e1)return 1; const t=(x-e0)/(e1-e0); return t*t*(3-2*t); };
378
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
379
+ const isTouch = matchMedia('(hover: none) and (pointer: coarse)').matches;
380
+ const isMobile = () => innerWidth <= 680;
381
+
382
+ const reel = document.getElementById('reel');
383
+ const rail = document.getElementById('rail');
384
+ const atmos = document.getElementById('atmos');
385
+ const bar = document.getElementById('scrollbar');
386
+
387
+ /* preflight: which real images exist? → fall back to CSS placeholder */
388
+ function probe(src){ return new Promise(res=>{ const i=new Image(); i.onload=()=>res(true); i.onerror=()=>res(false); i.src=src; }); }
389
+
390
+ (async () => {
391
+ for (const ch of CHAPTERS){
392
+ const sec = document.createElement('section');
393
+ sec.id = ch.id.replace(/^\d+-/,'');
394
+ sec.dataset.morph = ch.morph;
395
+
396
+ /* title: one block per LINE, each line a vertical clip-path mask wipe */
397
+ const titleHTML = ch.title.map(([txt,acc]) =>
398
+ `<span class="ln"><span class="t${acc?' accent':''}">${txt}</span></span>`
399
+ ).join('');
400
+
401
+ const imgPath = `assets/${ch.id}.jpg`;
402
+ const hasImg = await probe(imgPath);
403
+ const picStyle = hasImg ? `background-image:url('${imgPath}')` : '';
404
+ const picClass = hasImg ? 'pic' : 'pic placeholder';
405
+
406
+ sec.innerHTML = `
407
+ <div class="stage">
408
+ <div class="roman" data-depth="0.22" aria-hidden="true">${ch.roman}</div>
409
+ <div class="figwrap" data-depth="0.75">
410
+ <div class="frame">
411
+ <span class="br tl"></span><span class="br tr"></span><span class="br bl"></span><span class="br brc"></span>
412
+ <div class="${picClass}" style="${picStyle}" role="img" aria-label="${ch.figLabel}"></div>
413
+ <span class="rim"></span>
414
+ <div class="figcap"><span class="n">${ch.fig}</span><span>${ch.figLabel}</span></div>
415
+ </div>
416
+ </div>
417
+ <div class="copytext" data-depth="1.0">
418
+ <div class="eyebrow reveal">${ch.eyebrow}</div>
419
+ <h2 class="title">${titleHTML}</h2>
420
+ <p class="lede"><span class="reveal">${ch.lede}</span></p>
421
+ ${ch.cta
422
+ ? `<a class="cta reveal" href="mailto:transmission@example.com?subject=VANTASCOPE%20access">${ch.cta} →</a>`
423
+ : `<p class="meta reveal">${ch.fig} · ${ch.figLabel}</p>`}
424
+ </div>
425
+ ${ch.cue ? `<div class="cuewrap" data-depth="1.4">
426
+ <div class="cue"><span class="dot"></span>Begin the descent</div>
427
+ <span class="bar"></span>
428
+ </div>` : ''}
429
+ </div>`;
430
+ reel.appendChild(sec);
431
+
432
+ const r = document.createElement('div');
433
+ r.className='r'; r.dataset.id = sec.id;
434
+ r.innerHTML = `<span class="num">${ch.roman}</span><span class="ln"></span>`;
435
+ rail.appendChild(r);
436
+ }
437
+
438
+ /* ── APPEND THE GSAP SHOWCASE BEAT ("THE APPROACH") ──────────────────
439
+ Inserted right after the hero so it reads: signal → the approach →
440
+ descent. Built BEFORE sections/rails are collected so the centre-spy
441
+ and index rail stay aligned. Pure markup here; motion is wired at the
442
+ end (desktop-only, only if GSAP loaded). */
443
+ const show = document.createElement('section');
444
+ show.className = 'showcase'; show.id = 'approach';
445
+ show.dataset.morph = 'radial-gradient(120% 100% at 50% 0%, rgba(30,84,96,.42), rgba(11,26,31,0) 60%), linear-gradient(180deg, #0A0C0F 0%, #0B1A1F 60%, #08090C 100%)';
446
+ show.innerHTML = `
447
+ <div class="show-stage">
448
+ <div class="dolly" aria-hidden="true"></div>
449
+ <svg class="waveform" viewBox="0 0 1120 140" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
450
+ <path d="M0,70 L150,70 L180,70 L196,30 L212,110 L228,52 L244,88 L260,70 L440,70 L470,70 L486,18 L502,122 L518,44 L534,96 L550,70 L760,70 L790,70 L806,40 L822,100 L838,70 L1120,70"/>
451
+ </svg>
452
+ <div class="show-cap">
453
+ <b>THE APPROACH</b>
454
+ <span>A single coherent pulse climbs out of the noise floor</span>
455
+ </div>
456
+ </div>`;
457
+ reel.insertBefore(show, reel.children[1] || null); // after hero (index 0)
458
+ const sr = document.createElement('div');
459
+ sr.className = 'r'; sr.dataset.id = 'approach';
460
+ sr.innerHTML = `<span class="num">&#8767;</span><span class="ln"></span>`;
461
+ rail.insertBefore(sr, rail.children[1] || null);
462
+
463
+ const sections = [...document.querySelectorAll('section')];
464
+ const rails = [...rail.querySelectorAll('.r')];
465
+
466
+ /* rail click → smooth scroll to chapter */
467
+ rails.forEach(r => r.addEventListener('click', () => {
468
+ document.getElementById(r.dataset.id)?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' });
469
+ }));
470
+
471
+ /* background morph + active rail via whichever section crosses screen-center.
472
+ Sections are 230vh (taller than the viewport), so a threshold observer can
473
+ never fire — collapse the root to a 1px band at the vertical center via
474
+ rootMargin: the section straddling the middle of the screen is active. */
475
+ function applyActive(sec){
476
+ const idx = sections.indexOf(sec);
477
+ atmos.style.background = sec.dataset.morph;
478
+ rails.forEach((r,i)=> r.classList.toggle('on', i===idx));
479
+ }
480
+ const spy = new IntersectionObserver((entries)=>{
481
+ entries.forEach(e=>{ if (e.isIntersecting) applyActive(e.target); });
482
+ },{ rootMargin:'-50% 0px -50% 0px', threshold:0 });
483
+ sections.forEach(s=>spy.observe(s));
484
+ applyActive(sections[0]); // paint initial state immediately
485
+
486
+ /* top scroll-progress bar (cheap, transform/width-free: scaleX on a 0%-wide bar
487
+ would need width; instead drive `width` only here — NOT in the parallax loop —
488
+ this runs on a throttled handler and never touches layout of other elements). */
489
+ let pTick=false;
490
+ function progress(){
491
+ const max = document.documentElement.scrollHeight - innerHeight;
492
+ bar.style.transform = `scaleX(${max>0 ? clamp(scrollY/max,0,1):0})`;
493
+ pTick=false;
494
+ }
495
+ bar.style.width='100%'; bar.style.transformOrigin='left center'; bar.style.transform='scaleX(0)';
496
+
497
+ /* mobile / reduced-motion: static mid-state + simple fade-up; skip rAF engine */
498
+ if (reduce || isMobile()){
499
+ const up=new IntersectionObserver((es)=>{es.forEach(e=>{if(e.isIntersecting){e.target.classList.add('in');up.unobserve(e.target);}});},{threshold:.18});
500
+ document.querySelectorAll('.reveal').forEach(el=>up.observe(el));
501
+ addEventListener('scroll',()=>{ if(!pTick){pTick=true;requestAnimationFrame(progress);} },{passive:true});
502
+ progress();
503
+ return; // no pinned scrolljack, no parallax
504
+ }
505
+
506
+ /* ── desktop parallax — COMPOSITOR-ONLY cinematic engine ───────────────
507
+ Cinematic grammar (taste-guardrails §2):
508
+ • CRANE SHOT — figure translateY + rotateX swing as the camera cranes in
509
+ • TRACKING — Roman watermark drifts on a deep counter-axis plane
510
+ • MASK WIPE — each title line revealed via vertical clip-path (NOT cross-fade)
511
+ Unified approach→pin→exit timeline T∈[0,1]; never starts invisible.
512
+ Only transform + opacity (+ clip-path on the title wipe, which is NOT animated
513
+ continuously per-frame — it snaps through discrete reveal steps via smooth()).
514
+ 3D camera disabled on touch / reduced-motion (taste-guardrails §1.9). */
515
+ let mx=0,my=0; // pointer blend for the live 3D camera
516
+ if(!isTouch && !reduce){
517
+ addEventListener('pointermove',e=>{ mx=(e.clientX/innerWidth-0.5); my=(e.clientY/innerHeight-0.5); },{passive:true});
518
+ }
519
+
520
+ let ticking=false;
521
+ function parallax(){
522
+ const vh=innerHeight;
523
+ for(const sec of sections){
524
+ const rect=sec.getBoundingClientRect();
525
+ const total=sec.offsetHeight+vh;
526
+ const T=clamp((vh-rect.top)/total,0,1);
527
+ const enter=smooth(0.06,0.32,T);
528
+ const exit=1-smooth(0.66,0.90,T);
529
+ const live=enter*exit;
530
+ const drift=0.46-T; // signed, ~0 at hold
531
+ const onStage = T>0.04 && T<0.96; // only drive 3D while in view (perf)
532
+
533
+ /* figure-in-fog — crane-in with 3D camera swing (depth 0.75) */
534
+ const fig=sec.querySelector('.figwrap');
535
+ if(fig){ const d=+fig.dataset.depth;
536
+ const z = -120*(1-enter) + drift*110*d; // translateZ dolly
537
+ const y = drift*300*d;
538
+ const sc = 0.92 + 0.08*enter;
539
+ const ry = drift*8 + (onStage ? mx*2.4 : 0); // rotateY ±2° spec
540
+ const rx = -drift*10 + (onStage ? -my*4 : 0); // rotateX ±4° spec
541
+ fig.style.transform =
542
+ `translateY(calc(-50% + ${y.toFixed(1)}px)) translateZ(${z.toFixed(1)}px) `+
543
+ `rotateX(${clamp(rx,-4,4).toFixed(2)}deg) rotateY(${clamp(ry,-2,2).toFixed(2)}deg) scale(${sc.toFixed(3)})`;
544
+ fig.style.opacity=(0.08+0.92*live).toFixed(3);
545
+ }
546
+
547
+ /* Roman watermark — deep tracking drift, counter-axis to the type */
548
+ const roman=sec.querySelector('.roman');
549
+ if(roman){ const d=+roman.dataset.depth;
550
+ roman.style.transform=
551
+ `translate(calc(-50% + ${(drift*480*d).toFixed(0)}px),-50%) translateZ(-420px) scale(${(1.05-0.05*enter).toFixed(3)})`;
552
+ }
553
+
554
+ /* copy block + per-line MASK WIPE (vertical clip-path top→bottom) */
555
+ const copy=sec.querySelector('.copytext');
556
+ if(copy){
557
+ copy.style.transform=`translateY(${(drift*84).toFixed(1)}px) rotateX(${(drift*5).toFixed(2)}deg)`;
558
+ copy.style.opacity=(0.06+0.94*live).toFixed(3);
559
+ const lines=copy.querySelectorAll('h2 .ln .t'); const n=lines.length;
560
+ lines.forEach((t,k)=>{
561
+ const t0=0.06+(k/n)*0.20; const wp=smooth(t0,t0+0.18,T);
562
+ // vertical mask wipe: clip-path inset shrinks from bottom 100% → 0
563
+ t.style.clipPath=`inset(0 0 ${((1-wp)*100).toFixed(1)}% 0)`;
564
+ t.style.transform=`translateY(${((1-wp)*16).toFixed(1)}px)`;
565
+ t.style.opacity=(0.15+0.85*wp).toFixed(3);
566
+ });
567
+ const rev=copy.querySelectorAll('.reveal');
568
+ rev.forEach((el,k)=>{ el.style.opacity=(smooth(0.16+k*0.03,0.40+k*0.03,T)*exit).toFixed(3); });
569
+ }
570
+
571
+ /* scroll-cue badge — faster drift, fades out as the hero releases */
572
+ const cue=sec.querySelector('.cuewrap');
573
+ if(cue){ const d=+cue.dataset.depth;
574
+ cue.style.transform=`translate(-50%, ${(drift*120*d).toFixed(1)}px) translateZ(60px)`;
575
+ cue.style.opacity=(1-smooth(0.10,0.34,T)).toFixed(3);
576
+ }
577
+ }
578
+ if(!pTick){ progress(); }
579
+ ticking=false;
580
+ }
581
+ function onScroll(){ if(!ticking){ ticking=true; requestAnimationFrame(parallax); } }
582
+ addEventListener('scroll',onScroll,{passive:true});
583
+ addEventListener('resize',()=>{ticking=true;requestAnimationFrame(parallax);},{passive:true});
584
+ if(!isTouch && !reduce) addEventListener('pointermove',onScroll,{passive:true});
585
+ parallax(); // initial paint so nothing starts invisible
586
+
587
+ /* second-order micro-tilt on the still itself (pointer, non-touch) — adds
588
+ depth over the figure's scroll camera; lives on .pic so it doesn't fight .figwrap */
589
+ if(!isTouch && !reduce){
590
+ document.querySelectorAll('.frame').forEach(frame=>{
591
+ const pic=frame.querySelector('.pic');
592
+ frame.addEventListener('pointermove',ev=>{
593
+ const r=frame.getBoundingClientRect();
594
+ const px=(ev.clientX-r.left)/r.width-0.5, py=(ev.clientY-r.top)/r.height-0.5;
595
+ pic.style.transform=`translateZ(30px) rotateY(${(px*6).toFixed(2)}deg) rotateX(${(-py*4).toFixed(2)}deg)`;
596
+ });
597
+ frame.addEventListener('pointerleave',()=>{ pic.style.transform='translateZ(0) rotateY(0) rotateX(0)'; });
598
+ });
599
+ }
600
+
601
+ /* ── GSAP SHOWCASE WIRING — desktop & non-reduced (guarded by the early
602
+ return above, then matchMedia again so it auto-kills on resize into a
603
+ small/reduced context). Two cinematic techniques (taste-guardrails §2):
604
+ • DOLLY-ZOOM (Vertigo) — the backdrop pushes in while the caption holds
605
+ • SCRUBBED SIGNAL DRAW — the waveform draws via stroke-dashoffset (pattern #2)
606
+ If GSAP never loads, #approach simply stays at its complete static state. */
607
+ (function wireShowcase(tries){
608
+ if(!window.gsap || !window.ScrollTrigger){
609
+ if(tries>0) requestAnimationFrame(()=>wireShowcase(tries-1));
610
+ return;
611
+ }
612
+ gsap.registerPlugin(ScrollTrigger);
613
+ ScrollTrigger.defaults({ scrub:0.5, fastScrollEnd:true, invalidateOnRefresh:true });
614
+ const mm = gsap.matchMedia();
615
+ mm.add('(min-width:769px) and (prefers-reduced-motion:no-preference)', () => {
616
+ const stage = document.querySelector('#approach .show-stage');
617
+ if(!stage) return;
618
+ const dolly = stage.querySelector('.dolly');
619
+ const path = stage.querySelector('.waveform path');
620
+ const cap = stage.querySelector('.show-cap');
621
+ const len = path.getTotalLength();
622
+ gsap.set(path, { strokeDasharray:len, strokeDashoffset:len, willChange:'stroke-dashoffset' });
623
+ const tl = gsap.timeline({ scrollTrigger:{
624
+ trigger:'#approach', start:'top top', end:'+=130%', pin:stage, anticipatePin:1 } });
625
+ tl.fromTo(dolly, { scale:1 }, { scale:1.26, ease:'none' }, 0) // dolly-zoom
626
+ .to(path, { strokeDashoffset:0, ease:'none' }, 0) // signal draw
627
+ .fromTo(cap, { opacity:0, y:22 }, { opacity:1, y:0, ease:'power2.out', duration:0.4 }, 0.08);
628
+ return () => gsap.set([dolly, path, cap], { clearProps:'all' });
629
+ });
630
+ })(120);
631
+ })();
632
+ </script>
633
+ </body>
634
+ </html>