intro and bg

This commit is contained in:
Dorian
2026-05-10 12:13:03 +01:00
parent 6dca131724
commit e12faddb5d
4 changed files with 678 additions and 0 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 2.0 MiB

456
public/intro-test.html Normal file
View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Intro Reveal Tests — In + Out</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Barlow:wght@300;400;600;700&display=swap" rel="stylesheet"/>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--paint: #0a0a0a;
--bg: #FAFAFA;
--text: #1A1A18;
--muted: #5A5A54;
--green: #2a3010;
--cream: #f4ecd8;
--easeOut: cubic-bezier(0.22, 0.61, 0.36, 1);
--easeStrong: cubic-bezier(0.16, 1, 0.3, 1);
--easeIn: cubic-bezier(0.4, 0, 1, 1);
}
body {
background: var(--bg);
font-family: 'Barlow', sans-serif;
color: var(--text);
padding: 40px 20px 80px;
min-height: 100vh;
}
header {
max-width: 800px;
margin: 0 auto 32px;
text-align: center;
}
h1 {
font-family: 'DM Serif Display', serif;
font-weight: 400;
font-size: 40px;
margin-bottom: 8px;
letter-spacing: 0.01em;
}
.lede {
color: var(--muted);
font-size: 14px;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.lede small {
display: block;
margin-top: 6px;
color: var(--muted);
opacity: 0.85;
}
.controls {
text-align: center;
margin-bottom: 32px;
}
.btn-all {
font-family: 'Barlow', sans-serif;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
background: var(--green);
color: var(--cream);
border: none;
padding: 12px 22px;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 3px 6px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,236,200,0.15);
}
.btn-all:hover { background: #1a2008; }
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
max-width: 800px;
margin: 0 auto;
}
@media (min-width: 720px) {
.grid { grid-template-columns: 1fr 1fr; }
}
.card {
background: #FFFFFF;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.card:hover { border-color: rgba(0,0,0,0.18); transform: translateY(-2px); }
.card-stage {
position: relative;
height: 240px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 20px;
overflow: hidden;
background: var(--bg);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
.card-stage .demo-text {
display: inline-block;
font-family: 'Barlow', sans-serif;
font-weight: 400;
font-size: clamp(18px, 3.4vw, 26px);
text-transform: uppercase;
letter-spacing: 0.22em;
line-height: 1.3;
color: var(--paint);
opacity: 0; /* default hidden until .play-in or .play-out applied */
will-change: opacity, transform, filter, clip-path;
}
.card-stage.is-resting .demo-text {
opacity: 1; /* resting state for outro previews */
filter: none;
transform: none;
clip-path: inset(0 0 0 0);
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
}
.card-name {
font-weight: 600;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
}
.card-name small {
display: block;
font-weight: 400;
font-size: 11px;
letter-spacing: 0.02em;
text-transform: none;
color: var(--muted);
margin-top: 2px;
}
.card-num {
display: inline-block;
width: 26px; height: 26px;
border-radius: 50%;
background: var(--green);
color: var(--cream);
font-size: 11px;
font-weight: 700;
line-height: 26px;
text-align: center;
flex-shrink: 0;
}
.card-actions { display: flex; gap: 6px; }
.btn {
font-family: 'Barlow', sans-serif;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
background: transparent;
color: var(--text);
border: 1px solid rgba(0,0,0,0.18);
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.btn:hover { background: rgba(0,0,0,0.04); }
.btn.btn-out { border-color: rgba(192,48,48,0.4); color: #C03030; }
.btn.btn-out:hover { background: rgba(192,48,48,0.06); }
.tip {
text-align: center;
margin-top: 32px;
color: var(--muted);
font-size: 12px;
font-family: 'Barlow', monospace;
letter-spacing: 0.06em;
}
/* ─────────────────────────────────────────────────────────────
IN + OUT animations — every style now has a paired exit so the
user can preview the full cycle. Click the card or "Cycle" to
run In → hold → Out; "In" / "Out" buttons fire one direction.
───────────────────────────────────────────────────────────── */
/* 1. SMOKE */
@keyframes a-smoke-in { 0% { opacity: 0; filter: blur(16px); transform: translateY(14px); } 35% { opacity: 0.6; } 100% { opacity: 1; filter: blur(0); transform: translateY(0); } }
@keyframes a-smoke-out { 0% { opacity: 1; filter: blur(0); transform: translateY(0); } 100% { opacity: 0; filter: blur(16px); transform: translateY(-14px); } }
.demo-1.play-in { animation: a-smoke-in 1.6s var(--easeOut) forwards; }
.demo-1.play-out { animation: a-smoke-out 1.0s var(--easeIn) forwards; }
/* 2. FOCUS PULL */
@keyframes a-focus-in { 0% { opacity: 0; filter: blur(28px); } 40% { opacity: 1; filter: blur(28px); } 100% { opacity: 1; filter: blur(0); } }
@keyframes a-focus-out { 0% { opacity: 1; filter: blur(0); } 60% { opacity: 1; filter: blur(28px); } 100% { opacity: 0; filter: blur(28px); } }
.demo-2.play-in { animation: a-focus-in 1.8s var(--easeStrong) forwards; }
.demo-2.play-out { animation: a-focus-out 1.4s var(--easeIn) forwards; }
/* 3. SLOW ZOOM (entry: 1.08 → 1.00 ; exit: 1.00 → 1.10 outward) */
@keyframes a-zoom-in { 0% { opacity: 0; transform: scale(1.08); filter: blur(2px); } 100% { opacity: 1; transform: scale(1); filter: blur(0); } }
@keyframes a-zoom-out { 0% { opacity: 1; transform: scale(1); filter: blur(0); } 100% { opacity: 0; transform: scale(1.10); filter: blur(2px); } }
.demo-3.play-in { animation: a-zoom-in 2.2s var(--easeStrong) forwards; }
.demo-3.play-out { animation: a-zoom-out 1.0s var(--easeIn) forwards; }
/* 4. PUSH-IN (entry: 0.92 → 1.00 ; exit: 1.00 → 0.92 reverse dolly) */
@keyframes a-push-in { 0% { opacity: 0; transform: scale(0.92); filter: blur(3px); } 100% { opacity: 1; transform: scale(1); filter: blur(0); } }
@keyframes a-push-out { 0% { opacity: 1; transform: scale(1); filter: blur(0); } 100% { opacity: 0; transform: scale(0.92); filter: blur(3px); } }
.demo-4.play-in { animation: a-push-in 1.6s var(--easeOut) forwards; }
.demo-4.play-out { animation: a-push-out 1.0s var(--easeIn) forwards; }
/* 5. MASK UP (entry: clip bottom → top ; exit: clip top → bottom) */
@keyframes a-mask-in { 0% { opacity: 1; clip-path: inset(100% 0 0 0); } 10% { opacity: 1; } 100% { opacity: 1; clip-path: inset(0 0 0 0); } }
@keyframes a-mask-out { 0% { opacity: 1; clip-path: inset(0 0 0 0); } 100% { opacity: 1; clip-path: inset(0 0 100% 0); } }
.demo-5.play-in { animation: a-mask-in 1.4s var(--easeStrong) forwards; }
.demo-5.play-out { animation: a-mask-out 1.0s var(--easeIn) forwards; }
/* 6. IRIS (entry: circle 0% → 75% ; exit: circle 75% → 0%) */
@keyframes a-iris-in { 0% { opacity: 1; clip-path: circle(0% at 50% 50%); } 100% { opacity: 1; clip-path: circle(75% at 50% 50%); } }
@keyframes a-iris-out { 0% { opacity: 1; clip-path: circle(75% at 50% 50%); } 100% { opacity: 1; clip-path: circle(0% at 50% 50%); } }
.demo-6.play-in { animation: a-iris-in 1.4s var(--easeStrong) forwards; }
.demo-6.play-out { animation: a-iris-out 1.0s var(--easeIn) forwards; }
/* 7. WORD CASCADE (each word in/out with stagger; JS sets per-word delay) */
@keyframes a-word-in { 0% { opacity: 0; transform: translateY(14px); filter: blur(8px); } 100% { opacity: 1; transform: translateY(0); filter: blur(0); } }
@keyframes a-word-out { 0% { opacity: 1; transform: translateY(0); filter: blur(0); } 100% { opacity: 0; transform: translateY(-12px); filter: blur(6px); } }
.card-stage.style-7 .demo-text { opacity: 1; }
.card-stage.style-7 .demo-text > .word {
display: inline-block;
margin-right: 0.25em;
opacity: 0;
}
.card-stage.style-7.is-resting .demo-text > .word { opacity: 1; }
.card-stage.style-7.play-in .demo-text > .word { animation: a-word-in 0.7s var(--easeOut) forwards; }
.card-stage.style-7.play-out .demo-text > .word { animation: a-word-out 0.55s var(--easeIn) forwards; }
/* 8. GLOW HALO */
@keyframes a-glow-in { 0% { opacity: 0; text-shadow: 0 0 0 rgba(90,154,120,0); filter: blur(2px); transform: scale(0.97); } 45% { opacity: 1; text-shadow: 0 0 28px rgba(90,154,120,0.65), 0 0 12px rgba(90,154,120,0.45); } 100% { opacity: 1; text-shadow: 0 0 0 rgba(90,154,120,0); filter: blur(0); transform: scale(1); } }
@keyframes a-glow-out { 0% { opacity: 1; text-shadow: 0 0 0 rgba(90,154,120,0); filter: blur(0); } 50% { opacity: 1; text-shadow: 0 0 32px rgba(90,154,120,0.55); } 100% { opacity: 0; text-shadow: 0 0 4px rgba(90,154,120,0); filter: blur(2px); transform: scale(1.04); } }
.demo-8.play-in { animation: a-glow-in 1.9s var(--easeOut) forwards; }
.demo-8.play-out { animation: a-glow-out 1.2s var(--easeIn) forwards; }
/* 9. CHROMATIC */
.card-stage.style-9 .demo-text { position: relative; }
.card-stage.style-9 .demo-text::before,
.card-stage.style-9 .demo-text::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
}
.card-stage.style-9 .demo-text::before { color: rgba(220,40,40,0.85); }
.card-stage.style-9 .demo-text::after { color: rgba(40,120,220,0.85); }
@keyframes a-chrom-base-in { 0% { opacity: 0; } 20% { opacity: 1; } 100% { opacity: 1; } }
@keyframes a-chrom-base-out { 0% { opacity: 1; } 100% { opacity: 0; } }
@keyframes a-chrom-r-in { 0% { opacity: 0; transform: translate3d(-22px,0,0); } 20% { opacity: 0.85; } 100% { opacity: 0; transform: translate3d(0,0,0); } }
@keyframes a-chrom-r-out { 0% { opacity: 0; transform: translate3d(0,0,0); } 30% { opacity: 0.85; transform: translate3d(-22px,0,0); } 100% { opacity: 0; transform: translate3d(-32px,0,0); } }
@keyframes a-chrom-b-in { 0% { opacity: 0; transform: translate3d(22px,0,0); } 20% { opacity: 0.85; } 100% { opacity: 0; transform: translate3d(0,0,0); } }
@keyframes a-chrom-b-out { 0% { opacity: 0; transform: translate3d(0,0,0); } 30% { opacity: 0.85; transform: translate3d(22px,0,0); } 100% { opacity: 0; transform: translate3d(32px,0,0); } }
.card-stage.style-9.play-in .demo-text { animation: a-chrom-base-in 1.4s var(--easeStrong) forwards; }
.card-stage.style-9.play-in .demo-text::before { animation: a-chrom-r-in 1.4s var(--easeStrong) forwards; }
.card-stage.style-9.play-in .demo-text::after { animation: a-chrom-b-in 1.4s var(--easeStrong) forwards; }
.card-stage.style-9.play-out .demo-text { animation: a-chrom-base-out 1.0s var(--easeIn) forwards; }
.card-stage.style-9.play-out .demo-text::before { animation: a-chrom-r-out 1.0s var(--easeIn) forwards; }
.card-stage.style-9.play-out .demo-text::after { animation: a-chrom-b-out 1.0s var(--easeIn) forwards; }
/* 10. VAPOR RISE (entry: rises into focus ; exit: continues rising into haze) */
@keyframes a-vapor-in { 0% { opacity: 0; filter: blur(0) brightness(1.3); transform: translateY(28px); } 20% { opacity: 0.45; filter: blur(8px) brightness(1.2); } 100% { opacity: 1; filter: blur(0) brightness(1); transform: translateY(0); } }
@keyframes a-vapor-out { 0% { opacity: 1; filter: blur(0) brightness(1); transform: translateY(0); } 100% { opacity: 0; filter: blur(8px) brightness(1.2); transform: translateY(-28px); } }
.demo-10.play-in { animation: a-vapor-in 2.0s var(--easeOut) forwards; }
.demo-10.play-out { animation: a-vapor-out 1.0s var(--easeIn) forwards; }
</style>
</head>
<body>
<header>
<h1>Intro Reveal Tests — In + Out</h1>
<p class="lede">
Ten cinematic reveal styles, each with a matching exit.
<small>Tap the card for a full <em>In → hold → Out</em> cycle. "In" / "Out" preview each direction independently.</small>
</p>
</header>
<div class="controls">
<button class="btn-all" id="play-all">▶ Cycle All Together</button>
</div>
<div class="grid">
<div class="card" data-num="1">
<div class="card-stage style-1"><span class="demo-text demo-1">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">01</span><div>Smoke<small>blur 16px → focus → blur out</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="2">
<div class="card-stage style-2"><span class="demo-text demo-2">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">02</span><div>Focus pull<small>blur snaps to focus, then defocuses</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="3">
<div class="card-stage style-3"><span class="demo-text demo-3">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">03</span><div>Slow zoom<small>1.08 → 1.00 → 1.10 (camera pulls back)</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="4">
<div class="card-stage style-4"><span class="demo-text demo-4">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">04</span><div>Push-in<small>0.92 → 1.00 → 0.92 (reverse dolly)</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="5">
<div class="card-stage style-5"><span class="demo-text demo-5">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">05</span><div>Mask up<small>clip bottom → top; exits top → bottom</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="6">
<div class="card-stage style-6"><span class="demo-text demo-6">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">06</span><div>Iris<small>circle opens, then closes</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="7">
<div class="card-stage style-7"><span class="demo-text demo-7" data-text="Wisdom is to prepare">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">07</span><div>Word cascade<small>words in & out, staggered up/down</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="8">
<div class="card-stage style-8"><span class="demo-text demo-8">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">08</span><div>Glow halo<small>halo in, halo flares & fades out</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="9">
<div class="card-stage style-9"><span class="demo-text demo-9" data-text="Wisdom is to prepare">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">09</span><div>Chromatic<small>RGB split converges; splits apart on out</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
<div class="card" data-num="10">
<div class="card-stage style-10"><span class="demo-text demo-10">Wisdom is to prepare</span></div>
<div class="card-foot">
<div class="card-name"><span class="card-num">10</span><div>Vapor rise<small>rises into focus; continues rising into haze</small></div></div>
<div class="card-actions"><button class="btn btn-in">In</button><button class="btn btn-out">Out</button></div>
</div>
</div>
</div>
<p class="tip">Click a card for the full cycle, or use In / Out buttons to preview each direction.</p>
<script>
// Word cascade — split text into <span class="word"> chunks with stagger.
function buildWords() {
const t = document.querySelector('.demo-7')
if (!t) return
const text = t.dataset.text || t.textContent || ''
t.innerHTML = ''
text.split(' ').forEach((w, i) => {
const span = document.createElement('span')
span.className = 'word'
span.dataset.idx = i
span.dataset.total = text.split(' ').length
span.style.animationDelay = (i * 0.18) + 's'
span.textContent = w
t.appendChild(span)
})
}
buildWords()
// Stages 7 and 9 toggle classes on the .card-stage; the rest toggle on
// the .demo-N inner element.
function targetFor(num) {
const stageLevel = ['7', '9'].includes(String(num))
return stageLevel
? document.querySelector('.style-' + num)
: document.querySelector('.demo-' + num)
}
function setClass(num, cls) {
const t = targetFor(num)
if (!t) return
t.classList.remove('play-in', 'play-out')
void t.offsetWidth
t.classList.add(cls)
}
function reset(num) {
const t = targetFor(num)
if (!t) return
t.classList.remove('play-in', 'play-out')
// Park stage 7 (cascade) and 5/6 (clip) in resting state for outro previews.
const stage = (['7','9'].includes(String(num))) ? t : t.closest('.card-stage')
if (stage) stage.classList.add('is-resting')
}
function playIn(num) { document.querySelector('.style-' + num)?.classList.remove('is-resting'); setClass(num, 'play-in') }
function playOut(num) { document.querySelector('.style-' + num)?.classList.add('is-resting'); setClass(num, 'play-out') }
function cycle(num) {
playIn(num)
// Hold for a moment after in completes (~1.6s baseline + 0.7s read)
setTimeout(() => playOut(num), 2400)
}
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => cycle(card.dataset.num))
})
document.querySelectorAll('.btn-in').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); playIn(btn.closest('.card').dataset.num) })
})
document.querySelectorAll('.btn-out').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); playOut(btn.closest('.card').dataset.num) })
})
document.getElementById('play-all').addEventListener('click', () => {
for (let n = 1; n <= 10; n++) cycle(n)
})
// Auto-cycle each card once on load with a stagger.
window.addEventListener('load', () => {
for (let n = 1; n <= 10; n++) {
setTimeout(() => cycle(n), 300 + n * 150)
}
})
</script>
</body>
</html>

View File

@@ -192,6 +192,22 @@
</defs>
</svg>
<!-- INTRO OVERLAY three-stage typographic intro on first visit
(sweep reveal "Even if crisis" pause "is not here yet" final
pitch), then fades out to expose the hero. Skipped on subsequent
loads in the same tab via sessionStorage. -->
<div class="intro-overlay" id="intro-overlay" aria-hidden="true">
<div class="intro-stage stage-1" data-stage="1">
<span class="intro-text" data-i18n="intro_l1">Wisdom is to prepare</span>
</div>
<div class="intro-stage stage-2" data-stage="2">
<span class="intro-text" data-i18n="intro_l2">Even if crisis is not here yet</span>
</div>
<div class="intro-stage stage-3" data-stage="3">
<span class="intro-text" data-i18n="intro_l3">Figure out your Plan B in less than two minutes.</span>
</div>
</div>
<header class="site-header">
<div class="logo" data-i18n="brand">Deepstock</div>
<div class="header-right">
@@ -548,6 +564,9 @@ const T = {
hero_eyebrow: "Crisis Preparedness Advisor",
hero_sub: "Preparedness, refined.",
hero_cta: "Begin",
intro_l1: "Wisdom is to prepare",
intro_l2: "Even if crisis is not here yet",
intro_l3: "Figure out your Plan B in less than two minutes.",
stat_scenarios: "Scenarios",
stat_questions: "Questions",
stat_free: "Free Forever",
@@ -665,6 +684,9 @@ const T = {
hero_eyebrow: "⚡ Krisenvorsorge-Berater",
hero_sub: "Vorsorge, verfeinert.",
hero_cta: "Beginnen",
intro_l1: "Weise ist, wer vorsorgt.",
intro_l2: "Auch wenn noch keine Krise da ist.",
intro_l3: "Finden Sie Ihren Plan B in weniger als zwei Minuten.",
stat_scenarios: "Szenarien",
stat_questions: "Fragen",
stat_free: "Kostenlos",
@@ -1750,6 +1772,77 @@ function revealSections() {
})
}
// Three-stage intro overlay played on the user's first home-page visit
// in this tab. Skipped on subsequent loads (sessionStorage flag), and
// also skipped when restoring a saved quiz/results stage so a refresh
// mid-flow doesn't replay the intro. Each stage shows for `enter +
// hold` ms, then fades out before the next enters.
const INTRO_KEY = 'kammergut.intro.shown.v1'
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function playIntro() {
const overlay = document.getElementById('intro-overlay')
if (!overlay) return
// Pacing — entry durations match the introSmoke keyframe (1.8s for
// stages 1 and 3). Stage 2 needs ~2.6s to land both staggered lines
// (line-a 0.1+1.5s, line-b 1.1+1.5s). Holds are tuned so the viewer
// can read each line after it lands.
// Stages 1 + 2 push in (1.6s); stage 3 word-cascades (~2.4s — 10
// words × 0.16s stagger + 0.75s per-word). Stage 3 holds, then the
// overlay fade handles its outro so we don't double-dissolve.
const stages = [
{ sel: '.stage-1', enter: 1600, hold: 800 },
{ sel: '.stage-2', enter: 1600, hold: 1000 },
{ sel: '.stage-3', enter: 2400, hold: 1500 },
]
const exit = 800 // matches the introOut (smoke out) duration
// Split stage 3 into per-word spans for the cascade. Run on each
// play so a language switch between sessions still gets correct words.
const stage3Text = overlay.querySelector('.stage-3 .intro-text')
if (stage3Text) {
const text = stage3Text.textContent.trim()
stage3Text.innerHTML = ''
text.split(/\s+/).forEach((word, i) => {
const span = document.createElement('span')
span.className = 'word'
span.style.animationDelay = (0.05 + i * 0.16) + 's'
span.textContent = word
stage3Text.appendChild(span)
})
}
for (let i = 0; i < stages.length; i++) {
const s = stages[i]
const el = overlay.querySelector(s.sel)
el.classList.add('active')
await sleep(s.enter + s.hold)
// Skip the smoke-out on the last stage — the overlay's own fade
// dissolves the final line, so we don't get two competing outros
// (one on the text, one on the FAFAFA panel).
if (i < stages.length - 1) {
el.classList.add('leaving')
el.classList.remove('active')
await sleep(exit)
el.classList.remove('leaving')
}
}
// Fade overlay out and let the hero animations run via body class.
// The last stage is still .active here, so its text rides the
// overlay fade-out as one motion.
overlay.classList.add('done')
document.body.classList.add('intro-done')
await sleep(900)
overlay.classList.add('gone')
}
function maybePlayIntro({ skip = false } = {}) {
if (skip || sessionStorage.getItem(INTRO_KEY)) {
document.body.classList.add('intro-done')
const ov = document.getElementById('intro-overlay')
if (ov) ov.classList.add('gone')
return
}
try { sessionStorage.setItem(INTRO_KEY, '1') } catch {}
playIntro()
}
// Scroll-lock the page behind the modal. Saving and restoring scrollY +
// position:fixed on body is the iOS-Safari-safe pattern; plain
// overflow:hidden alone doesn't stop touch-driven body scroll there.
@@ -2018,6 +2111,12 @@ onMounted(() => {
setLang(lang)
updateRegionIndicator()
// Intro overlay — only on the very first home-page visit in this tab.
// If we're resuming a saved quiz/results stage, or the intro has already
// played in this session, jump straight to the hero with no animation.
const resumingMidFlow = !!(saved && saved.stage && saved.stage !== 'home')
maybePlayIntro({ skip: resumingMidFlow })
if (saved && saved.stage && saved.stage !== 'home') {
answers = saved.answers || {}
currentQ = typeof saved.currentQ === 'number' ? saved.currentQ : 0

View File

@@ -1017,6 +1017,129 @@ input[type=range]::-moz-range-thumb {
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.hidden { display: none !important; }
/* ── INTRO OVERLAY ─────────────────────────────────────────────────
Three-stage cinematic typographic intro: a slow left-to-right sweep
on the first line, then two stages that drift in from a soft blur
while the letter-spacing tightens. Text is flat #0a0a0a on a FAFAFA
panel — no paint gloss filter. Body picks up `.intro-done` once all
stages have played, which is what lets the hero/header animations
start. */
.intro-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: #FAFAFA;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
transition: opacity 1.1s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.intro-overlay.done { opacity: 0; }
.intro-overlay.gone { display: none; }
.intro-stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 32px;
white-space: pre-line;
}
/* Intro text — uppercase Barlow at heading scale with wide tracking,
matching the "Preparedness. Refined." sub-line typeface. No paint
filter / no gradient text-fill (those tanked render performance during
the animation, hence the wobble). Solid #0a0a0a on the FAFAFA panel. */
.intro-stage .intro-text {
display: inline-block;
font-family: var(--font-body);
font-weight: 400;
font-size: clamp(22px, 4.6vw, 42px);
line-height: 1.3;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #0a0a0a;
/* Push-in default state — held at slightly back-of-frame, soft blur,
so the .active class can pull the text forward. */
opacity: 0;
filter: blur(3px);
transform: scale(0.92);
}
/* Smoke reveal — heavy blur clears as the text emerges, with a small
y-drift to suggest weight settling. No clip-path wipe and no letter
spacing animation (the resizing effect read as twitchy). Used by all
three stages so the unveiling is one consistent vocabulary. Stage 3
runs at half-speed (3.6s vs 1.8s) so the closing line lands with
weight instead of rushing past. */
/* Stages 1 + 2 — push-in entry (scale 0.92 → 1.00, blur 3px → 0). */
.intro-stage.stage-1.active .intro-text,
.intro-stage.stage-2.active .intro-text {
animation: introSmoke 1.6s 0.05s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
/* Stage 3 — word cascade. Wrapper is visible; per-word spans animate
in with a stagger (delays set inline by JS). The overlay's own fade
handles the outro so we don't double-dissolve the closing line. */
.intro-stage.stage-3 .intro-text {
opacity: 1;
filter: none;
transform: none;
letter-spacing: 0.18em;
}
.intro-stage.stage-3 .intro-text > .word {
display: inline-block;
margin-right: 0.28em;
opacity: 0;
}
.intro-stage.stage-3.active .intro-text > .word {
animation: introWord 0.75s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
@keyframes introWord {
0% { opacity: 0; transform: translateY(14px); filter: blur(8px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}
/* Push-in — camera dollies forward: scale 0.92 → 1.00 with a soft 3px
blur resolving to crisp, fade-up paired. Cinematic without wobble. */
@keyframes introSmoke {
0% { opacity: 0; filter: blur(3px); transform: scale(0.92); }
100% { opacity: 1; filter: blur(0); transform: scale(1); }
}
/* Exit — smoke out: re-blur to 16px, drift upward 14px, fade to 0.
Same effect for every stage so each sentence dissolves into haze
before the next one resolves. */
.intro-stage.leaving .intro-text {
animation: introOut 0.8s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes introOut {
0% { opacity: 1; filter: blur(0); transform: translateY(0); }
100% { opacity: 0; filter: blur(16px); transform: translateY(-14px); }
}
/* Hero — hidden until intro is done so the bg image, brand wordmark,
tagline and Begin button appear AFTER the intro plays out rather
than during. The original animations on these elements stay defined
below; we just override animation-name to none until intro-done is
on the body, at which point each animation plays from its origin. */
body:not(.intro-done) .hero-eyebrow,
body:not(.intro-done) .hero h1,
body:not(.intro-done) .hero .brand-line,
body:not(.intro-done) .hero-sub,
body:not(.intro-done) .hero .cta-btn {
animation-name: none !important;
opacity: 0;
}
/* Header + lang toggle ride along — hidden until intro is done. The
intro-overlay already covers the hero bg during the stages, so the
bg image's fade-in is the natural consequence of the overlay
fading out. */
body:not(.intro-done) .site-header { opacity: 0; }
body.intro-done .site-header {
opacity: 1;
transition: opacity 0.6s ease 0.2s;
}
/* ── SINGLE FOLD: hide non-essential sections on load ──
(header stays visible with a transparent background on the homepage) */
.site-header { background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; border-bottom: none; }