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,1284 @@
1
+ # Troubleshooting
2
+
3
+ > Symptom → Cause → Fix. No debugging required.
4
+ >
5
+ > Start at the **Quick Diagnosis** flowchart. If that doesn't get you there, drill into the category sections. Every fix includes actual code or commands — not vague advice.
6
+
7
+ ---
8
+
9
+ ## Quick Diagnosis
10
+
11
+ Follow this decision tree in order:
12
+
13
+ ```
14
+ 1. Is the page blank / nothing renders?
15
+ └─ Yes → See [Asset Issues: Images not loading](#asset-issues)
16
+ └─ Images load fine? → See [Build Issues: SSR hydration mismatch](#build-issues)
17
+
18
+ 2. Does scroll feel sticky, janky, or drop frames?
19
+ └─ Yes → See [Performance Issues: Scroll stutters](#performance-issues)
20
+ └─ Only on mobile? → See [Performance Issues: Slow on mobile](#performance-issues)
21
+ └─ Only one specific section? → See [Animation Issues: Overlapping animations](#animation-issues)
22
+
23
+ 3. Do animations not play or play incorrectly?
24
+ └─ Not triggering at all? → See [Animation Issues: Not triggering](#animation-issues)
25
+ └─ Wrong direction? → See [Animation Issues: Wrong direction](#animation-issues)
26
+ └─ Feel mechanical/wrong? → See [Animation Issues: Easing feels wrong](#animation-issues)
27
+
28
+ 4. Does the mobile layout look broken?
29
+ └─ Yes → See [Mobile Issues: Layout broken](#mobile-issues)
30
+ └─ Touch scroll not responding? → See [Mobile Issues: Touch not working](#mobile-issues)
31
+ └─ 3D effects glitching? → See [Mobile Issues: 3D transforms glitch](#mobile-issues)
32
+
33
+ 5. Are AI-generated images failing or wrong?
34
+ └─ Generation fails entirely? → See [AI Pipeline Issues: Generation failed](#ai-pipeline-issues)
35
+ └─ Wrong style/quality? → See [AI Pipeline Issues: Wrong style](#ai-pipeline-issues)
36
+ └─ Too expensive? → See [AI Pipeline Issues: Cost too high](#ai-pipeline-issues)
37
+
38
+ 6. Does `npm run dev` or `npm run build` fail?
39
+ └─ Yes → See [Build Issues](#build-issues)
40
+
41
+ 7. Does reduced-motion or keyboard nav not work?
42
+ └─ Yes → See [Accessibility Issues](#accessibility-issues)
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Performance Issues
48
+
49
+ ### Symptom: Scroll stutters or drops frames
50
+ **Likely causes (check in order):**
51
+
52
+ **Cause 1: Layout reads in scroll handler**
53
+ The most common cause of scroll jank. Reading `getBoundingClientRect()`, `offsetHeight`, `clientWidth`, or `scrollHeight` inside a scroll callback forces the browser to recalculate layout synchronously.
54
+
55
+ **Fix — Cache layout values on init and resize:**
56
+ ```javascript
57
+ // WRONG — layout read every frame
58
+ window.addEventListener('scroll', () => {
59
+ const rect = el.getBoundingClientRect(); // FORBIDDEN in scroll handler
60
+ el.style.transform = `translateY(${rect.top * 0.5}px)`;
61
+ });
62
+
63
+ // RIGHT — cache once, read from cache
64
+ const cache = new Map();
65
+ function refreshCache() {
66
+ document.querySelectorAll('.parallax-layer').forEach(el => {
67
+ cache.set(el, { top: el.offsetTop, height: el.offsetHeight });
68
+ });
69
+ }
70
+ refreshCache();
71
+ window.addEventListener('resize', debounce(refreshCache, 150));
72
+
73
+ window.addEventListener('scroll', () => {
74
+ const cached = cache.get(el);
75
+ el.style.transform = `translateY(${cached.top * 0.5}px)`;
76
+ }, { passive: true });
77
+ ```
78
+
79
+ **Cause 2: Animating forbidden properties**
80
+ Animating `width`, `height`, `top`, `left`, `filter`, `box-shadow`, or `clip-path` during scroll triggers layout recalculation or main-thread compositing.
81
+
82
+ **Fix — Use transform + opacity only:**
83
+ ```javascript
84
+ // WRONG — triggers layout
85
+ gsap.to('.element', { width: '100%', scrollTrigger: { scrub: true } });
86
+
87
+ // RIGHT — scale instead of width
88
+ gsap.to('.element', { scaleX: 1, transformOrigin: 'left', scrollTrigger: { scrub: true } });
89
+
90
+ // WRONG — filter animation kills GPU
91
+ gsap.to('.element', { filter: 'blur(10px)', scrollTrigger: { scrub: true } });
92
+
93
+ // RIGHT — crossfade two pre-blurred layers
94
+ gsap.to('.sharp-layer', { opacity: 0, scrollTrigger: { scrub: true } });
95
+ gsap.to('.blurred-layer', { opacity: 1, scrollTrigger: { scrub: true } });
96
+ ```
97
+
98
+ **Cause 3: Too many compositor layers**
99
+ Each promoted layer consumes 4-8MB GPU memory. Beyond the budget (10 on desktop, 4 on mobile), the browser drops layers to CPU rasterization.
100
+
101
+ **Fix — Audit and reduce layers:**
102
+ ```bash
103
+ # In Chrome DevTools: Layers panel (⋮ → More tools → Layers)
104
+ # Count promoted layers. If > 10 on desktop or > 4 on mobile, reduce:
105
+ ```
106
+ ```css
107
+ /* Remove will-change from non-animated elements */
108
+ .parallax-layer {
109
+ /* will-change: transform; ← REMOVE this if the layer is not currently animating */
110
+ }
111
+
112
+ /* Only apply will-change when the element is in viewport */
113
+ .parallax-layer.is-visible {
114
+ will-change: transform;
115
+ }
116
+ ```
117
+ ```javascript
118
+ // Remove will-change after animation completes
119
+ gsap.to('.element', {
120
+ y: 100,
121
+ onComplete: () => { el.style.willChange = 'auto'; }
122
+ });
123
+ ```
124
+
125
+ **Cause 4: Heavy JavaScript in scroll callback**
126
+ Processing that exceeds the 2ms scroll handler budget (2ms on desktop, 1ms on mobile).
127
+
128
+ **Fix — Use GSAP quickTo for batched property writes:**
129
+ ```javascript
130
+ // WRONG — direct style manipulation per frame
131
+ window.addEventListener('scroll', () => {
132
+ elements.forEach(el => {
133
+ el.style.transform = `translateY(${scrollY * 0.5}px)`;
134
+ });
135
+ });
136
+
137
+ // RIGHT — GSAP quickTo batches writes and uses RAF internally
138
+ const quickSetters = elements.map(el => gsap.quickTo(el, 'y', { duration: 0.3 }));
139
+ ScrollTrigger.create({
140
+ trigger: '.container',
141
+ onUpdate: (self) => {
142
+ quickSetters.forEach((setter, i) => {
143
+ setter(self.progress * distances[i]);
144
+ });
145
+ }
146
+ });
147
+ ```
148
+
149
+ **Cause 5: Images loading during scroll**
150
+ Network requests firing mid-scroll cause the main thread to block on image decode.
151
+
152
+ **Fix — Preload all scroll animation assets:**
153
+ ```html
154
+ <!-- In <head>, preload critical above-fold images -->
155
+ <link rel="preload" as="image" href="/hero-chapter-1.webp" type="image/webp">
156
+ <link rel="preload" as="image" href="/hero-chapter-2.webp" type="image/webp">
157
+ ```
158
+ ```javascript
159
+ // For below-fold chapters, lazy-load with IntersectionObserver
160
+ const imgObserver = new IntersectionObserver((entries) => {
161
+ entries.forEach(entry => {
162
+ if (entry.isIntersecting) {
163
+ const img = entry.target;
164
+ img.src = img.dataset.src; // Trigger load
165
+ imgObserver.unobserve(img);
166
+ }
167
+ });
168
+ }, { rootMargin: '400px' }); // Start loading 400px before visible
169
+ ```
170
+
171
+ ---
172
+
173
+ ### Symptom: Pin "sticks" and won't release
174
+ **Likely cause:** ScrollTrigger `end` value miscalculated or pin container height insufficient.
175
+
176
+ **Fix — Check pin configuration:**
177
+ ```javascript
178
+ // WRONG — pin may never release if element is shorter than scroll distance
179
+ ScrollTrigger.create({
180
+ trigger: '.chapter',
181
+ pin: true,
182
+ start: 'top top',
183
+ end: '+=500vh', // Ensure the pinned element's parent has enough scroll height
184
+ });
185
+
186
+ // RIGHT — explicit end with parent height check
187
+ ScrollTrigger.create({
188
+ trigger: '.chapter',
189
+ pin: true,
190
+ start: 'top top',
191
+ end: '+=250vh',
192
+ pinSpacing: true, // Preserves layout space after unpin
193
+ });
194
+ ```
195
+ ```css
196
+ /* Ensure the parent wrapper has sufficient height for the pin distance */
197
+ .chapter-wrapper {
198
+ min-height: 350vh; /* Must be > pin distance + viewport height */
199
+ position: relative;
200
+ }
201
+ ```
202
+
203
+ **If pin spacing collapses on resize:**
204
+ ```javascript
205
+ // Call ScrollTrigger.refresh() after fonts load and images decode
206
+ window.addEventListener('load', () => {
207
+ ScrollTrigger.refresh();
208
+ });
209
+
210
+ // Also after dynamic content changes
211
+ const resizeObserver = new ResizeObserver(
212
+ debounce(() => ScrollTrigger.refresh(), 200)
213
+ );
214
+ resizeObserver.observe(document.querySelector('.chapter-wrapper'));
215
+ ```
216
+
217
+ ---
218
+
219
+ ### Symptom: Slow on mobile / battery drains rapidly
220
+ **Likely cause:** Full desktop experience running on a mobile GPU that should be on a degradation tier.
221
+
222
+ **Fix — Implement tier detection and degradation:**
223
+ ```javascript
224
+ function getPerformanceTier() {
225
+ // Check reduced motion first (takes precedence)
226
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
227
+ return 'reduced';
228
+ }
229
+
230
+ const memory = navigator.deviceMemory; // GB (Chrome only)
231
+ const cores = navigator.hardwareConcurrency;
232
+ const isMobile = /iPhone|iPad|iPod|Android/.test(navigator.userAgent);
233
+
234
+ if (!isMobile) return 'desktop';
235
+ if (memory >= 8 && cores >= 6) return 'flagship';
236
+ if (memory >= 4 && cores >= 4) return 'mid-range';
237
+ return 'budget';
238
+ }
239
+
240
+ const tier = getPerformanceTier();
241
+
242
+ // Apply tier-specific config
243
+ const tierConfig = {
244
+ desktop: { layers: 7, parallax: true, transform3d: true, video: true },
245
+ flagship: { layers: 7, parallax: true, transform3d: true, video: true },
246
+ 'mid-range':{ layers: 5, parallax: true, transform3d: true, video: false },
247
+ budget: { layers: 2, parallax: false, transform3d: false, video: false },
248
+ reduced: { layers: 1, parallax: false, transform3d: false, video: false },
249
+ }[tier];
250
+ ```
251
+
252
+ **Battery drain fix — Add emergency degradation:**
253
+ ```javascript
254
+ let consecutiveSlowFrames = 0;
255
+ let lastTimestamp = 0;
256
+
257
+ function checkFrameRate(timestamp) {
258
+ if (lastTimestamp) {
259
+ const delta = timestamp - lastTimestamp;
260
+ if (delta > 33.33) { // Below 30fps
261
+ consecutiveSlowFrames++;
262
+ if (consecutiveSlowFrames > 3 * 60) { // 3 seconds at 60fps sample
263
+ emergencyDegrade();
264
+ }
265
+ } else {
266
+ consecutiveSlowFrames = 0;
267
+ }
268
+ }
269
+ lastTimestamp = timestamp;
270
+ requestAnimationFrame(checkFrameRate);
271
+ }
272
+
273
+ function emergencyDegrade() {
274
+ ScrollTrigger.getAll().forEach(st => st.kill()); // Unpin everything
275
+ document.querySelectorAll('.parallax-layer').forEach(el => {
276
+ el.style.transform = 'none';
277
+ el.style.willChange = 'auto';
278
+ });
279
+ document.body.classList.add('emergency-degraded');
280
+ console.warn('[cinematic-scroll] Emergency degradation applied — frame rate too low');
281
+ }
282
+ ```
283
+
284
+ ---
285
+
286
+ ### Symptom: Cumulative Layout Shift (CLS) > 0.1
287
+ **Likely cause:** Images or fonts loading without intrinsic dimensions, causing content to jump.
288
+
289
+ **Fix — Reserve space with aspect ratio and use font-display: swap:**
290
+ ```html
291
+ <!-- WRONG — no dimensions, layout shifts when image loads -->
292
+ <img src="hero.webp" alt="Hero">
293
+
294
+ <!-- RIGHT — explicit dimensions or aspect-ratio -->
295
+ <img src="hero.webp" alt="Hero" width="1920" height="1080"
296
+ style="aspect-ratio: 16/9; height: auto;">
297
+ ```
298
+ ```css
299
+ /* Reserve space for chapter containers before content loads */
300
+ .chapter-wrapper {
301
+ min-height: 100vh;
302
+ contain: layout style; /* Isolate layout changes */
303
+ }
304
+
305
+ /* Mandatory font-display: swap prevents FOIT (Flash of Invisible Text) */
306
+ @font-face {
307
+ font-family: 'Display';
308
+ src: url('/fonts/display.woff2') format('woff2');
309
+ font-display: swap; /* Text visible immediately in fallback font */
310
+ }
311
+ ```
312
+
313
+ ---
314
+
315
+ ## Mobile Issues
316
+
317
+ ### Symptom: Layout broken on mobile (elements overlap, overflow, wrong sizes)
318
+ **Likely cause:** Missing viewport meta tag, fixed pixel values instead of fluid sizing, or no mobile breakpoint handling.
319
+
320
+ **Fix — Check these three things:**
321
+ ```html
322
+ <!-- 1. Viewport meta tag MUST include viewport-fit=cover for notched devices -->
323
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
324
+ ```
325
+ ```css
326
+ /* 2. All typography MUST use clamp(), never fixed px for font-size */
327
+ .chapter-title {
328
+ /* WRONG — overflows on small screens */
329
+ /* font-size: 120px; */
330
+
331
+ /* RIGHT — fluid scaling */
332
+ font-size: clamp(2.5rem, 8vw + 1rem, 7.5rem);
333
+ }
334
+
335
+ /* 3. Pin chapters MUST be disabled below 768px */
336
+ @media (max-width: 767px) {
337
+ .chapter-wrapper {
338
+ min-height: auto; /* Remove pin scroll height */
339
+ height: auto;
340
+ }
341
+
342
+ .pinned-content {
343
+ position: relative; /* NOT fixed or sticky */
344
+ transform: none !important;
345
+ }
346
+
347
+ /* Stack depth layers instead of parallaxing */
348
+ .parallax-layer {
349
+ position: relative;
350
+ transform: none !important;
351
+ will-change: auto;
352
+ }
353
+ }
354
+ ```
355
+
356
+ ---
357
+
358
+ ### Symptom: Touch scrolling not working / feels unresponsive
359
+ **Likely cause:** Missing `passive: true` on scroll listeners, or `touch-action` CSS preventing default touch behavior.
360
+
361
+ **Fix — Add passive listeners and check touch-action:**
362
+ ```javascript
363
+ // WRONG — blocks scroll on touch
364
+ window.addEventListener('scroll', onScroll);
365
+ window.addEventListener('touchmove', onTouchMove);
366
+
367
+ // RIGHT — passive listeners never block scroll
368
+ window.addEventListener('scroll', onScroll, { passive: true });
369
+ window.addEventListener('touchmove', onTouchMove, { passive: true });
370
+ ```
371
+ ```css
372
+ /* Ensure touch-action allows scrolling */
373
+ .scroll-container {
374
+ touch-action: pan-y; /* Allow vertical scroll, no horizontal interference */
375
+ }
376
+
377
+ /* If using Lenis, ensure it's initialized with touch support */
378
+ ```
379
+ ```javascript
380
+ // Lenis initialization (Mode B)
381
+ const lenis = new Lenis({
382
+ lerp: 0.1,
383
+ smoothWheel: true,
384
+ touchMultiplier: 1.5, // Slightly faster on touch
385
+ });
386
+
387
+ // Forward Lenis RAF to GSAP
388
+ function raf(time) {
389
+ lenis.raf(time);
390
+ requestAnimationFrame(raf);
391
+ }
392
+ requestAnimationFrame(raf);
393
+
394
+ lenis.on('scroll', ScrollTrigger.update);
395
+ ```
396
+
397
+ ---
398
+
399
+ ### Symptom: 3D transforms glitch on iOS / video freezes
400
+ **Likely cause:** iOS Safari freezes `<video>` frames inside a `transform-style: preserve-3d` ancestor that updates during scroll. Also affects `position: fixed` elements inside 3D contexts.
401
+
402
+ **Fix — Detect touch and bypass 3D wrapper for video:**
403
+ ```javascript
404
+ const isTouchDevice = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
405
+ ```
406
+ ```css
407
+ /* On touch devices, disable 3D context entirely */
408
+ @media (hover: none) and (pointer: coarse) {
409
+ .chapter-scene {
410
+ transform-style: flat !important;
411
+ perspective: none !important;
412
+ }
413
+
414
+ .parallax-layer {
415
+ transform: none !important;
416
+ }
417
+ }
418
+
419
+ /* Video wrapper must be OUTSIDE the 3D context */
420
+ .video-container {
421
+ position: relative;
422
+ /* NOT inside .preserve-3d wrapper */
423
+ }
424
+ ```
425
+ ```jsx
426
+ // React component approach (Mode B)
427
+ function ChapterScene({ useVideo }) {
428
+ const isTouch = useDevice().isTouch;
429
+
430
+ return (
431
+ <section className="chapter-scene"
432
+ style={{
433
+ perspective: isTouch ? 'none' : '1200px',
434
+ transformStyle: isTouch ? 'flat' : 'preserve-3d',
435
+ }}>
436
+
437
+ {/* Video MUST be outside the 3D context on iOS */}
438
+ {useVideo && (
439
+ <div className="video-container" style={{ transformStyle: 'flat' }}>
440
+ <video playsInline muted loop preload="metadata"
441
+ poster="/fallback-poster.webp">
442
+ <source src="/chapter-video.mp4" type="video/mp4" />
443
+ </video>
444
+ </div>
445
+ )}
446
+
447
+ {/* 3D layers only on non-touch */}
448
+ {!isTouch && <ParallaxLayers />}
449
+ </section>
450
+ );
451
+ }
452
+ ```
453
+
454
+ ---
455
+
456
+ ### Symptom: Font too small / unreadable on mobile
457
+ **Likely cause:** Typography not using `clamp()`, body text below 16px (iOS zooms < 16px), or insufficient line-height.
458
+
459
+ **Fix — Enforce mobile type scale:**
460
+ ```css
461
+ /* Minimum 16px body text — iOS Safari auto-zooms anything smaller */
462
+ body {
463
+ font-size: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); /* 16px - 18px */
464
+ line-height: 1.6;
465
+ }
466
+
467
+ /* Display type must scale down dramatically */
468
+ .display-title {
469
+ font-size: clamp(2.5rem, 6vw + 1rem, 7.5rem); /* 40px - 120px */
470
+ line-height: 1.05;
471
+ }
472
+
473
+ /* Safe area insets for notched devices */
474
+ .safe-area-text {
475
+ padding-left: env(safe-area-inset-left, 16px);
476
+ padding-right: env(safe-area-inset-right, 16px);
477
+ }
478
+
479
+ /* Tap targets minimum 44px square (Apple HIG) */
480
+ .nav-button {
481
+ min-width: 44px;
482
+ min-height: 44px;
483
+ }
484
+ ```
485
+
486
+ ---
487
+
488
+ ## Animation Issues
489
+
490
+ ### Symptom: Animations not triggering at all
491
+ **Likely causes:** ScrollTrigger not registered, wrong trigger element, or element not in DOM when ScrollTrigger initializes.
492
+
493
+ **Fix — Verify GSAP plugin registration and trigger targeting:**
494
+ ```javascript
495
+ // 1. Register plugins ONCE at app startup (Mode B)
496
+ import gsap from 'gsap';
497
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
498
+ import { SplitText } from 'gsap/SplitText';
499
+
500
+ gsap.registerPlugin(ScrollTrigger, SplitText); // REQUIRED
501
+
502
+ // 2. In React, use useGSAP with scope for cleanup
503
+ import { useGSAP } from '@gsap/react';
504
+
505
+ function Chapter({ id }) {
506
+ const containerRef = useRef(null);
507
+
508
+ useGSAP(() => {
509
+ // Animations here are automatically scoped and cleaned up
510
+ gsap.from('.title', {
511
+ y: 100,
512
+ opacity: 0,
513
+ scrollTrigger: {
514
+ trigger: containerRef.current, // Explicit trigger element
515
+ start: 'top 80%',
516
+ toggleActions: 'play none none reverse',
517
+ }
518
+ });
519
+ }, { scope: containerRef }); // ← scope is critical
520
+
521
+ return <section ref={containerRef} id={id}>...</section>;
522
+ }
523
+
524
+ // 3. If elements are dynamically loaded, refresh after they mount
525
+ useEffect(() => {
526
+ // After data loads and renders
527
+ ScrollTrigger.refresh();
528
+ }, [data]);
529
+ ```
530
+
531
+ ---
532
+
533
+ ### Symptom: Animations play in wrong direction (exit instead of entrance)
534
+ **Likely cause:** `scrub: true` with wrong `fromTo()` order, or scroll position starts below the trigger.
535
+
536
+ **Fix — Use fromTo() for scrubbed animations:**
537
+ ```javascript
538
+ // WRONG — with scrub, to() only defines the end state
539
+ // If scroll starts past the trigger, element is already at end state
540
+ gsap.to('.element', { y: 0, opacity: 1, scrollTrigger: { scrub: true } });
541
+
542
+ // RIGHT — fromTo() defines both states explicitly
543
+ gsap.fromTo('.element',
544
+ { y: 100, opacity: 0 }, // Start state
545
+ {
546
+ y: 0,
547
+ opacity: 1,
548
+ scrollTrigger: {
549
+ trigger: '.element',
550
+ start: 'top 90%',
551
+ end: 'top 30%',
552
+ scrub: 0.5, // 0.5s smoothing lag
553
+ }
554
+ }
555
+ );
556
+ ```
557
+
558
+ ---
559
+
560
+ ### Symptom: Easing feels wrong / mechanical / "like PowerPoint"
561
+ **Likely cause:** Using default easing (`ease`, `ease-in-out`, `linear`) instead of the custom cinematic easing curves.
562
+
563
+ **Fix — Use the skill's easing vocabulary:**
564
+ ```javascript
565
+ // WRONG — default easing feels mechanical
566
+ gsap.to('.hero-title', { y: 0, opacity: 1, duration: 1, ease: 'power2.out' });
567
+
568
+ // RIGHT — custom easing per role
569
+ // Hero entrances: dramatic deceleration
570
+ gsap.to('.hero-title', {
571
+ y: 0, opacity: 1, duration: 1,
572
+ ease: 'cubic-bezier(0.16, 1, 0.3, 1)'
573
+ });
574
+
575
+ // Chapter exits: clean acceleration away
576
+ gsap.to('.chapter-exit', {
577
+ y: -50, opacity: 0, duration: 0.8,
578
+ ease: 'cubic-bezier(0.7, 0, 0.84, 0)'
579
+ });
580
+
581
+ // Micro-interactions: playful overshoot
582
+ gsap.to('.button', {
583
+ scale: 1, duration: 0.4,
584
+ ease: 'back.out(1.4)'
585
+ });
586
+
587
+ // Never use the same easing for every animation in a chapter
588
+ // Vary by role: entrance, exit, micro-interaction, transition
589
+ ```
590
+
591
+ ---
592
+
593
+ ### Symptom: Overlapping animations create visual chaos
594
+ **Likely cause:** More than 3 simultaneous motion types in a 50vh window, violating the motion density limit. Or adjacent chapters using the same transition type.
595
+
596
+ **Fix — Audit motion density and enforce the 3-type limit:**
597
+ ```javascript
598
+ // WRONG — 5 simultaneous motion types in one viewport
599
+ gsap.to('.bg', { y: 100, scrollTrigger: { scrub: true } }); // parallax
600
+ gsap.to('.title', { opacity: 1, scrollTrigger: { scrub: true } }); // title reveal
601
+ gsap.to('.figure', { rotateY: 10, scrollTrigger: { scrub: true } }); // 3D tilt
602
+ gsap.to('.hud', { scaleX: 1, scrollTrigger: { scrub: true } }); // progress HUD
603
+ gsap.to('.overlay', { opacity: 0.5, scrollTrigger: { scrub: true } }); // color morph
604
+
605
+ // RIGHT — pick the 3 most important, let the others rest
606
+ // Keep: parallax bg, title reveal, 3D tilt
607
+ // Defer: progress HUD (snap to final state), color morph (static opacity)
608
+ ```
609
+
610
+ **Fix — Alternate transition types between adjacent chapters:**
611
+ ```javascript
612
+ // Chapter 1 exit: fade-through-black
613
+ // Chapter 2 entrance CANNOT also be fade — must be different family
614
+ const transitions = ['fade', 'slide-up', 'scale-in', 'wipe-left'];
615
+
616
+ chapters.forEach((chapter, i) => {
617
+ const exitTransition = transitions[i % transitions.length];
618
+ const enterTransition = transitions[(i + 1) % transitions.length];
619
+ // Never the same transition for adjacent chapters
620
+ });
621
+ ```
622
+
623
+ ---
624
+
625
+ ## Asset Issues
626
+
627
+ ### Symptom: Images not loading / show as broken
628
+ **Likely causes:** Wrong path, missing file, format not supported, or CORS issue on external URL.
629
+
630
+ **Fix — Diagnostic checklist:**
631
+ ```bash
632
+ # 1. Verify the file exists in the right location
633
+ ls -la public/hero-chapter-1.webp
634
+ ls -la public/generated/ # For AI-generated assets
635
+
636
+ # 2. Check the network tab in DevTools for 404 errors
637
+ # If 404, the path in the manifest doesn't match the file location
638
+
639
+ # 3. For Mode B, ensure images are in /public/ (served as static)
640
+ # NOT in /app/ or /src/ — those aren't publicly accessible
641
+ ```
642
+ ```tsx
643
+ // 4. Use next/image with proper configuration
644
+ import Image from 'next/image';
645
+
646
+ // For local images: import the file (Next.js handles optimization)
647
+ import heroImg from '../../public/hero-chapter-1.webp';
648
+
649
+ <Image
650
+ src={heroImg}
651
+ alt="Chapter hero"
652
+ priority // Above the fold
653
+ placeholder="blur" // LQIP
654
+ />
655
+
656
+ // For external images: add domain to next.config.ts
657
+ // next.config.ts:
658
+ const nextConfig = {
659
+ images: {
660
+ domains: ['fal.media', 'your-cdn.com'],
661
+ },
662
+ };
663
+ ```
664
+
665
+ **Fallback for broken images:**
666
+ ```tsx
667
+ function SafeImage({ src, alt, fallback = '/fallback-gradient.webp', ...props }) {
668
+ const [error, setError] = useState(false);
669
+
670
+ if (error || !src) {
671
+ return (
672
+ <div className="image-fallback"
673
+ style={{ background: 'linear-gradient(135deg, #1a1a2e, #16213e)' }}>
674
+ <span>{alt}</span>
675
+ </div>
676
+ );
677
+ }
678
+
679
+ return <Image {...props} src={src} alt={alt} onError={() => setError(true)} />;
680
+ }
681
+ ```
682
+
683
+ ---
684
+
685
+ ### Symptom: Fonts flash (FOUT — Flash of Unstyled Text)
686
+ **Likely cause:** `@font-face` declaration missing `font-display: swap`, or font file too large.
687
+
688
+ **Fix — Use font-display: swap and preload critical font:**
689
+ ```css
690
+ /* Mandatory: font-display: swap */
691
+ @font-face {
692
+ font-family: 'Display';
693
+ src: url('/fonts/display.woff2') format('woff2'),
694
+ url('/fonts/display.woff') format('woff');
695
+ font-weight: 400 700;
696
+ font-display: swap; /* ← Text visible immediately, font swaps in when loaded */
697
+ }
698
+
699
+ /* Subset fonts to only needed characters (use glyphhanger or similar) */
700
+ /* Target: < 200KB total for all font weights combined */
701
+ ```
702
+ ```html
703
+ <!-- Preload only the first viewport's primary font (max 1 preload) -->
704
+ <link rel="preload" as="font" href="/fonts/display.woff2" type="font/woff2" crossorigin>
705
+ ```
706
+
707
+ ---
708
+
709
+ ### Symptom: Background videos not autoplaying
710
+ **Likely cause:** Missing `muted`, `playsInline`, or `preload` attributes. iOS Safari has strict autoplay policies.
711
+
712
+ **Fix — Use the iOS-safe video pattern:**
713
+ ```html
714
+ <!-- Mandatory attributes for autoplay -->
715
+ <video
716
+ autoplay
717
+ muted <!-- REQUIRED — muted autoplay is always allowed -->
718
+ loop
719
+ playsInline <!-- REQUIRED — prevents iOS fullscreen -->
720
+ preload="metadata"
721
+ poster="/video-poster.webp"> <!-- Show poster while video loads -->
722
+ <source src="/chapter-bg.mp4" type="video/mp4">
723
+ <source src="/chapter-bg.webm" type="video/webm">
724
+ </video>
725
+ ```
726
+ ```javascript
727
+ // Graceful fallback: if video fails, show poster
728
+ const video = document.querySelector('video');
729
+ video.addEventListener('error', () => {
730
+ video.style.display = 'none';
731
+ document.querySelector('.video-poster').style.display = 'block';
732
+ });
733
+
734
+ // On touch devices, use poster only (no autoplay for battery)
735
+ if (window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
736
+ video.pause();
737
+ video.currentTime = 0;
738
+ video.style.display = 'none';
739
+ document.querySelector('.video-poster').style.display = 'block';
740
+ }
741
+ ```
742
+
743
+ ---
744
+
745
+ ### Symptom: Page loads slowly / oversized assets
746
+ **Likely cause:** Images not optimized, wrong format, or too large for their display size.
747
+
748
+ **Fix — Run the asset optimization checklist:**
749
+ ```bash
750
+ # 1. Convert to WebP (or AVIF for >85% browser support)
751
+ cwebp -q 85 input.jpg -o output.webp
752
+
753
+ # 2. Resize to max display dimensions (never serve 4000px images for 800px slots)
754
+ # Desktop backgrounds: max 2400px wide
755
+ # Mobile backgrounds: max 828px wide
756
+ # Foreground figures: max 1200px wide
757
+
758
+ # 3. Verify file sizes (performance budget)
759
+ # Desktop images per chapter: max 500KB total
760
+ # Mobile images per chapter: max 200KB total
761
+ find public/ -name "*.webp" -exec ls -lh {} \; | awk '{ print $5 ": " $9 }'
762
+
763
+ # 4. Use responsive images
764
+ ```
765
+ ```html
766
+ <!-- Responsive image with art direction -->
767
+ <picture>
768
+ <source media="(max-width: 767px)" srcset="/hero-mobile-828.webp" type="image/webp">
769
+ <source media="(min-width: 768px)" srcset="/hero-desktop-1920.webp" type="image/webp">
770
+ <img src="/hero-fallback.jpg" alt="Hero" loading="lazy" decoding="async"
771
+ width="1920" height="1080" style="aspect-ratio: 16/9;">
772
+ </picture>
773
+ ```
774
+
775
+ ---
776
+
777
+ ## AI Pipeline Issues
778
+
779
+ ### Symptom: fal.ai generation fails (error response, no images)
780
+ **Likely causes:** Missing `FAL_KEY`, wrong key format, insufficient credits, or wrong model ID.
781
+
782
+ **Fix — Run through the fal.ai diagnostic:**
783
+ ```bash
784
+ # 1. Verify .env.local exists and has the correct format
785
+ cat .env.local
786
+ # Expected: FAL_KEY="key_id:key_secret" (two parts separated by colon)
787
+
788
+ # 2. Restart dev server after adding env vars
789
+ # (Next.js only reads .env.local at startup)
790
+ npm run dev
791
+
792
+ # 3. Test with curl (bypass the app to isolate the issue)
793
+ curl -X POST https://queue.fal.run/fal-ai/flux-2-pro \
794
+ -H "Authorization: Key $FAL_KEY" \
795
+ -H "Content-Type: application/json" \
796
+ -d '{"prompt": "test image, solid blue background"}'
797
+
798
+ # 4. Check response codes:
799
+ # 200 → fal.ai works, issue is in app code
800
+ # 401 → Invalid key. Regenerate at https://fal.ai/dashboard/keys
801
+ # 402 → Insufficient credits. Add credits in fal.ai billing.
802
+ # 404 → Wrong model ID. Check MODELS.md for valid IDs.
803
+ ```
804
+
805
+ **Common app-side fixes:**
806
+ ```tsx
807
+ // 5. Verify FAL_KEY is not exposed in client code
808
+ // WRONG — key in client component
809
+ const fal = falClient({ credentials: process.env.FAL_KEY }); // ← NEVER in 'use client'
810
+
811
+ // RIGHT — key stays server-side via proxy
812
+ // app/api/fal/proxy/route.ts:
813
+ export async function POST(req: NextRequest) {
814
+ const key = process.env.FAL_KEY; // Server-only
815
+ // ... proxy logic
816
+ }
817
+
818
+ // 6. Verify the proxy exports GET, POST, AND PUT
819
+ export async function PUT(req: NextRequest) { /* newer fal client requires PUT */ }
820
+ ```
821
+
822
+ ---
823
+
824
+ ### Symptom: Generated images have wrong style / poor quality
825
+ **Likely cause:** Prompt not following the prompt contract, wrong model for the job, or `negative_prompt` sent to a model that doesn't support it.
826
+
827
+ **Fix — Follow the prompt contract and use the adapter:**
828
+ ```typescript
829
+ // WRONG — inlining parameters that differ per model
830
+ await fal.subscribe('fal-ai/flux-2-pro', {
831
+ input: {
832
+ prompt: 'renaissance painting',
833
+ negative_prompt: 'modern elements', // ← FLUX.2 ignores this!
834
+ image_size: '16:9', // ← Wrong format for FLUX.2
835
+ }
836
+ });
837
+
838
+ // RIGHT — use the adapter (handles model-specific quirks)
839
+ import { generateEditionImage } from '@/lib/fal-generate';
840
+
841
+ const asset = await generateEditionImage({
842
+ chapterId: 'prologue',
843
+ subject: 'two figures in a renaissance studio, oil painting',
844
+ productTruth: 'the product turns updates into a release system',
845
+ historicalLayer: 'renaissance',
846
+ modernLayer: 'transparent software panel, AI terminal glow',
847
+ palette: ['aged cream', 'deep umber', 'acid pink'],
848
+ camera: 'wide',
849
+ outputRole: 'hero',
850
+ });
851
+ // The adapter in lib/fal-models.ts handles:
852
+ // - FLUX.2: image_size: 'landscape_16_9' (not aspect_ratio)
853
+ // - Gemini: aspect_ratio: '16:9' (not image_size)
854
+ // - Negative prompt: inlined into prompt text (no negative_prompt param)
855
+ ```
856
+
857
+ **Model selection guide:**
858
+ ```bash
859
+ # Editorial depth, materials, atmosphere → FLUX.2 Pro (default)
860
+ FAL_IMAGE_MODEL="fal-ai/flux-2-pro"
861
+
862
+ # Text baked into image, complex scene direction → Nano Banana Pro
863
+ FAL_IMAGE_MODEL="fal-ai/gemini-3-pro-image-preview"
864
+
865
+ # Fast drafts, cheap iteration → FLUX.2 Turbo
866
+ FAL_IMAGE_MODEL="fal-ai/flux-2/turbo"
867
+ ```
868
+
869
+ ---
870
+
871
+ ### Symptom: Generation times out or takes too long
872
+ **Likely cause:** Using synchronous mode for batches > 5 images, or using a model with cold-start > 10s.
873
+
874
+ **Fix — Use queue mode for large batches:**
875
+ ```bash
876
+ # Sync mode: blocks until done. OK for ≤5 images, prototyping.
877
+ curl -X POST http://localhost:3000/api/generate-edition-asset \
878
+ -H "Content-Type: application/json" \
879
+ -d '{"mode":"sync","chapterId":"prologue",...}'
880
+
881
+ # Queue mode: returns immediately, result posted to webhook
882
+ # Use for: batches >5, video generation, any model with cold-start ≥10s
883
+ curl -X POST http://localhost:3000/api/generate-edition-asset \
884
+ -H "Content-Type: application/json" \
885
+ -d '{"mode":"queue","chapterId":"prologue",...}'
886
+ # → { "status":"queued", "requestId":"...", "modelId":"fal-ai/flux-2-pro" }
887
+
888
+ # The result is POSTed to /api/fal/webhook?chapter=prologue when ready
889
+ ```
890
+
891
+ ---
892
+
893
+ ### Symptom: fal.ai cost is unexpectedly high
894
+ **Likely cause:** Using the wrong model for the job, regenerating unnecessarily, or not using dry-run to validate prompts first.
895
+
896
+ **Fix — Cost optimization:**
897
+ ```bash
898
+ # 1. Always dry-run first — prints prompts, no fal calls, no cost
899
+ node scripts/generate-chapter-assets.mjs --dry-run
900
+
901
+ # 2. Use turbo model for draft rounds
902
+ node scripts/generate-chapter-assets.mjs --model fal-ai/flux-2/turbo
903
+
904
+ # 3. Only regenerate failed chapters
905
+ node scripts/generate-chapter-assets.mjs --only prologue,studio
906
+
907
+ # 4. Skip fal.ai entirely — use CSS-only mode or static images
908
+ # CSS-only mode costs $0 and looks stunning (ChapterDemoVisual component)
909
+ ```
910
+
911
+ **Typical costs for 8-chapter page:**
912
+ | Model | Cost | When |
913
+ |-------|------|------|
914
+ | FLUX.2 Pro (default) | ~$0.48 | Production quality |
915
+ | FLUX.2 Turbo | ~$0.16 | Draft rounds |
916
+ | Nano Banana 2 | ~$0.56 | Text-heavy chapters |
917
+ | CSS-only | $0 | Zero AI setup |
918
+
919
+ ---
920
+
921
+ ## Build Issues
922
+
923
+ ### Symptom: TypeScript compilation errors (`tsc --noEmit` fails)
924
+ **Likely causes:** Missing types, wrong import paths, or version mismatch between `@types/react` and React version.
925
+
926
+ **Fix — Check these common issues:**
927
+ ```bash
928
+ # 1. Verify TypeScript version matches the project
929
+ npx tsc --version # Should be >= 5.6
930
+
931
+ # 2. Ensure @types packages match installed versions
932
+ npm ls @types/react @types/react-dom
933
+ # Should match react and react-dom versions in package.json
934
+
935
+ # 3. Common fix: regenerate tsconfig from template
936
+ cp templates/nextjs/tsconfig.json ./tsconfig.json
937
+
938
+ # 4. If 'Cannot find module' for choreo-3d:
939
+ npm ls choreo-3d # Should show 1.0.0
940
+ # If missing: npm install choreo-3d@1.0.0
941
+
942
+ # 5. Clear TypeScript cache and rebuild
943
+ rm -rf node_modules/.cache
944
+ cd .next && rm -rf cache && cd ..
945
+ npm run typecheck
946
+ ```
947
+
948
+ ---
949
+
950
+ ### Symptom: `Module not found` or `Cannot resolve` errors
951
+ **Likely causes:** Missing dependency, wrong package name (Lenis), or importing from a non-existent path.
952
+
953
+ **Fix — The three most common module failures:**
954
+
955
+ **Failure 1: Wrong Lenis package** (see KNOWN_ISSUES.md)
956
+ ```bash
957
+ # WRONG package — deprecated, max version 1.0.42
958
+ npm ls @studio-freight/lenis # If this exists, REMOVE it
959
+
960
+ # RIGHT package
961
+ npm ls lenis # Should show ^1.3.23
962
+
963
+ # Fix: Replace with bundled package.json
964
+ cp templates/nextjs/package.json ./package.json
965
+ rm -rf node_modules package-lock.json
966
+ npm install
967
+ ```
968
+
969
+ **Failure 2: Missing choreo-3d**
970
+ ```bash
971
+ npm install choreo-3d@1.0.0
972
+ ```
973
+
974
+ **Failure 3: GSAP plugin imports**
975
+ ```typescript
976
+ // WRONG — old Club CDN or incorrect path
977
+ import { ScrollTrigger } from 'gsap/dist/ScrollTrigger'; // May not resolve
978
+
979
+ // RIGHT — GSAP 3.13+ (all plugins now free)
980
+ import { ScrollTrigger } from 'gsap/ScrollTrigger';
981
+ import { SplitText } from 'gsap/SplitText';
982
+ import { ScrollSmoother } from 'gsap/ScrollSmoother';
983
+ ```
984
+
985
+ ---
986
+
987
+ ### Symptom: SSR hydration mismatch (React "Text content did not match")
988
+ **Likely cause:** Server-rendered HTML differs from client-rendered HTML. Common causes: `window`/`document` references during SSR, random values, or date/time differences.
989
+
990
+ **Fix — Make SSR and client renders identical:**
991
+ ```tsx
992
+ // WRONG — window access during render (server has no window)
993
+ function Component() {
994
+ const width = window.innerWidth; // ← undefined on server
995
+ return <div style={{ width }}>...</div>;
996
+ }
997
+
998
+ // RIGHT — use useEffect for client-only values
999
+ function Component() {
1000
+ const [width, setWidth] = useState(1024); // Same default on server + client
1001
+
1002
+ useEffect(() => {
1003
+ setWidth(window.innerWidth); // Update only on client
1004
+ const handler = () => setWidth(window.innerWidth);
1005
+ window.addEventListener('resize', handler);
1006
+ return () => window.removeEventListener('resize', handler);
1007
+ }, []);
1008
+
1009
+ return <div style={{ width }}>...</div>;
1010
+ }
1011
+ ```
1012
+ ```tsx
1013
+ // WRONG — random values differ between server and client
1014
+ const id = Math.random().toString(36); // Different every render
1015
+
1016
+ // RIGHT — deterministic values (stable across renders)
1017
+ const id = useId(); // React 18+ — same on server and client
1018
+ // Or use a seed-based approach for procedural values
1019
+ ```
1020
+ ```tsx
1021
+ // For truly client-only content (e.g., 3D tilt), suppress SSR
1022
+ import dynamic from 'next/dynamic';
1023
+
1024
+ const TiltComponent = dynamic(
1025
+ () => import('./TiltComponent'),
1026
+ { ssr: false } // Only renders on client
1027
+ );
1028
+ ```
1029
+
1030
+ ---
1031
+
1032
+ ### Symptom: `npm install` fails with ETARGET on Lenis
1033
+ **Full error:** `No matching version found for @studio-freight/lenis@^1.0.45`
1034
+
1035
+ **Cause:** The wrong Lenis package scope is specified. `@studio-freight/lenis` is deprecated (max version 1.0.42). Version `^1.0.45` does not exist.
1036
+
1037
+ **Fix — Use the correct package:**
1038
+ ```bash
1039
+ # 1. Check current package.json
1040
+ grep -i "lenis" package.json
1041
+
1042
+ # If it shows @studio-freight/lenis, replace the entire package.json:
1043
+ cp templates/nextjs/package.json ./package.json
1044
+ rm -rf node_modules package-lock.json
1045
+ npm install
1046
+
1047
+ # 2. Verify the correct package is installed
1048
+ npm ls lenis # Should show: lenis@1.3.x
1049
+ # NOT: @studio-freight/lenis
1050
+
1051
+ # 3. Check imports in code
1052
+ # WRONG: import Lenis from '@studio-freight/lenis';
1053
+ # RIGHT: import Lenis from 'lenis';
1054
+ ```
1055
+
1056
+ See also: `examples/KNOWN_ISSUES.md` for the full QA log of this specific failure.
1057
+
1058
+ ---
1059
+
1060
+ ## Accessibility Issues
1061
+
1062
+ ### Symptom: Reduced-motion preference not respected
1063
+ **Likely cause:** Missing `prefers-reduced-motion` media query check, or scroll animations running regardless.
1064
+
1065
+ **Fix — Implement the mandatory reduced-motion fallback:**
1066
+ ```css
1067
+ /* 1. CSS: disable all transitions and animations */
1068
+ @media (prefers-reduced-motion: reduce) {
1069
+ *, *::before, *::after {
1070
+ animation-duration: 0.01ms !important;
1071
+ animation-iteration-count: 1 !important;
1072
+ transition-duration: 0.01ms !important;
1073
+ scroll-behavior: auto !important;
1074
+ }
1075
+
1076
+ /* Show all pinned content immediately */
1077
+ .parallax-layer {
1078
+ transform: none !important;
1079
+ opacity: 1 !important;
1080
+ }
1081
+
1082
+ .chapter-wrapper {
1083
+ min-height: auto !important;
1084
+ position: relative !important;
1085
+ }
1086
+ }
1087
+ ```
1088
+ ```javascript
1089
+ // 2. JS: detect and disable GSAP animations
1090
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1091
+
1092
+ if (prefersReducedMotion) {
1093
+ // Kill all ScrollTrigger instances
1094
+ ScrollTrigger.getAll().forEach(st => st.kill());
1095
+
1096
+ // Show all content immediately
1097
+ gsap.set('.parallax-layer', { opacity: 1, y: 0, x: 0, scale: 1 });
1098
+ gsap.set('.chapter-title', { opacity: 1, clipPath: 'inset(0 0 0 0)' });
1099
+
1100
+ // Convert pinned sections to static flow
1101
+ document.querySelectorAll('.chapter-wrapper').forEach(el => {
1102
+ el.style.minHeight = 'auto';
1103
+ el.style.position = 'relative';
1104
+ });
1105
+ }
1106
+ ```
1107
+ ```tsx
1108
+ // 3. React: use the use-device hook (bundled with skill)
1109
+ import { useReducedMotion } from '@/lib/use-device';
1110
+
1111
+ function ChapterScene() {
1112
+ const reducedMotion = useReducedMotion();
1113
+
1114
+ if (reducedMotion) {
1115
+ return <StaticChapterLayout />; // No animations, all content visible
1116
+ }
1117
+
1118
+ return <AnimatedChapterScene />;
1119
+ }
1120
+ ```
1121
+
1122
+ **Test it:** macOS → System Settings → Accessibility → Display → Reduce Motion (toggle ON). Reload the page. All content should be visible, no animations, no pinning.
1123
+
1124
+ ---
1125
+
1126
+ ### Symptom: Keyboard navigation broken (Tab key doesn't work, focus trapped)
1127
+ **Likely cause:** Focusable elements inside pinned sections with `visibility: hidden` or `opacity: 0`, or missing `tabindex` management.
1128
+
1129
+ **Fix — Ensure focusable elements are accessible:**
1130
+ ```css
1131
+ /* Elements with opacity: 0 in their initial state must still be focusable
1132
+ when they enter the viewport */
1133
+ .chapter-content {
1134
+ /* Don't use display: none or visibility: hidden for scroll-hidden content */
1135
+ /* Use opacity + pointer-events instead */
1136
+ opacity: 0;
1137
+ pointer-events: none; /* Prevent interaction when hidden */
1138
+ }
1139
+
1140
+ .chapter-content.is-visible {
1141
+ opacity: 1;
1142
+ pointer-events: auto;
1143
+ }
1144
+ ```
1145
+ ```javascript
1146
+ // Manage tabindex for off-screen content
1147
+ ScrollTrigger.create({
1148
+ trigger: '.chapter',
1149
+ onEnter: () => {
1150
+ document.querySelectorAll('.chapter-content a, .chapter-content button')
1151
+ .forEach(el => el.removeAttribute('tabindex'));
1152
+ },
1153
+ onLeave: () => {
1154
+ document.querySelectorAll('.chapter-content a, .chapter-content button')
1155
+ .forEach(el => el.setAttribute('tabindex', '-1'));
1156
+ },
1157
+ onEnterBack: () => { /* same as onEnter */ },
1158
+ onLeaveBack: () => { /* same as onLeave */ },
1159
+ });
1160
+ ```
1161
+
1162
+ ---
1163
+
1164
+ ### Symptom: Screen reader doesn't announce chapter content / navigation
1165
+ **Likely cause:** Missing `aria-label`, `role`, or live region updates for dynamic content.
1166
+
1167
+ **Fix — Add semantic structure and ARIA attributes:**
1168
+ ```html
1169
+ <!-- 1. Chapter sections must have semantic structure -->
1170
+ <section id="chapter-prologue"
1171
+ aria-labelledby="prologue-title"
1172
+ role="region">
1173
+
1174
+ <h2 id="prologue-title" class="sr-only">Prologue</h2>
1175
+ <!-- Visual title (non-heading, decorative) -->
1176
+ <span aria-hidden="true" class="display-title">Prologue</span>
1177
+
1178
+ <!-- Eyebrow and summary are real content -->
1179
+ <p class="eyebrow">The Beginning</p>
1180
+ <p class="summary">Content description here</p>
1181
+ </section>
1182
+
1183
+ <!-- 2. Navigation must be labeled -->
1184
+ <nav aria-label="Chapter navigation">
1185
+ <ul role="list">
1186
+ <li><a href="#chapter-prologue" aria-label="Go to Prologue">I</a></li>
1187
+ <li><a href="#chapter-studio" aria-label="Go to Studio">II</a></li>
1188
+ </ul>
1189
+ </nav>
1190
+
1191
+ <!-- 3. Live region for dynamic content updates -->
1192
+ <div aria-live="polite" aria-atomic="true" class="sr-only">
1193
+ <span id="current-chapter-label">Prologue</span>
1194
+ </div>
1195
+ ```
1196
+ ```javascript
1197
+ // Update live region when chapter changes
1198
+ const observer = new IntersectionObserver((entries) => {
1199
+ const visible = entries.filter(e => e.isIntersecting)
1200
+ .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
1201
+
1202
+ if (visible?.target.id) {
1203
+ document.getElementById('current-chapter-label').textContent =
1204
+ getChapterName(visible.target.id);
1205
+ }
1206
+ }, { threshold: [0.25, 0.5, 0.75] });
1207
+ ```
1208
+ ```css
1209
+ /* Screen reader only text (visually hidden but accessible) */
1210
+ .sr-only {
1211
+ position: absolute;
1212
+ width: 1px;
1213
+ height: 1px;
1214
+ padding: 0;
1215
+ margin: -1px;
1216
+ overflow: hidden;
1217
+ clip: rect(0, 0, 0, 0);
1218
+ white-space: nowrap;
1219
+ border: 0;
1220
+ }
1221
+ ```
1222
+
1223
+ ---
1224
+
1225
+ ## Appendix: Emergency Recovery
1226
+
1227
+ If multiple issues are present and you don't know where to start:
1228
+
1229
+ ### Nuclear option — Reset to known-good state
1230
+
1231
+ ```bash
1232
+ # 1. Reset to bundled templates (Mode B)
1233
+ cd /your-project
1234
+ rm -rf node_modules package-lock.json
1235
+
1236
+ # 2. Copy fresh templates from skill
1237
+ cp -r /path/to/skill/templates/nextjs/* ./
1238
+
1239
+ # 3. Verify package.json has correct dependencies
1240
+ cat package.json | grep -E "lenis|choreo-3d|gsap|next|react"
1241
+
1242
+ # 4. Reinstall
1243
+ npm install
1244
+
1245
+ # 5. Clear all caches
1246
+ rm -rf .next
1247
+ rm -rf node_modules/.cache
1248
+
1249
+ # 6. Verify dev server starts
1250
+ npm run dev
1251
+ ```
1252
+
1253
+ ### Still broken? Check these files exist and are correct:
1254
+
1255
+ ```bash
1256
+ # Mode B critical files — if any are missing, copy from templates
1257
+ ls -la \
1258
+ package.json \
1259
+ tsconfig.json \
1260
+ tailwind.config.ts \
1261
+ postcss.config.js \
1262
+ app/layout.tsx \
1263
+ app/page.tsx \
1264
+ app/globals.css \
1265
+ app/api/fal/proxy/route.ts \
1266
+ app/api/fal/webhook/route.ts \
1267
+ app/api/generate-edition-asset/route.ts \
1268
+ lib/fal-models.ts \
1269
+ lib/fal-generate.ts \
1270
+ lib/use-device.ts \
1271
+ components/ChapterScene.tsx \
1272
+ components/SmoothScrollProvider.tsx
1273
+ ```
1274
+
1275
+ ### Report an issue
1276
+
1277
+ Include in your report:
1278
+ 1. Skill version (from `manifest.md` or `manifest.json`)
1279
+ 2. Mode (A or B)
1280
+ 3. Browser + version
1281
+ 4. Device / OS
1282
+ 5. Exact error message or symptom description
1283
+ 6. Steps to reproduce
1284
+ 7. What you've tried from this troubleshooting guide