@opendirectory.dev/skills 0.1.67 → 0.1.69

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.
@@ -0,0 +1,896 @@
1
+ # Scene Library — vid-product-launch
2
+
3
+ 11 scene types for the 5-section narrative arc. Read this file before generating HTML.
4
+ Each entry includes: purpose, narrative placement, CSS, HTML template, and renderFrame logic.
5
+
6
+ ---
7
+
8
+ ## Scene Placement by Narrative Section
9
+
10
+ ```
11
+ TEASE (builds problem / curiosity):
12
+ blackout-opener — always first (0–1500ms of tease)
13
+ tease-words ★ PRIMARY — each problem keyword as its own 200px+ beat
14
+ tease-problem — fallback: all words together (shorter durations only)
15
+ countdown-card — if launch_date provided (can sit in tease OR cta)
16
+
17
+ BUILD (tension rising, solution approaching):
18
+ terminal-card ★ PRIMARY — 3 typed cards showing manual work being done
19
+ tension-build — supplemental: particles/canvas (max 5s alone, never 20s)
20
+
21
+ REVEAL (the hero moment):
22
+ reveal-hero — product name + tagline. The slam / materialise.
23
+ tagline-card — optional second reveal beat for standalone tagline
24
+
25
+ PROOF (one result, one truth):
26
+ proof-stat — oversized single metric with counter animation
27
+ feature-bullet — single capability with supporting line
28
+
29
+ CTA (close):
30
+ cta-card — URL large, action text. Final frame. Always last.
31
+ ```
32
+
33
+ ★ = preferred. Use these first. Old alternatives still valid for 30s or special cases.
34
+
35
+ ---
36
+
37
+ ## Visual Quality Rules (apply to every scene)
38
+
39
+ - **Dark tease/build**: `background: #000` or `#080808` always — even in minimal/energetic presets.
40
+ - **Dot-grid on dark scenes**: `background-image: radial-gradient(circle, rgba(255,255,255,0.035) 1px, transparent 1px); background-size: 60px 60px;` on build, reveal, proof, cta.
41
+ - **Film grain canvas**: `width="240" height="135"` (stretched via CSS to W×H). Never full resolution.
42
+ - **Vignette**: `radial-gradient(ellipse at center, transparent 28%, rgba(0,0,0,0.65) 100%)` fixed overlay.
43
+ - **Accent glow**: blue/gold lines get `box-shadow: 0 0 18px rgba([accent-rgb],0.45)`.
44
+ - **Typography scale**: tease words ≥ 200px. Product name ≥ 220px. Proof stat ≥ 240px. CTA URL ≥ 88px.
45
+
46
+ ---
47
+
48
+ ## SFX Cue Reference
49
+
50
+ Six synthesized SFX types. The export script generates all from FFmpeg `aevalsrc`/`anoisesrc` — no audio files required. Embed `window.__sfxTimeline` in the HTML before the preview loop.
51
+
52
+ | Scene | SFX Type | Trigger Moment | Vol | Character |
53
+ |---|---|---|---|---|
54
+ | `blackout-opener` | _(silence)_ | — | — | True silence — no SFX before 1000ms |
55
+ | `tease-words` | `word-hit` | Each `WORD_BEATS[i].start` | 0.55 (last: 0.65) | Sub punch (50Hz) + transient click (2.2kHz) + noise burst — 180ms, 3 layers |
56
+ | `terminal-card` | `type-sequence` | `CARDS[i].start + 180` | 0.28 | 15Hz mechanical keyboard clicks for 1.9s — sin^30 pulse × 1200Hz click + 200Hz thud |
57
+ | `terminal-card` (fade) | `whoosh` | `BUILD_CLOSE` | 0.50 | Two-band noise sweep — 1.1kHz body + 4–8kHz air, 700ms |
58
+ | _(transition)_ | `tension-riser` | `BUILD_CLOSE + 700` | 0.35 | 2.9s ascending low rumble + sub tone ramp — peaks at REVEAL_START |
59
+ | `reveal-hero` | `reveal-boom` | `REVEAL_START` (exact) | 0.88 | Sub (45Hz) + body (90Hz) + shimmer (4.5–11kHz) + 85ms echo — 900ms |
60
+ | `proof-stat` | `counter-tick` | `PROOF_START`, `+2200`, `+4400` | 0.32 → 0.20 | Harmonic click — 880Hz + 440Hz + 1760Hz + 2640Hz, 80ms. Decrescendo |
61
+ | `cta-card` | `cta-chime` | `CTA_START` (exact) | 0.62 | A major chord (440 + 554 + 659 + 880Hz) + aecho bell shimmer — 1.2s |
62
+
63
+ **Mixing rules:**
64
+ - If `--music` flag provided: music plays at 22% volume under SFX
65
+ - SFX do not duck background music — they sit on top
66
+ - `adelay` places each SFX at the exact millisecond — timing is frame-accurate
67
+
68
+ ---
69
+
70
+ ## 1. blackout-opener
71
+
72
+ **Purpose:** Silence before the storm. 1–2 seconds of pure darkness, then a single line
73
+ emerges from black. Sets cinematic tone immediately.
74
+
75
+ **CSS:**
76
+ ```css
77
+ .blackout-opener {
78
+ background: #000; /* override preset bg — always black */
79
+ align-items: center;
80
+ justify-content: center;
81
+ }
82
+ .blackout-opener .opener-line {
83
+ font-family: var(--font-body);
84
+ font-size: var(--body-size);
85
+ font-weight: 400;
86
+ letter-spacing: var(--tracking-wide);
87
+ text-transform: uppercase;
88
+ color: var(--text-secondary);
89
+ opacity: 0;
90
+ transform: translateY(8px);
91
+ }
92
+ ```
93
+
94
+ **HTML template:**
95
+ ```html
96
+ <div class="scene blackout-opener">
97
+ <div class="scene-inner" style="text-align:center">
98
+ <p class="opener-line">[one line — year, or a short evocative phrase, or empty for pure black]</p>
99
+ </div>
100
+ </div>
101
+ ```
102
+
103
+ **renderFrame notes:**
104
+ - Scene fades in (standard sceneState opacity).
105
+ - `.opener-line` appears at 40% of scene duration: `opacity 0→1, translateY 8px→0, 600ms`.
106
+ - For pure black opener (no text): omit the `<p>` element entirely.
107
+
108
+ ---
109
+
110
+ ## 2. tease-problem
111
+
112
+ **Purpose:** The problem. Revealed word-by-word in all-caps. Audience recognises themselves.
113
+ Never names the product. Ends on the pain, not the solution.
114
+
115
+ **CSS:**
116
+ ```css
117
+ .tease-problem {
118
+ align-items: center;
119
+ justify-content: center;
120
+ }
121
+ .tease-problem .problem-text {
122
+ font-family: var(--font-display);
123
+ font-size: 52px;
124
+ font-weight: 700;
125
+ letter-spacing: var(--tracking-wide);
126
+ text-transform: uppercase;
127
+ color: var(--text-primary);
128
+ text-align: center;
129
+ line-height: 1.1;
130
+ max-width: 800px;
131
+ }
132
+ .tease-problem .problem-text .word {
133
+ display: inline-block;
134
+ opacity: 0;
135
+ transform: translateY(20px);
136
+ margin-right: 0.2em;
137
+ }
138
+ .tease-problem .sub-line {
139
+ font-family: var(--font-body);
140
+ font-size: var(--body-size);
141
+ color: var(--text-secondary);
142
+ text-align: center;
143
+ margin-top: 24px;
144
+ letter-spacing: var(--tracking-wide);
145
+ text-transform: uppercase;
146
+ opacity: 0;
147
+ }
148
+ ```
149
+
150
+ **HTML template:**
151
+ ```html
152
+ <div class="scene tease-problem">
153
+ <div class="scene-inner" style="text-align:center">
154
+ <p class="problem-text">
155
+ <!-- Agent: wrap each word in a span.word -->
156
+ <span class="word">You've</span>
157
+ <span class="word">been</span>
158
+ <span class="word">doing</span>
159
+ <span class="word">this</span>
160
+ <span class="word">the</span>
161
+ <span class="word">hard</span>
162
+ <span class="word">way.</span>
163
+ </p>
164
+ <p class="sub-line">[optional: short clarifying line, 4–6 words max]</p>
165
+ </div>
166
+ </div>
167
+ ```
168
+
169
+ **renderFrame notes:**
170
+ ```javascript
171
+ // Word-by-word reveal: stagger 180ms per word, starting at 15% of scene duration
172
+ function wordReveal(words, t, sceneStart, sceneEnd) {
173
+ const stagger = 180; // ms between words
174
+ const revealStart = sceneStart + (sceneEnd - sceneStart) * 0.15;
175
+ words.forEach((w, i) => {
176
+ const wStart = revealStart + i * stagger;
177
+ const wDur = 350;
178
+ const p = clamp((t - wStart) / wDur, 0, 1);
179
+ w.style.opacity = easeOutCubic(p);
180
+ w.style.transform = `translateY(${lerp(20, 0, easeOutCubic(p)).toFixed(2)}px)`;
181
+ });
182
+ }
183
+ // Sub-line appears after all words: delay = revealStart + wordCount * stagger + 200ms
184
+ ```
185
+
186
+ ---
187
+
188
+ ## 3. tension-build
189
+
190
+ **Purpose:** Rising action before the reveal. No product name. Visual energy converges
191
+ toward the center — particles, a filling bar, or a count-up to a tipping point.
192
+
193
+ **CSS (particles variant — default):**
194
+ ```css
195
+ .tension-build {
196
+ align-items: center;
197
+ justify-content: center;
198
+ }
199
+ .tension-build canvas.particles {
200
+ position: absolute;
201
+ inset: 0;
202
+ opacity: 0.8;
203
+ }
204
+ .tension-build .build-label {
205
+ font-family: var(--font-body);
206
+ font-size: var(--body-size);
207
+ letter-spacing: var(--tracking-wide);
208
+ text-transform: uppercase;
209
+ color: var(--text-secondary);
210
+ text-align: center;
211
+ position: relative;
212
+ z-index: 1;
213
+ opacity: 0;
214
+ }
215
+ .tension-build .build-counter {
216
+ font-family: var(--font-display);
217
+ font-size: 80px;
218
+ font-weight: 700;
219
+ color: var(--text-primary);
220
+ text-align: center;
221
+ position: relative;
222
+ z-index: 1;
223
+ margin-bottom: 16px;
224
+ opacity: 0;
225
+ }
226
+ ```
227
+
228
+ **HTML template:**
229
+ ```html
230
+ <div class="scene tension-build">
231
+ <canvas class="particles" id="tension-canvas" width="[W]" height="[H]"></canvas>
232
+ <div class="scene-inner" style="text-align:center;pointer-events:none">
233
+ <p class="build-counter" id="build-counter">0</p>
234
+ <p class="build-label">[optional: short context, e.g. "teams wasting hours daily"]</p>
235
+ </div>
236
+ </div>
237
+ ```
238
+
239
+ **renderFrame notes:**
240
+ ```javascript
241
+ // Particle convergence: N particles start spread, converge to center over scene duration
242
+ // Initialise once, then animate position each frame
243
+ function renderParticles(ctx, t, sceneStart, sceneEnd, W, H) {
244
+ if (!window.__particles) {
245
+ window.__particles = Array.from({length: 60}, () => ({
246
+ x: Math.random() * W, y: Math.random() * H,
247
+ tx: W/2 + (Math.random()-0.5)*40, ty: H/2 + (Math.random()-0.5)*40,
248
+ r: Math.random() * 2 + 1,
249
+ }));
250
+ }
251
+ const prog = clamp((t - sceneStart) / (sceneEnd - sceneStart), 0, 1);
252
+ const ease = easeOutCubic(prog);
253
+ ctx.clearRect(0, 0, W, H);
254
+ ctx.fillStyle = 'var(--accent)'; // resolved at runtime; use literal hex from preset
255
+ window.__particles.forEach(p => {
256
+ const px = lerp(p.x, p.tx, ease);
257
+ const py = lerp(p.y, p.ty, ease);
258
+ ctx.beginPath();
259
+ ctx.arc(px, py, p.r, 0, Math.PI * 2);
260
+ ctx.globalAlpha = ease * 0.6;
261
+ ctx.fill();
262
+ });
263
+ }
264
+ // Build counter: counts up from 0 to a number the agent specifies
265
+ // Appears at 10% of scene, completes at 70%
266
+ ```
267
+
268
+ ---
269
+
270
+ ## 4. reveal-hero
271
+
272
+ **Purpose:** THE moment. Product name slams or materialises into view.
273
+ Everything before this built toward this frame. Make it feel earned.
274
+
275
+ **CSS:**
276
+ ```css
277
+ .reveal-hero {
278
+ align-items: center;
279
+ justify-content: center;
280
+ }
281
+ .reveal-hero .flash-overlay {
282
+ position: absolute;
283
+ inset: 0;
284
+ background: #FFFFFF; /* cinematic/emotional: warm #FFF8F0; energetic: #FFFFFF; minimal: #FFFFFF */
285
+ opacity: 0;
286
+ pointer-events: none;
287
+ z-index: 2;
288
+ }
289
+ .reveal-hero .product-name {
290
+ font-family: var(--font-display);
291
+ font-size: var(--product-size); /* min 120px for 16:9, min 80px for 9:16 */
292
+ font-weight: 900;
293
+ letter-spacing: var(--tracking-tight);
294
+ color: var(--text-primary);
295
+ text-align: center;
296
+ line-height: 0.9;
297
+ position: relative;
298
+ z-index: 3;
299
+ opacity: 0;
300
+ transform-origin: center center;
301
+ }
302
+ .reveal-hero .tagline {
303
+ font-family: var(--font-body);
304
+ font-size: var(--tagline-size);
305
+ font-weight: 400;
306
+ letter-spacing: var(--tracking-wide);
307
+ color: var(--accent);
308
+ text-align: center;
309
+ margin-top: 28px;
310
+ position: relative;
311
+ z-index: 3;
312
+ opacity: 0;
313
+ text-transform: uppercase;
314
+ }
315
+ ```
316
+
317
+ **HTML template:**
318
+ ```html
319
+ <div class="scene reveal-hero">
320
+ <div class="flash-overlay" id="reveal-flash"></div>
321
+ <div class="scene-inner" style="text-align:center">
322
+ <h1 class="product-name" id="product-name-el">[PRODUCT NAME]</h1>
323
+ <p class="tagline" id="tagline-el">[tagline — 4–6 words]</p>
324
+ </div>
325
+ </div>
326
+ ```
327
+
328
+ **renderFrame notes:**
329
+ ```javascript
330
+ // Flash fires at scene start: opacity 0→0.6→0 over 300ms
331
+ // Then product name: cinematic=materialise(700ms) | energetic=slam(120ms) | minimal=fade(400ms) | emotional=wordReveal
332
+ // Tagline: fades in 400ms after product name completes
333
+
334
+ function renderRevealHero(t, sceneStart) {
335
+ const flash = document.getElementById('reveal-flash');
336
+ const nameEl = document.getElementById('product-name-el');
337
+ const tagEl = document.getElementById('tagline-el');
338
+
339
+ // Flash: 0→peak→0 in 300ms at scene start
340
+ const flashDur = 300;
341
+ const fp = clamp(t - sceneStart, 0, flashDur) / flashDur;
342
+ flash.style.opacity = (fp < 0.4 ? fp / 0.4 : (1 - (fp - 0.4) / 0.6) * 0.6).toFixed(3);
343
+
344
+ // Product name reveal (example: materialise for cinematic)
345
+ const nameStart = sceneStart + 200; // 200ms after flash peak
346
+ const nameDur = 700;
347
+ const np = clamp((t - nameStart) / nameDur, 0, 1);
348
+ const blurVal = lerp(10, 0, easeOutCubic(np));
349
+ nameEl.style.opacity = easeOutCubic(np).toFixed(3);
350
+ nameEl.style.filter = `blur(${blurVal.toFixed(2)}px)`;
351
+
352
+ // Tagline: after name completes
353
+ const tagStart = nameStart + nameDur + 100;
354
+ const tagDur = 500;
355
+ const tp = clamp((t - tagStart) / tagDur, 0, 1);
356
+ tagEl.style.opacity = easeOutCubic(tp).toFixed(3);
357
+ }
358
+ ```
359
+
360
+ ---
361
+
362
+ ## 5. tagline-card
363
+
364
+ **Purpose:** The product's promise stated alone. Large type, nothing else. Used when the
365
+ tagline is powerful enough to hold a scene solo — or as a second Reveal beat.
366
+
367
+ **CSS:**
368
+ ```css
369
+ .tagline-card {
370
+ align-items: center;
371
+ justify-content: center;
372
+ }
373
+ .tagline-card .main-line {
374
+ font-family: var(--font-display);
375
+ font-size: 64px;
376
+ font-weight: 700;
377
+ letter-spacing: var(--tracking-tight);
378
+ color: var(--text-primary);
379
+ text-align: center;
380
+ line-height: 1.15;
381
+ max-width: 860px;
382
+ opacity: 0;
383
+ }
384
+ .tagline-card .accent-bar {
385
+ width: 48px;
386
+ height: 3px;
387
+ background: var(--accent);
388
+ margin: 32px auto 0;
389
+ transform: scaleX(0);
390
+ transform-origin: left center;
391
+ }
392
+ ```
393
+
394
+ **HTML template:**
395
+ ```html
396
+ <div class="scene tagline-card">
397
+ <div class="scene-inner" style="text-align:center">
398
+ <p class="main-line" id="tagline-main">[The tagline — 4–6 words, no punctuation at end]</p>
399
+ <div class="accent-bar" id="tagline-bar"></div>
400
+ </div>
401
+ </div>
402
+ ```
403
+
404
+ **renderFrame notes:**
405
+ - Text: materialise or fade-up at 10% of scene.
406
+ - Accent bar: `scaleX 0→1` after text completes (200ms, `ease-out`).
407
+
408
+ ---
409
+
410
+ ## 6. proof-stat
411
+
412
+ **Purpose:** One number. One truth. The counter animation makes it feel earned.
413
+ No context list — one stat only. If you have multiple, pick the strongest.
414
+
415
+ **CSS:**
416
+ ```css
417
+ .proof-stat {
418
+ align-items: center;
419
+ justify-content: center;
420
+ }
421
+ .proof-stat .stat-value {
422
+ font-family: var(--font-display);
423
+ font-size: var(--stat-size);
424
+ font-weight: 900;
425
+ letter-spacing: var(--tracking-tight);
426
+ color: var(--text-primary);
427
+ text-align: center;
428
+ line-height: 0.85;
429
+ opacity: 0;
430
+ transform-origin: center center;
431
+ }
432
+ .proof-stat .stat-suffix {
433
+ font-family: var(--font-display);
434
+ font-size: calc(var(--stat-size) * 0.45);
435
+ font-weight: 700;
436
+ color: var(--accent);
437
+ vertical-align: super;
438
+ font-size: 80px;
439
+ }
440
+ .proof-stat .stat-label {
441
+ font-family: var(--font-body);
442
+ font-size: var(--body-size);
443
+ letter-spacing: var(--tracking-wide);
444
+ text-transform: uppercase;
445
+ color: var(--text-secondary);
446
+ text-align: center;
447
+ margin-top: 20px;
448
+ opacity: 0;
449
+ }
450
+ ```
451
+
452
+ **HTML template:**
453
+ ```html
454
+ <div class="scene proof-stat">
455
+ <div class="scene-inner" style="text-align:center">
456
+ <p class="stat-value" id="stat-number">
457
+ <span id="stat-counter">0</span><span class="stat-suffix">[+ or % or × — agent chooses]</span>
458
+ </p>
459
+ <p class="stat-label" id="stat-label">[3–6 word context, e.g. "teams ship 10× faster"]</p>
460
+ </div>
461
+ </div>
462
+ ```
463
+
464
+ **renderFrame notes:**
465
+ ```javascript
466
+ // Stat value: slam-in at scene start (all presets — number always feels energetic)
467
+ // Counter: counts from 0 to TARGET over 60% of scene duration
468
+ // Label: fades in after counter completes
469
+
470
+ function renderProofStat(t, sceneStart, sceneEnd, targetNum) {
471
+ const counterEl = document.getElementById('stat-counter');
472
+ const statEl = document.querySelector('.stat-value');
473
+ const labelEl = document.getElementById('stat-label');
474
+
475
+ // Appear at scene start
476
+ const appearDur = 180;
477
+ const ap = clamp((t - sceneStart) / appearDur, 0, 1);
478
+ statEl.style.opacity = easeOutCubic(ap).toFixed(3);
479
+
480
+ // Counter runs from 0 to targetNum over 60% of scene
481
+ const countDur = (sceneEnd - sceneStart) * 0.60;
482
+ const cp = clamp((t - sceneStart) / countDur, 0, 1);
483
+ counterEl.textContent = Math.round(easeOutCubic(cp) * targetNum).toLocaleString();
484
+
485
+ // Label appears when counter completes
486
+ const labelStart = sceneStart + countDur + 100;
487
+ const lp = clamp((t - labelStart) / 400, 0, 1);
488
+ labelEl.style.opacity = easeOutCubic(lp).toFixed(3);
489
+ }
490
+ ```
491
+
492
+ ---
493
+
494
+ ## 7. feature-bullet
495
+
496
+ **Purpose:** Single product capability with one line of context. Used in Proof section
497
+ when the proof is qualitative rather than numerical.
498
+
499
+ **CSS:**
500
+ ```css
501
+ .feature-bullet {
502
+ align-items: center;
503
+ justify-content: center;
504
+ }
505
+ .feature-bullet .bullet-icon {
506
+ width: 48px;
507
+ height: 3px;
508
+ background: var(--accent);
509
+ margin: 0 auto 40px;
510
+ transform: scaleX(0);
511
+ transform-origin: left center;
512
+ }
513
+ .feature-bullet .bullet-main {
514
+ font-family: var(--font-display);
515
+ font-size: 60px;
516
+ font-weight: 700;
517
+ letter-spacing: var(--tracking-tight);
518
+ color: var(--text-primary);
519
+ text-align: center;
520
+ line-height: 1.1;
521
+ max-width: 800px;
522
+ opacity: 0;
523
+ }
524
+ .feature-bullet .bullet-sub {
525
+ font-family: var(--font-body);
526
+ font-size: var(--body-size);
527
+ color: var(--text-secondary);
528
+ text-align: center;
529
+ margin-top: 20px;
530
+ max-width: 640px;
531
+ line-height: 1.6;
532
+ opacity: 0;
533
+ }
534
+ ```
535
+
536
+ **HTML template:**
537
+ ```html
538
+ <div class="scene feature-bullet">
539
+ <div class="scene-inner" style="text-align:center">
540
+ <div class="bullet-icon" id="bullet-icon"></div>
541
+ <p class="bullet-main" id="bullet-main">[the capability — 4–8 words]</p>
542
+ <p class="bullet-sub" id="bullet-sub">[supporting context — 1 sentence, 10–15 words]</p>
543
+ </div>
544
+ </div>
545
+ ```
546
+
547
+ **renderFrame notes:**
548
+ - Icon bar: `scaleX 0→1` at scene start over 200ms.
549
+ - Main text: materialise/fade at 200ms.
550
+ - Sub text: fade after main completes.
551
+
552
+ ---
553
+
554
+ ## 8. countdown-card
555
+
556
+ **Purpose:** Countdown to launch date. Visual tension device. Can appear in Tease OR as
557
+ a scene within CTA. Agent sets values at generation time based on current date vs launch_date.
558
+
559
+ **CSS:**
560
+ ```css
561
+ .countdown-card {
562
+ align-items: center;
563
+ justify-content: center;
564
+ }
565
+ .countdown-card .countdown-label {
566
+ font-family: var(--font-body);
567
+ font-size: var(--body-size);
568
+ letter-spacing: var(--tracking-wide);
569
+ text-transform: uppercase;
570
+ color: var(--text-secondary);
571
+ text-align: center;
572
+ margin-bottom: 40px;
573
+ opacity: 0;
574
+ }
575
+ .countdown-card .countdown-grid {
576
+ display: flex;
577
+ gap: 48px;
578
+ justify-content: center;
579
+ align-items: flex-end;
580
+ opacity: 0;
581
+ }
582
+ .countdown-card .countdown-unit {
583
+ text-align: center;
584
+ }
585
+ .countdown-card .countdown-num {
586
+ font-family: var(--font-display);
587
+ font-size: 120px;
588
+ font-weight: 900;
589
+ letter-spacing: var(--tracking-tight);
590
+ color: var(--text-primary);
591
+ line-height: 0.9;
592
+ }
593
+ .countdown-card .countdown-sub {
594
+ font-family: var(--font-body);
595
+ font-size: 14px;
596
+ letter-spacing: var(--tracking-wide);
597
+ text-transform: uppercase;
598
+ color: var(--text-secondary);
599
+ margin-top: 12px;
600
+ }
601
+ .countdown-card .countdown-date {
602
+ font-family: var(--font-body);
603
+ font-size: var(--body-size);
604
+ color: var(--accent);
605
+ text-align: center;
606
+ margin-top: 40px;
607
+ letter-spacing: var(--tracking-wide);
608
+ text-transform: uppercase;
609
+ opacity: 0;
610
+ }
611
+ ```
612
+
613
+ **HTML template:**
614
+ ```html
615
+ <div class="scene countdown-card">
616
+ <div class="scene-inner" style="text-align:center">
617
+ <p class="countdown-label" id="cd-label">Launching in</p>
618
+ <div class="countdown-grid" id="cd-grid">
619
+ <div class="countdown-unit">
620
+ <p class="countdown-num">[DD]</p>
621
+ <p class="countdown-sub">Days</p>
622
+ </div>
623
+ <div class="countdown-unit">
624
+ <p class="countdown-num">[HH]</p>
625
+ <p class="countdown-sub">Hours</p>
626
+ </div>
627
+ <div class="countdown-unit">
628
+ <p class="countdown-num">[MM]</p>
629
+ <p class="countdown-sub">Minutes</p>
630
+ </div>
631
+ </div>
632
+ <p class="countdown-date" id="cd-date">[formatted launch date, e.g. "June 12, 2025"]</p>
633
+ </div>
634
+ </div>
635
+ ```
636
+
637
+ **renderFrame notes:**
638
+ - Label fades first (0–200ms of scene).
639
+ - Grid appears (200–500ms of scene): slam-in or materialise per preset.
640
+ - Date fades after grid (500–800ms).
641
+ - Values are static — agent calculates `DD`, `HH`, `MM` at generation time from `launch_date`.
642
+
643
+ ---
644
+
645
+ ## 9. cta-card
646
+
647
+ **Purpose:** The close. URL large. Action text above. Always the final scene.
648
+ Nothing else — no feature list, no secondary CTA.
649
+
650
+ **CSS:**
651
+ ```css
652
+ .cta-card {
653
+ align-items: center;
654
+ justify-content: center;
655
+ }
656
+ .cta-card .cta-action {
657
+ font-family: var(--font-body);
658
+ font-size: var(--body-size);
659
+ letter-spacing: var(--tracking-wide);
660
+ text-transform: uppercase;
661
+ color: var(--text-secondary);
662
+ text-align: center;
663
+ margin-bottom: 24px;
664
+ opacity: 0;
665
+ }
666
+ .cta-card .cta-url {
667
+ font-family: var(--font-display);
668
+ font-size: 72px;
669
+ font-weight: 700;
670
+ letter-spacing: var(--tracking-tight);
671
+ color: var(--text-primary);
672
+ text-align: center;
673
+ line-height: 1;
674
+ opacity: 0;
675
+ }
676
+ .cta-card .cta-accent {
677
+ width: 64px;
678
+ height: 2px;
679
+ background: var(--accent);
680
+ margin: 32px auto 0;
681
+ transform: scaleX(0);
682
+ transform-origin: left center;
683
+ }
684
+ .cta-card .cta-sub {
685
+ font-family: var(--font-body);
686
+ font-size: 18px;
687
+ color: var(--text-secondary);
688
+ text-align: center;
689
+ margin-top: 24px;
690
+ opacity: 0;
691
+ }
692
+ ```
693
+
694
+ **HTML template:**
695
+ ```html
696
+ <div class="scene cta-card">
697
+ <div class="scene-inner" style="text-align:center">
698
+ <p class="cta-action" id="cta-action">[action phrase — "Available now" / "Join the waitlist" / "Try it free"]</p>
699
+ <p class="cta-url" id="cta-url">[url — no https:// prefix]</p>
700
+ <div class="cta-accent" id="cta-accent"></div>
701
+ <p class="cta-sub" id="cta-sub">[optional: one supporting line — "Free 14-day trial. No card required."]</p>
702
+ </div>
703
+ </div>
704
+ ```
705
+
706
+ **renderFrame notes:**
707
+ - Action text: fades in at scene start (300ms).
708
+ - URL: materialise/slam after action (200ms delay, 500ms duration).
709
+ - Accent bar: `scaleX 0→1` after URL (200ms).
710
+ - Sub text: fades after accent bar (200ms).
711
+ - The URL stays fully visible through end of scene — no exit animation on cta-card (it holds on screen).
712
+
713
+ ---
714
+
715
+ ## 10. tease-words ★ PRIMARY TEASE SCENE
716
+
717
+ **Purpose:** Each problem keyword owns the entire screen at massive scale. Punchy, rhythmic, unavoidable. Reference: Apple "Shot on iPhone" cadence. Replaces `tease-problem` for 60s and 90s videos.
718
+
719
+ **Timing:** Each word gets 1600–1800ms. 4 words = ~7200ms. Sub-line fades in at last 1500ms of tease. Reserve first 1500ms for `blackout-opener`.
720
+
721
+ **CSS:**
722
+ ```css
723
+ .scene-tease { background: #000; }
724
+ .hero-word {
725
+ position: absolute; inset: 0;
726
+ display: flex; align-items: center; justify-content: center;
727
+ font-family: var(--font-display);
728
+ font-size: 210px; font-weight: 900;
729
+ letter-spacing: -0.05em; line-height: 1;
730
+ color: #fff;
731
+ opacity: 0; will-change: opacity, transform;
732
+ }
733
+ .tease-sub-line {
734
+ position: absolute; bottom: 140px; left: 0; right: 0; text-align: center;
735
+ font-size: 15px; font-weight: 400;
736
+ letter-spacing: 0.22em; text-transform: uppercase;
737
+ color: rgba(255,255,255,0.20);
738
+ opacity: 0; will-change: opacity;
739
+ }
740
+ ```
741
+
742
+ **HTML template:**
743
+ ```html
744
+ <div class="scene scene-tease">
745
+ <p id="word-0" class="hero-word">[word 1 — e.g. Research.]</p>
746
+ <p id="word-1" class="hero-word">[word 2 — e.g. Write.]</p>
747
+ <p id="word-2" class="hero-word">[word 3 — e.g. Outreach.]</p>
748
+ <p id="word-3" class="hero-word">[word 4 — e.g. Repeat.]</p>
749
+ <p id="tease-sub" class="tease-sub-line">[pain statement — e.g. "Hours that don't scale"]</p>
750
+ </div>
751
+ ```
752
+
753
+ **renderFrame notes:**
754
+ ```javascript
755
+ // Word beats — each word is a separate element, one visible at a time
756
+ const WORD_BEATS = [
757
+ { id: 'word-0', start: TEASE_START + 1500, end: TEASE_START + 3300 },
758
+ { id: 'word-1', start: TEASE_START + 3300, end: TEASE_START + 5100 },
759
+ { id: 'word-2', start: TEASE_START + 5100, end: TEASE_START + 6900 },
760
+ { id: 'word-3', start: TEASE_START + 6900, end: TEASE_END },
761
+ ];
762
+ const FADE = 170; // ms
763
+
764
+ WORD_BEATS.forEach(({ id, start, end }, i) => {
765
+ const el = document.getElementById(id);
766
+ if (!el) return;
767
+ if (t < start || t > end + 60) { el.style.opacity = '0'; el.style.transform = ''; return; }
768
+ if (t < start + FADE) {
769
+ const p = easeOutQuint((t - start) / FADE);
770
+ el.style.opacity = p.toFixed(3);
771
+ el.style.transform = `translateY(${lerp(32, 0, p).toFixed(2)}px)`;
772
+ } else if (i < 3 && t > end - FADE) {
773
+ const p = (t - (end - FADE)) / FADE;
774
+ el.style.opacity = (1 - p).toFixed(3);
775
+ el.style.transform = `translateY(${lerp(0, -22, p).toFixed(2)}px)`;
776
+ } else { el.style.opacity = '1'; el.style.transform = ''; }
777
+ });
778
+
779
+ const tsub = document.getElementById('tease-sub');
780
+ if (tsub) tsub.style.opacity = easeOutCubic(clamp((t - (TEASE_END - 1600)) / 600, 0, 1)).toFixed(3);
781
+ ```
782
+
783
+ **Design rules:**
784
+ - Each word is centered, full-screen, no decoration.
785
+ - 4 words maximum. If description has more pain points, pick the 4 sharpest.
786
+ - Sub-line: 15px, all-caps, very low opacity. It echoes, not competes.
787
+ - Background: always `#000` — never white, even in minimal preset.
788
+
789
+ ---
790
+
791
+ ## 11. terminal-card ★ PRIMARY BUILD SCENE
792
+
793
+ **Purpose:** Shows the audience's current reality — manual work being done, line by line, like a terminal output. 3 cards appear sequentially with a typewriter animation. Creates empathy before the reveal destroys that world. Inspired by Linear's "the old way vs new way" narrative pattern.
794
+
795
+ **CSS:**
796
+ ```css
797
+ .scene-build {
798
+ background: #080808;
799
+ background-image: radial-gradient(circle, rgba(255,255,255,0.035) 1px, transparent 1px);
800
+ background-size: 60px 60px;
801
+ align-items: flex-start; justify-content: center;
802
+ padding: 0 200px;
803
+ }
804
+ .build-header {
805
+ font-size: 13px; font-weight: 500;
806
+ letter-spacing: 0.22em; text-transform: uppercase;
807
+ color: rgba(245,245,245,0.35);
808
+ margin-bottom: 48px;
809
+ opacity: 0; will-change: opacity;
810
+ }
811
+ .term-cards { width: 100%; max-width: 1000px; display: flex; flex-direction: column; gap: 20px; }
812
+ .term-card {
813
+ display: flex; align-items: center; gap: 18px;
814
+ border: 1px solid rgba(255,255,255,0.07);
815
+ border-radius: 14px; padding: 26px 32px;
816
+ background: rgba(255,255,255,0.018);
817
+ opacity: 0; will-change: opacity, transform;
818
+ }
819
+ .term-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
820
+ .term-text {
821
+ font-size: 22px; font-weight: 400; letter-spacing: -0.01em;
822
+ color: rgba(245,245,245,0.65);
823
+ }
824
+ .build-close-line {
825
+ position: absolute; bottom: 140px; left: 200px; right: 200px;
826
+ height: 1px; background: var(--accent);
827
+ transform: scaleX(0); transform-origin: left center; will-change: transform;
828
+ box-shadow: 0 0 18px rgba([accent-rgb],0.4);
829
+ }
830
+ .build-close-label {
831
+ position: absolute; bottom: 96px; left: 0; right: 0; text-align: center;
832
+ font-size: 13px; font-weight: 500; letter-spacing: 0.22em; text-transform: uppercase;
833
+ color: var(--accent); opacity: 0; will-change: opacity;
834
+ }
835
+ ```
836
+
837
+ **HTML template:**
838
+ ```html
839
+ <div class="scene scene-build">
840
+ <div style="width:100%;max-width:1000px">
841
+ <p id="build-header" class="build-header">Meanwhile, your team is spending hours on</p>
842
+ <div class="term-cards">
843
+ <div id="tc0" class="term-card"><div class="term-dot"></div><span id="tt0" class="term-text"></span></div>
844
+ <div id="tc1" class="term-card"><div class="term-dot"></div><span id="tt1" class="term-text"></span></div>
845
+ <div id="tc2" class="term-card"><div class="term-dot"></div><span id="tt2" class="term-text"></span></div>
846
+ </div>
847
+ </div>
848
+ <div id="close-line" class="build-close-line"></div>
849
+ <p id="close-label" class="build-close-label">Not anymore</p>
850
+ </div>
851
+ ```
852
+
853
+ **renderFrame notes:**
854
+ ```javascript
855
+ const CARDS = [
856
+ { id: 0, start: BUILD_START_MS + 1500, text: 'Researching [N] competitor strategies...' },
857
+ { id: 1, start: BUILD_START_MS + 5500, text: 'Writing [N] content drafts from briefs...' },
858
+ { id: 2, start: BUILD_START_MS + 9500, text: 'Scheduling [N] personalised outreach emails...' },
859
+ ];
860
+ const TYPE_DUR = 1800; // ms for full typewriter reveal
861
+ const BUILD_CLOSE = BUILD_END_MS - 3500; // fade cards out before build ends
862
+ const BUILD_LABEL = BUILD_END_MS - 2200;
863
+
864
+ CARDS.forEach(({ id, start, text }) => {
865
+ const card = document.getElementById(`tc${id}`);
866
+ const txt = document.getElementById(`tt${id}`);
867
+ if (!card || !txt) return;
868
+
869
+ if (t < start) { card.style.opacity = '0'; card.style.transform = ''; txt.textContent = ''; return; }
870
+ if (t >= BUILD_CLOSE) {
871
+ const fp = clamp((t - BUILD_CLOSE) / 550, 0, 1);
872
+ card.style.opacity = (1 - easeOutCubic(fp)).toFixed(3);
873
+ card.style.transform = `translateX(${lerp(0, -24, easeOutCubic(fp)).toFixed(2)}px)`;
874
+ } else {
875
+ const fi = clamp((t - start) / 380, 0, 1);
876
+ card.style.opacity = easeOutCubic(fi).toFixed(3);
877
+ card.style.transform = `translateX(${lerp(-24, 0, easeOutCubic(fi)).toFixed(2)}px)`;
878
+ }
879
+ const tp = clamp((t - (start + 180)) / TYPE_DUR, 0, 1);
880
+ const chars = Math.floor(tp * text.length);
881
+ const blink = t < (start + 180 + TYPE_DUR) && chars < text.length && Math.sin((t / 380) * Math.PI) > 0;
882
+ txt.textContent = text.slice(0, chars) + (blink ? '▋' : '');
883
+ });
884
+
885
+ const closeLine = document.getElementById('close-line');
886
+ const closeLabel = document.getElementById('close-label');
887
+ if (closeLine) closeLine.style.transform = `scaleX(${easeOutCubic(clamp((t - BUILD_LABEL) / 650, 0, 1)).toFixed(3)})`;
888
+ if (closeLabel) closeLabel.style.opacity = easeOutCubic(clamp((t - (BUILD_LABEL + 500)) / 500, 0, 1)).toFixed(3);
889
+ ```
890
+
891
+ **Design rules:**
892
+ - Card text must describe the actual manual work the target audience does — not generic placeholders.
893
+ - Tailor the numbers in the card text to the specific product's context (make them feel real).
894
+ - `build-header` sets the scene: "Meanwhile, your team is spending hours on" — always present.
895
+ - Cards fade out 3500ms before build ends, leaving a closing accent line + "Not anymore".
896
+ - Background: dark with dot-grid always — never white.