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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fal.ai model registry — per-model input adapters.
|
|
3
|
+
*
|
|
4
|
+
* Each model on fal.ai has subtly different input parameters:
|
|
5
|
+
* - FLUX.2 family uses `image_size` (enum) and ignores `negative_prompt`/`num_images`
|
|
6
|
+
* - Gemini "Nano Banana" family uses `aspect_ratio` (different enum) + `resolution` + `num_images`
|
|
7
|
+
* - Imagen 3 uses `aspect_ratio`
|
|
8
|
+
*
|
|
9
|
+
* This file is the single source of truth that maps a generic `GenericImageRequest`
|
|
10
|
+
* to the exact shape each model expects. Update here when fal.ai changes a schema —
|
|
11
|
+
* never inline model params in calling code.
|
|
12
|
+
*
|
|
13
|
+
* Source: https://fal.ai/docs/model-api-reference (verified 2026).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type FalImageModelId =
|
|
17
|
+
| 'fal-ai/flux-2-pro'
|
|
18
|
+
| 'fal-ai/flux-2-max'
|
|
19
|
+
| 'fal-ai/flux-2/turbo'
|
|
20
|
+
| 'fal-ai/flux-pro/v1.1/ultra'
|
|
21
|
+
| 'fal-ai/flux-pro/v1.1'
|
|
22
|
+
| 'fal-ai/gemini-3-pro-image-preview'
|
|
23
|
+
| 'fal-ai/gemini-3.1-flash-image-preview'
|
|
24
|
+
| 'fal-ai/gemini-2.5-flash-image'
|
|
25
|
+
| 'fal-ai/imagen3';
|
|
26
|
+
|
|
27
|
+
export type Orientation = 'landscape' | 'portrait' | 'square';
|
|
28
|
+
export type OutputFormat = 'jpeg' | 'png' | 'webp';
|
|
29
|
+
|
|
30
|
+
export type GenericImageRequest = {
|
|
31
|
+
prompt: string;
|
|
32
|
+
/** What the asset is for — drives orientation defaults. */
|
|
33
|
+
orientation: Orientation;
|
|
34
|
+
/** Inline negative-prompt language. FLUX ignores `negative_prompt` so we append it to the prompt string instead. */
|
|
35
|
+
avoid?: string;
|
|
36
|
+
outputFormat?: OutputFormat;
|
|
37
|
+
seed?: number;
|
|
38
|
+
/** Gemini-only: 1K | 2K | 4K. Ignored by FLUX models. */
|
|
39
|
+
resolution?: '1K' | '2K' | '4K';
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type FalModelDescriptor = {
|
|
43
|
+
id: FalImageModelId;
|
|
44
|
+
family: 'flux' | 'gemini' | 'imagen';
|
|
45
|
+
/** Approx cost in USD per generation. */
|
|
46
|
+
costPerImage: number;
|
|
47
|
+
/** Typical wall-clock seconds. */
|
|
48
|
+
speedSec: number;
|
|
49
|
+
buildInput: (req: GenericImageRequest) => Record<string, unknown>;
|
|
50
|
+
/** Extract the first image URL from a fal result. */
|
|
51
|
+
extractUrl: (data: unknown) => string | undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ─── FLUX family ──────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const FLUX_IMAGE_SIZE: Record<Orientation, string> = {
|
|
57
|
+
landscape: 'landscape_16_9',
|
|
58
|
+
portrait: 'portrait_4_3',
|
|
59
|
+
square: 'square_hd',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const buildFluxInput = (req: GenericImageRequest) => {
|
|
63
|
+
// FLUX.2 ignores negative_prompt — inline it into the prompt instead.
|
|
64
|
+
const prompt = req.avoid ? `${req.prompt} Avoid: ${req.avoid}.` : req.prompt;
|
|
65
|
+
return {
|
|
66
|
+
prompt,
|
|
67
|
+
image_size: FLUX_IMAGE_SIZE[req.orientation],
|
|
68
|
+
output_format: req.outputFormat === 'webp' ? 'png' : (req.outputFormat ?? 'jpeg'),
|
|
69
|
+
enable_safety_checker: true,
|
|
70
|
+
safety_tolerance: '2',
|
|
71
|
+
...(req.seed !== undefined ? { seed: req.seed } : {}),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ─── Gemini family (Nano Banana) ──────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const GEMINI_ASPECT: Record<Orientation, string> = {
|
|
78
|
+
landscape: '16:9',
|
|
79
|
+
portrait: '3:4',
|
|
80
|
+
square: '1:1',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const buildGeminiInput = (req: GenericImageRequest) => {
|
|
84
|
+
// Gemini supports negative-language via prompt rewriting only.
|
|
85
|
+
const prompt = req.avoid ? `${req.prompt} Avoid: ${req.avoid}.` : req.prompt;
|
|
86
|
+
return {
|
|
87
|
+
prompt,
|
|
88
|
+
aspect_ratio: GEMINI_ASPECT[req.orientation],
|
|
89
|
+
output_format: req.outputFormat ?? 'png',
|
|
90
|
+
resolution: req.resolution ?? '1K',
|
|
91
|
+
num_images: 1,
|
|
92
|
+
safety_tolerance: '4',
|
|
93
|
+
...(req.seed !== undefined ? { seed: req.seed } : {}),
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ─── Imagen 3 ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const IMAGEN_ASPECT: Record<Orientation, string> = {
|
|
100
|
+
landscape: '16:9',
|
|
101
|
+
portrait: '3:4',
|
|
102
|
+
square: '1:1',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const buildImagenInput = (req: GenericImageRequest) => ({
|
|
106
|
+
prompt: req.avoid ? `${req.prompt} Avoid: ${req.avoid}.` : req.prompt,
|
|
107
|
+
aspect_ratio: IMAGEN_ASPECT[req.orientation],
|
|
108
|
+
num_images: 1,
|
|
109
|
+
...(req.seed !== undefined ? { seed: req.seed } : {}),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ─── Shared output extractor ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const extractFirstUrl = (data: unknown): string | undefined => {
|
|
115
|
+
if (!data || typeof data !== 'object') return undefined;
|
|
116
|
+
const images = (data as { images?: Array<{ url?: string }> }).images;
|
|
117
|
+
return images?.[0]?.url;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ─── Registry ─────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export const FAL_MODELS: Record<FalImageModelId, FalModelDescriptor> = {
|
|
123
|
+
'fal-ai/flux-2-pro': {
|
|
124
|
+
id: 'fal-ai/flux-2-pro',
|
|
125
|
+
family: 'flux',
|
|
126
|
+
costPerImage: 0.06,
|
|
127
|
+
speedSec: 4,
|
|
128
|
+
buildInput: buildFluxInput,
|
|
129
|
+
extractUrl: extractFirstUrl,
|
|
130
|
+
},
|
|
131
|
+
'fal-ai/flux-2-max': {
|
|
132
|
+
id: 'fal-ai/flux-2-max',
|
|
133
|
+
family: 'flux',
|
|
134
|
+
costPerImage: 0.08,
|
|
135
|
+
speedSec: 5,
|
|
136
|
+
buildInput: buildFluxInput,
|
|
137
|
+
extractUrl: extractFirstUrl,
|
|
138
|
+
},
|
|
139
|
+
'fal-ai/flux-2/turbo': {
|
|
140
|
+
id: 'fal-ai/flux-2/turbo',
|
|
141
|
+
family: 'flux',
|
|
142
|
+
costPerImage: 0.02,
|
|
143
|
+
speedSec: 2,
|
|
144
|
+
buildInput: buildFluxInput,
|
|
145
|
+
extractUrl: extractFirstUrl,
|
|
146
|
+
},
|
|
147
|
+
'fal-ai/flux-pro/v1.1/ultra': {
|
|
148
|
+
id: 'fal-ai/flux-pro/v1.1/ultra',
|
|
149
|
+
family: 'flux',
|
|
150
|
+
costPerImage: 0.06,
|
|
151
|
+
speedSec: 10,
|
|
152
|
+
buildInput: buildFluxInput,
|
|
153
|
+
extractUrl: extractFirstUrl,
|
|
154
|
+
},
|
|
155
|
+
'fal-ai/flux-pro/v1.1': {
|
|
156
|
+
id: 'fal-ai/flux-pro/v1.1',
|
|
157
|
+
family: 'flux',
|
|
158
|
+
costPerImage: 0.05,
|
|
159
|
+
speedSec: 4.5,
|
|
160
|
+
buildInput: buildFluxInput,
|
|
161
|
+
extractUrl: extractFirstUrl,
|
|
162
|
+
},
|
|
163
|
+
'fal-ai/gemini-3-pro-image-preview': {
|
|
164
|
+
id: 'fal-ai/gemini-3-pro-image-preview',
|
|
165
|
+
family: 'gemini',
|
|
166
|
+
costPerImage: 0.15,
|
|
167
|
+
speedSec: 8,
|
|
168
|
+
buildInput: buildGeminiInput,
|
|
169
|
+
extractUrl: extractFirstUrl,
|
|
170
|
+
},
|
|
171
|
+
'fal-ai/gemini-3.1-flash-image-preview': {
|
|
172
|
+
id: 'fal-ai/gemini-3.1-flash-image-preview',
|
|
173
|
+
family: 'gemini',
|
|
174
|
+
costPerImage: 0.07,
|
|
175
|
+
speedSec: 2,
|
|
176
|
+
buildInput: buildGeminiInput,
|
|
177
|
+
extractUrl: extractFirstUrl,
|
|
178
|
+
},
|
|
179
|
+
'fal-ai/gemini-2.5-flash-image': {
|
|
180
|
+
id: 'fal-ai/gemini-2.5-flash-image',
|
|
181
|
+
family: 'gemini',
|
|
182
|
+
costPerImage: 0.04,
|
|
183
|
+
speedSec: 2,
|
|
184
|
+
buildInput: buildGeminiInput,
|
|
185
|
+
extractUrl: extractFirstUrl,
|
|
186
|
+
},
|
|
187
|
+
'fal-ai/imagen3': {
|
|
188
|
+
id: 'fal-ai/imagen3',
|
|
189
|
+
family: 'imagen',
|
|
190
|
+
costPerImage: 0.04,
|
|
191
|
+
speedSec: 3,
|
|
192
|
+
buildInput: buildImagenInput,
|
|
193
|
+
extractUrl: extractFirstUrl,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** Resolve a model id from env or fallback to FLUX.2 Pro (the recommended default). */
|
|
198
|
+
export function resolveModelId(envValue?: string): FalImageModelId {
|
|
199
|
+
const id = envValue as FalImageModelId | undefined;
|
|
200
|
+
return id && id in FAL_MODELS ? id : 'fal-ai/flux-2-pro';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getModel(id: FalImageModelId): FalModelDescriptor {
|
|
204
|
+
return FAL_MODELS[id];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const ALLOWED_FAL_ENDPOINTS = [
|
|
208
|
+
...Object.keys(FAL_MODELS),
|
|
209
|
+
// Edit variants — many models also expose /edit
|
|
210
|
+
'fal-ai/flux-2-pro/edit',
|
|
211
|
+
'fal-ai/gemini-3-pro-image-preview/edit',
|
|
212
|
+
'fal-ai/gemini-3.1-flash-image-preview/edit',
|
|
213
|
+
];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured editorial prompt contract for fal.ai image generation.
|
|
3
|
+
*
|
|
4
|
+
* The same prompt format works across FLUX.2, Gemini "Nano Banana", and Imagen 3.
|
|
5
|
+
* Negative-prompt language is inlined via `EDITION_AVOID` and applied by
|
|
6
|
+
* `fal-models.ts` because FLUX.2 ignores the `negative_prompt` API param.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type HistoricalLayer = 'renaissance' | 'baroque' | 'atelier' | 'architectural' | 'industrial';
|
|
10
|
+
export type CameraMode = 'wide' | 'medium' | 'macro' | 'isometric' | 'low-angle';
|
|
11
|
+
export type OutputRole = 'hero' | 'chapter-bg' | 'foreground-object' | 'poster' | 'motion-source';
|
|
12
|
+
|
|
13
|
+
export type EditionAssetPrompt = {
|
|
14
|
+
chapterId: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
productTruth: string;
|
|
17
|
+
historicalLayer: HistoricalLayer;
|
|
18
|
+
modernLayer: string;
|
|
19
|
+
palette: string[];
|
|
20
|
+
camera: CameraMode;
|
|
21
|
+
outputRole: OutputRole;
|
|
22
|
+
/** Optional — deterministic generation when set. */
|
|
23
|
+
seed?: number;
|
|
24
|
+
/** Optional — override the default editorial aesthetic for a single chapter. */
|
|
25
|
+
aestheticDirection?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const HISTORICAL_GUIDANCE: Record<HistoricalLayer, string> = {
|
|
29
|
+
renaissance:
|
|
30
|
+
'Renaissance composition: layered chiaroscuro, dramatic fabric, sfumato edges, classical proportions, museum-grade lighting.',
|
|
31
|
+
baroque:
|
|
32
|
+
'Baroque composition: theatrical movement, deep shadow contrast, gilded textures, dynamic diagonals, opulent palette.',
|
|
33
|
+
atelier:
|
|
34
|
+
'Painterly atelier composition: visible brushwork, warm sepia base, soft natural studio light, painterly imperfection.',
|
|
35
|
+
architectural:
|
|
36
|
+
'Architectural drafting composition: orthographic clarity, parchment tones, fine ink lines, structural geometry, blueprint sensibility.',
|
|
37
|
+
industrial:
|
|
38
|
+
'Industrial-era composition: forged metal, oxidised brass, steam-warm light, mechanical detail, victorian engine-room atmosphere.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const CAMERA_GUIDANCE: Record<CameraMode, string> = {
|
|
42
|
+
wide: 'Wide cinematic shot, deep field, atmospheric perspective.',
|
|
43
|
+
medium: 'Medium shot, balanced subject framing, contextual environment.',
|
|
44
|
+
macro: 'Macro detail shot, shallow depth of field, tactile material focus.',
|
|
45
|
+
isometric: 'Clean isometric projection, technical clarity, even lighting.',
|
|
46
|
+
'low-angle': 'Low-angle hero shot, monumental perspective, sky negative space.',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const OUTPUT_ROLE_GUIDANCE: Record<OutputRole, string> = {
|
|
50
|
+
hero: 'Primary chapter hero image. Compose with negative space at top-left for HTML title overlay.',
|
|
51
|
+
'chapter-bg': 'Background plate. Subject de-emphasised, atmosphere primary, suitable for radial vignette overlay.',
|
|
52
|
+
'foreground-object': 'Isolated subject on neutral background. Portrait crop. Clean edge separation for cut-out use.',
|
|
53
|
+
poster: 'Editorial poster composition. Strong central subject, dramatic palette, frame-filling.',
|
|
54
|
+
'motion-source': 'First-frame of a motion sequence. Stable composition, gentle implied movement.',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function buildEditionPrompt(input: EditionAssetPrompt): string {
|
|
58
|
+
const parts: string[] = [
|
|
59
|
+
'Original editorial product-scene image for a high-craft interactive release website.',
|
|
60
|
+
`Scene: ${input.subject}.`,
|
|
61
|
+
`Product truth: ${input.productTruth}.`,
|
|
62
|
+
HISTORICAL_GUIDANCE[input.historicalLayer],
|
|
63
|
+
`Modern layer: ${input.modernLayer} — integrated naturally, not stickered on.`,
|
|
64
|
+
`Palette: ${input.palette.join(', ')}.`,
|
|
65
|
+
CAMERA_GUIDANCE[input.camera],
|
|
66
|
+
OUTPUT_ROLE_GUIDANCE[input.outputRole],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
if (input.aestheticDirection) {
|
|
70
|
+
parts.push(`Aesthetic direction: ${input.aestheticDirection}.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
parts.push(
|
|
74
|
+
'No brand logos, no readable text, no imitation of named living artists, no stock-photo gloss.',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return parts.join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inline negative-prompt language. Applied by `fal-models.ts` as
|
|
82
|
+
* "Avoid: …" inside the main prompt string, because FLUX.2 Pro ignores
|
|
83
|
+
* the `negative_prompt` API parameter.
|
|
84
|
+
*/
|
|
85
|
+
export const EDITION_AVOID = [
|
|
86
|
+
'brand logos',
|
|
87
|
+
'unreadable text overlays',
|
|
88
|
+
'fake UI labels baked into the image',
|
|
89
|
+
'watermarks',
|
|
90
|
+
'low resolution',
|
|
91
|
+
'distorted hands',
|
|
92
|
+
'extra limbs',
|
|
93
|
+
'over-saturated colour',
|
|
94
|
+
'plastic skin',
|
|
95
|
+
'generic AI gloss',
|
|
96
|
+
'stock photography composition',
|
|
97
|
+
].join(', ');
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/** True when viewport is narrower than the given breakpoint (default 768px). SSR-safe. */
|
|
6
|
+
export function useIsMobile(breakpoint = 768) {
|
|
7
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const mq = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
|
10
|
+
const handler = () => setIsMobile(mq.matches);
|
|
11
|
+
handler();
|
|
12
|
+
mq.addEventListener('change', handler);
|
|
13
|
+
return () => mq.removeEventListener('change', handler);
|
|
14
|
+
}, [breakpoint]);
|
|
15
|
+
return isMobile;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** True on coarse-pointer / no-hover devices (touch). */
|
|
19
|
+
export function useIsTouch() {
|
|
20
|
+
const [isTouch, setIsTouch] = useState(false);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const mq = window.matchMedia('(hover: none) and (pointer: coarse)');
|
|
23
|
+
const handler = () => setIsTouch(mq.matches);
|
|
24
|
+
handler();
|
|
25
|
+
mq.addEventListener('change', handler);
|
|
26
|
+
return () => mq.removeEventListener('change', handler);
|
|
27
|
+
}, []);
|
|
28
|
+
return isTouch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when user requests reduced motion. */
|
|
32
|
+
export function useReducedMotion() {
|
|
33
|
+
const [reduced, setReduced] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
36
|
+
const handler = () => setReduced(mq.matches);
|
|
37
|
+
handler();
|
|
38
|
+
mq.addEventListener('change', handler);
|
|
39
|
+
return () => mq.removeEventListener('change', handler);
|
|
40
|
+
}, []);
|
|
41
|
+
return reduced;
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import Lenis from 'lenis';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Smooth scroll for release pages. Disabled when prefers-reduced-motion is set.
|
|
8
|
+
* If GSAP ScrollTrigger is used, forward Lenis rAF to ScrollTrigger.update().
|
|
9
|
+
*/
|
|
10
|
+
export function useLenisSmoothScroll(enabled = true) {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!enabled) return;
|
|
13
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
14
|
+
if (mq.matches) return;
|
|
15
|
+
|
|
16
|
+
const lenis = new Lenis({
|
|
17
|
+
duration: 1.2,
|
|
18
|
+
smoothWheel: true,
|
|
19
|
+
wheelMultiplier: 1,
|
|
20
|
+
touchMultiplier: 1.2,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
let frameId = 0;
|
|
24
|
+
const raf = (time: number) => {
|
|
25
|
+
lenis.raf(time);
|
|
26
|
+
frameId = requestAnimationFrame(raf);
|
|
27
|
+
};
|
|
28
|
+
frameId = requestAnimationFrame(raf);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
cancelAnimationFrame(frameId);
|
|
32
|
+
lenis.destroy();
|
|
33
|
+
};
|
|
34
|
+
}, [enabled]);
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NextConfig } from 'next';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Baseline security response headers for the deployed app (defense-in-depth).
|
|
5
|
+
*
|
|
6
|
+
* A strict Content-Security-Policy is intentionally left out: GSAP/Framer Motion
|
|
7
|
+
* and Next.js inject inline styles/scripts, so a wrong CSP silently breaks the
|
|
8
|
+
* page. Add one deliberately (with nonces) once your asset origins are settled —
|
|
9
|
+
* a starting point is commented below.
|
|
10
|
+
*/
|
|
11
|
+
const securityHeaders = [
|
|
12
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
13
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
14
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
15
|
+
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
|
|
16
|
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
|
17
|
+
// {
|
|
18
|
+
// key: 'Content-Security-Policy',
|
|
19
|
+
// value: "default-src 'self'; img-src 'self' https: data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
|
20
|
+
// },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const nextConfig: NextConfig = {
|
|
24
|
+
async headers() {
|
|
25
|
+
return [{ source: '/:path*', headers: securityHeaders }];
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default nextConfig;
|