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,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
|