Bday
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#171335">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>%F0%9F%8E%82</text></svg>">
<title>A Birthday, Just for You 🎂</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=Fraunces:ital,wght@0,400;0,600;0,700;1,500&family=Quicksand:wght@500;600;700&family=Caveat:wght@500;600&display=swap" rel="stylesheet">
<style>
:root{
--ink:#171335;
--ink-deep:#0c0a1f;
--ink-light:#2a2359;
--gold:#f5b942;
--gold-soft:#ffe1a8;
--blush:#ff6f91;
--blush-deep:#e14f74;
--sage:#8fd0b4;
--cream:#fbf1e1;
--font-display:'Fraunces', serif;
--font-body:'Quicksand', sans-serif;
--font-script:'Caveat', cursive;
}
*{ box-sizing:border-box; }
html,body{ margin:0; padding:0; }
body{
font-family:var(--font-body);
background: radial-gradient(ellipse at top, var(--ink-light) 0%, var(--ink) 45%, var(--ink-deep) 100%);
color:var(--cream);
min-height:100vh; min-height:100svh;
overflow-x:hidden;
}
button{ font-family:inherit; }
button:focus-visible{ outline:2px solid var(--gold); outline-offset:3px; border-radius:8px; }
p{ margin:0; }
noscript div{
position:fixed; inset:0; z-index:9999; display:flex; align-items:center; justify-content:center;
background:var(--ink); color:var(--cream); font-family:var(--font-body); text-align:center; padding:40px;
}
/* ---------- ambient background ---------- */
.bg-lights{ position:fixed; inset:0; overflow:hidden; z-index:0; pointer-events:none; }
.orb{ position:absolute; border-radius:50%; opacity:.5; animation: drift 16s ease-in-out infinite; }
.orb--1{ width:200px;height:200px; left:6%; top:10%; background:radial-gradient(circle, var(--gold) 0%, transparent 70%); animation-duration:17s; }
.orb--2{ width:150px;height:150px; right:8%; top:18%; background:radial-gradient(circle, var(--blush) 0%, transparent 70%); animation-duration:20s; animation-delay:-4s;}
.orb--3{ width:240px;height:240px; left:16%; bottom:8%; background:radial-gradient(circle, var(--sage) 0%, transparent 70%); animation-duration:23s; animation-delay:-9s;}
.orb--4{ width:130px;height:130px; right:14%; bottom:14%; background:radial-gradient(circle, var(--gold-soft) 0%, transparent 70%); animation-duration:18s; animation-delay:-2s;}
.orb--5{ width:100px;height:100px; left:46%; top:4%; background:radial-gradient(circle, var(--blush-deep) 0%, transparent 70%); animation-duration:14s; animation-delay:-6s;}
.orb--6{ width:170px;height:170px; right:36%; bottom:2%; background:radial-gradient(circle, var(--gold) 0%, transparent 70%); animation-duration:21s; animation-delay:-12s;}
@keyframes drift{
0%,100%{ transform:translate(0,0) scale(1); }
50%{ transform:translate(24px,-34px) scale(1.12); }
}
/* ---------- fixed chrome ---------- */
.mute-btn{
position:fixed; top:16px; right:16px; z-index:50;
width:42px;height:42px;border-radius:50%;
background:rgba(23,19,53,.55); border:1px solid rgba(251,241,225,.2);
backdrop-filter:blur(6px); color:var(--cream);
display:flex; align-items:center; justify-content:center; cursor:pointer;
}
.progress{
position:fixed; top:22px; left:50%; transform:translateX(-50%);
display:flex; gap:8px; z-index:50; opacity:0; transition:opacity .4s ease;
}
.dot{ width:7px;height:7px;border-radius:50%; background:rgba(251,241,225,.25); transition:all .3s ease; }
.dot.is-active{ background:var(--gold); transform:scale(1.4); }
.dot.is-done{ background:var(--blush); }
/* ---------- scene shell ---------- */
#app{ position:relative; z-index:1; min-height:100vh; min-height:100svh; overflow:hidden; }
.scene{
position:absolute; inset:0;
display:flex; flex-direction:column; align-items:center; justify-content:center;
text-align:center; padding:100px 22px 60px;
opacity:0; pointer-events:none; transition:opacity .55s ease;
}
.scene.active{ opacity:1; pointer-events:auto; }
.eyebrow{
font-family:var(--font-body); font-weight:700; text-transform:uppercase;
letter-spacing:.22em; font-size:.72rem; color:var(--gold-soft); margin:0 0 14px;
}
.btn{
font-family:var(--font-body); font-weight:700; font-size:1rem; letter-spacing:.02em;
padding:.9em 1.7em; border-radius:999px; border:none; cursor:pointer;
display:inline-flex; align-items:center; gap:.5em;
transition:transform .2s ease, box-shadow .2s ease, background .2s ease;
}
.btn--primary{
background:linear-gradient(135deg, var(--gold-soft), var(--gold)); color:var(--ink-deep);
box-shadow:0 8px 24px rgba(245,185,66,.35);
}
.btn--primary:hover{ transform:translateY(-2px); box-shadow:0 12px 30px rgba(245,185,66,.45); }
.btn--ghost{
background:rgba(251,241,225,.08); color:var(--cream); border:1.5px solid rgba(251,241,225,.35);
}
.btn--ghost:hover{ background:rgba(251,241,225,.16); }
/* ---------- cover ---------- */
.cover-card{ max-width:520px; }
.cover-title{
font-family:var(--font-display); font-weight:600; font-size:clamp(2.1rem,7vw,3.4rem);
line-height:1.12; color:var(--cream); margin:0 0 18px;
}
.cover-title span{ color:var(--gold); font-style:italic; }
.cover-sub{ font-size:1.05rem; color:rgba(251,241,225,.75); line-height:1.65; margin-bottom:36px; }
/* ---------- hero ---------- */
.hero-title{
font-family:var(--font-display); font-weight:600; font-size:clamp(2.1rem,7vw,3.4rem);
color:var(--cream); margin:0 0 18px;
}
.hero-title span{ color:var(--gold); font-style:italic; }
.hero-sub{ font-size:1.05rem; color:rgba(251,241,225,.75); line-height:1.65; max-width:460px; margin:0 auto 36px; }
/* ---------- shared scene head ---------- */
.scene-head{ margin-bottom:30px; max-width:480px; }
.scene-title{ font-family:var(--font-display); font-weight:600; font-size:clamp(1.6rem,5.5vw,2.3rem); color:var(--cream); margin:0 0 8px; }
.scene-sub{ font-size:.98rem; color:rgba(251,241,225,.7); margin:0; }
/* ---------- balloons ---------- */
.balloon-field{ display:flex; flex-wrap:wrap; justify-content:center; gap:16px 20px; max-width:520px; margin-bottom:22px; }
.balloon{ background:none; border:none; padding:0; cursor:pointer; display:flex; flex-direction:column; align-items:center; }
.balloon:not(.popped){ animation:floaty var(--sway-dur,4s) ease-in-out infinite; animation-delay:var(--float-delay,0s); }
.balloon-svg{ width:58px; height:auto; filter:drop-shadow(0 8px 14px rgba(12,10,31,.45)); }
.balloon-string{ width:2px; height:42px; background:rgba(251,241,225,.3); margin-top:-2px; }
.balloon.popped{ animation:pop .35s ease-out forwards; pointer-events:none; }
@keyframes floaty{
0%,100%{ transform:translateY(0) rotate(-3deg); }
50%{ transform:translateY(-14px) rotate(3deg); }
}
@keyframes pop{
0%{ transform:scale(1); opacity:1; }
55%{ transform:scale(1.25); opacity:1; }
100%{ transform:scale(0); opacity:0; }
}
.counter{ font-weight:700; color:var(--gold-soft); letter-spacing:.05em; }
/* ---------- candles / cake ---------- */
.cake-wrap{ display:flex; flex-direction:column; align-items:center; margin:10px 0 20px; }
.candles{ display:flex; gap:14px; margin-bottom:-6px; position:relative; z-index:2; }
.candle{ position:relative; width:12px; background:none; border:none; cursor:pointer; padding:26px 2px 0; }
.candle-stick{ display:block; width:8px; height:34px; margin:0 auto; border-radius:2px;
background:repeating-linear-gradient(180deg, var(--cream) 0 6px, var(--blush) 6px 12px); }
.wick{ position:absolute; top:20px; left:50%; transform:translateX(-50%); width:2px;height:6px; background:#4a3a1a; }
.flame{
position:absolute; top:-1px; left:50%; transform:translateX(-50%); width:13px;height:19px;
background:radial-gradient(circle at 50% 65%, #fff6d8 0%, var(--gold) 55%, #e8791a 100%);
border-radius:50% 50% 50% 50%/60% 60% 40% 40%;
filter:drop-shadow(0 0 6px rgba(245,185,66,.85));
animation:flicker 1.5s ease-in-out infinite; animation-delay:var(--flicker-delay,0s);
transform-origin:bottom center;
}
@keyframes flicker{
0%,100%{ transform:translateX(-50%) scaleY(1) scaleX(1) rotate(0deg); }
25%{ transform:translateX(-50%) scaleY(1.08) scaleX(.95) rotate(-3deg); }
50%{ transform:translateX(-50%) scaleY(.95) scaleX(1.05) rotate(2deg); }
75%{ transform:translateX(-50%) scaleY(1.05) scaleX(.97) rotate(-2deg); }
}
.candle.out .flame{ opacity:0; transform:translateX(-50%) translateY(4px) scale(.3); transition:opacity .25s ease, transform .25s ease; animation:none; }
.cake-body{ display:flex; flex-direction:column; align-items:center; }
.cake-tier--top{ width:150px;height:54px; border-radius:14px 14px 6px 6px; background:linear-gradient(180deg,var(--gold-soft),var(--gold)); box-shadow:inset 0 -8px 0 rgba(255,255,255,.15); }
.cake-tier--bottom{ width:230px;height:74px; margin-top:-8px; border-radius:16px 16px 8px 8px; background:linear-gradient(180deg,#ff9fb6,var(--blush-deep)); box-shadow:inset 0 -10px 0 rgba(255,255,255,.12); }
.cake-plate{ width:270px;height:14px;border-radius:50%; background:var(--cream); box-shadow:0 6px 18px rgba(12,10,31,.5); margin-top:2px; }
.wish-msg{ margin-top:18px; font-family:var(--font-display); font-style:italic; font-size:1.15rem; color:var(--gold-soft); opacity:0; transform:translateY(8px); transition:opacity .5s ease, transform .5s ease; }
.wish-msg.show{ opacity:1; transform:translateY(0); }
/* ---------- cut the cake ---------- */
.cake-round{ position:relative; width:180px;height:180px; border:none; padding:0; cursor:pointer; background:none; margin-bottom:26px; }
.cake-half{
position:absolute; inset:0; border-radius:50%;
background:radial-gradient(circle at 35% 30%, #ffdca0, var(--gold) 45%, var(--blush-deep) 100%);
box-shadow:0 10px 30px rgba(12,10,31,.5);
transition:transform .5s cubic-bezier(.34,1.56,.64,1);
}
.cake-half--left{ clip-path:inset(0 50% 0 0); }
.cake-half--right{ clip-path:inset(0 0 0 50%); }
.cake-round.cut .cake-half--left{ transform:translateX(-46px) rotate(-10deg); }
.cake-round.cut .cake-half--right{ transform:translateX(46px) rotate(10deg); }
.knife{ position:absolute; top:-14px; right:-8px; font-size:2.1rem; transform:rotate(20deg); transition:transform .4s ease; }
.cake-round.cut .knife{ transform:rotate(75deg) translateY(14px); }
.fortune-note{
max-width:360px; background:rgba(251,241,225,.06); border:1px solid rgba(251,241,225,.18);
border-radius:20px; padding:26px 24px; opacity:0; transform:translateY(14px) scale(.96);
transition:opacity .5s ease, transform .5s ease; pointer-events:none;
}
.fortune-note.show{ opacity:1; transform:translateY(0) scale(1); pointer-events:auto; }
.fortune-label{ text-transform:uppercase; letter-spacing:.18em; font-size:.7rem; color:var(--gold-soft); margin:0 0 10px; font-weight:700; }
.fortune-text{ font-family:var(--font-display); font-style:italic; font-size:1.25rem; color:var(--cream); margin:0 0 20px; }
/* ---------- letter ---------- */
.letter-card{
position:relative; background:linear-gradient(160deg, var(--cream), #f4e3c8); color:#3a2c14;
border-radius:18px; padding:42px 36px; max-width:440px; text-align:left;
box-shadow:0 24px 60px rgba(12,10,31,.55); margin-bottom:28px;
}
.letter-card::before{
content:''; position:absolute; inset:0; border-radius:18px;
box-shadow:0 0 60px 20px rgba(245,185,66,.25); z-index:-1;
}
.letter-line, .letter-sign{
font-family:var(--font-script); font-size:1.55rem; line-height:1.5; margin:0 0 14px;
opacity:0; transform:translateY(14px); animation:lineIn .6s ease forwards;
animation-delay:calc(var(--i,0) * .18s);
}
.letter-greeting{ font-size:1.85rem; margin-bottom:18px; }
.letter-sign{ margin-top:14px; margin-bottom:0; }
@keyframes lineIn{ to{ opacity:1; transform:translateY(0); } }
/* ---------- finale ---------- */
.marquee{ font-family:var(--font-display); font-weight:700; font-size:clamp(2rem,8vw,3.6rem); color:var(--cream); margin:0 0 14px; }
.marquee-name{ display:block; color:var(--gold); font-style:italic; animation:glow 2.2s ease-in-out infinite; }
@keyframes glow{
0%,100%{ text-shadow:0 0 18px rgba(245,185,66,.5); }
50%{ text-shadow:0 0 34px rgba(245,185,66,.85); }
}
.finale-sub{ color:rgba(251,241,225,.75); margin-bottom:32px; font-size:1.05rem; max-width:420px; }
/* ---------- particles ---------- */
.confetti-piece{ position:fixed; width:8px;height:8px; border-radius:2px; pointer-events:none; z-index:500; animation:confettiFly .9s ease-out forwards; }
@keyframes confettiFly{ to{ transform:translate(var(--dx),var(--dy)) rotate(var(--rot)); opacity:0; } }
.confetti-fall{ position:fixed; top:-20px; width:8px;height:8px; border-radius:2px; pointer-events:none; z-index:500; animation:confettiFall 2.8s linear forwards; }
@keyframes confettiFall{ to{ transform:translate(var(--drift,0px),110vh) rotate(340deg); opacity:.15; } }
.smoke-puff{ position:fixed; width:14px;height:14px; border-radius:50%; background:rgba(255,255,255,.55); pointer-events:none; z-index:400; animation:smokeRise 1s ease-out forwards; }
@keyframes smokeRise{ to{ transform:translateY(-40px) scale(2); opacity:0; } }
/* ---------- responsive ---------- */
@media (max-width:480px){
.scene{ padding:88px 16px 50px; }
.balloon-svg{ width:48px; }
.cake-tier--bottom{ width:196px; }
.cake-tier--top{ width:128px; }
.cake-round{ width:150px;height:150px; }
.letter-card{ padding:30px 22px; }
.letter-line, .letter-sign{ font-size:1.35rem; }
.letter-greeting{ font-size:1.6rem; }
}
@media (prefers-reduced-motion: reduce){
*, *::before, *::after{
animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important;
}
}
</style>
</head>
<body>
<noscript><div>This little celebration needs JavaScript turned on to unwrap properly 💌</div></noscript>
<div class="bg-lights" aria-hidden="true">
<span class="orb orb--1"></span>
<span class="orb orb--2"></span>
<span class="orb orb--3"></span>
<span class="orb orb--4"></span>
<span class="orb orb--5"></span>
<span class="orb orb--6"></span>
</div>
<button class="mute-btn" id="muteBtn" aria-label="Mute sound" aria-pressed="false"></button>
<div class="progress" id="progress" aria-hidden="true">
<span class="dot" data-dot="2"></span>
<span class="dot" data-dot="3"></span>
<span class="dot" data-dot="4"></span>
<span class="dot" data-dot="5"></span>
</div>
<main id="app">
<section class="scene scene--cover active" data-scene="0">
<div class="cover-card">
<p class="eyebrow">admit one</p>
<h1 class="cover-title">A Birthday, Just for <span id="coverName">Maya</span></h1>
<p class="cover-sub" id="coverSub">No crowds. No small talk. Just you, six candles, and everyone who loves you.</p>
<button class="btn btn--primary" id="openBtn"><span aria-hidden="true">🎁</span> Open your gift</button>
</div>
</section>
<section class="scene scene--hero" data-scene="1">
<div>
<h2 class="hero-title">Happy Birthday, <span id="heroName">Maya</span>!</h2>
<p class="hero-sub" id="heroSub">I built you a tiny party — just balloons, candlelight, and a few things I've been wanting to say.</p>
<button class="btn btn--primary" data-next>Let's go ↓</button>
</div>
</section>
<section class="scene scene--balloons" data-scene="2">
<div class="scene-head">
<p class="eyebrow">step one</p>
<h2 class="scene-title">Pop every balloon</h2>
<p class="scene-sub">each one's got something good inside</p>
</div>
<div class="balloon-field" id="balloonField"></div>
<p class="counter" id="balloonCounter">0 of 6 popped</p>
</section>
<section class="scene scene--candles" data-scene="3">
<div class="scene-head">
<p class="eyebrow">step two</p>
<h2 class="scene-title">Make a wish</h2>
<p class="scene-sub">blow out every candle — tap each one</p>
</div>
<div class="cake-wrap">
<div class="candles" id="candleField"></div>
<div class="cake-body">
<div class="cake-tier cake-tier--top"></div>
<div class="cake-tier cake-tier--bottom"></div>
</div>
<div class="cake-plate"></div>
</div>
<p class="wish-msg" id="wishMsg">🌙 wish made. it's already coming true.</p>
</section>
<section class="scene scene--cake" data-scene="4">
<div class="scene-head">
<p class="eyebrow">step three</p>
<h2 class="scene-title">Cut the first slice</h2>
<p class="scene-sub">go on, it's tradition</p>
</div>
<button class="cake-round" id="cakeCutBtn" aria-label="Cut the cake">
<span class="cake-half cake-half--left"></span>
<span class="cake-half cake-half--right"></span>
<span class="knife" aria-hidden="true">🔪</span>
</button>
<div class="fortune-note" id="fortuneNote">
<p class="fortune-label">your fortune</p>
<p class="fortune-text" id="fortuneText">This is going to be your best year yet.</p>
<button class="btn btn--ghost" data-next>Continue →</button>
</div>
</section>
<section class="scene scene--letter" data-scene="5">
<p class="eyebrow">one more thing</p>
<div class="letter-card" id="letterCard"></div>
<button class="btn btn--primary" data-next>Keep going →</button>
</section>
<section class="scene scene--finale" data-scene="6">
<h2 class="marquee">Happy Birthday<span class="marquee-name" id="finaleName">Maya</span>🎉</h2>
<p class="finale-sub" id="finaleSub">This gift doesn't expire. Neither does how much you're loved.</p>
<button class="btn btn--primary" id="replayBtn">Replay ↻</button>
</section>
</main>
<script>
/* ============ PERSONALIZE ME ============ */
const CONFIG = {
name: "Maya",
fromName: "Me",
ticketLine: "No crowds. No small talk. Just you, six candles, and everyone who loves you.",
heroLine: "I built you a tiny party — just balloons, candlelight, and a few things I've been wanting to say.",
fortune: "This is going to be your best year yet.",
finaleLine: "This gift doesn't expire. Neither does how much you're loved.",
balloonCount: 6,
candleCount: 6,
letter: [
"Maya,",
"I don't say this enough, so I'm saying it here, where you can't interrupt me: I'm really glad you exist.",
"This year, I hope you laugh more than you plan for, rest more than you feel guilty about, and get exactly the birthday you deserve — today, and every day after.",
"Here's to another year of you being exactly, wonderfully yourself."
]
};
/* ========================================= */
const BALLOON_COLORS = ['#F5B942','#FF6F91','#8FD0B4','#FFE1A8','#E14F74','#FBF1E1'];
const CONFETTI_COLORS = BALLOON_COLORS;
const ICON_SOUND = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>';
const ICON_MUTED = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>';
const scenes = Array.from(document.querySelectorAll('.scene'));
const progress = document.getElementById('progress');
const dots = Array.from(document.querySelectorAll('.dot'));
const muteBtn = document.getElementById('muteBtn');
const coverName = document.getElementById('coverName');
const coverSub = document.getElementById('coverSub');
const openBtn = document.getElementById('openBtn');
const heroName = document.getElementById('heroName');
const heroSub = document.getElementById('heroSub');
const balloonField = document.getElementById('balloonField');
const balloonCounter = document.getElementById('balloonCounter');
const candleField = document.getElementById('candleField');
const wishMsg = document.getElementById('wishMsg');
const cakeCutBtn = document.getElementById('cakeCutBtn');
const fortuneNote = document.getElementById('fortuneNote');
const fortuneText = document.getElementById('fortuneText');
const letterCard = document.getElementById('letterCard');
const finaleName = document.getElementById('finaleName');
const finaleSub = document.getElementById('finaleSub');
const replayBtn = document.getElementById('replayBtn');
let currentScene = 0;
let poppedCount = 0;
let blownCount = 0;
let cakeCutDone = false;
let muted = false;
let audioCtx = null;
let finaleInterval = null;
function escapeHTML(str){
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* ---------- audio ---------- */
function ensureAudio(){
if(!audioCtx){
try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }
catch(e){ audioCtx = null; }
}
if(audioCtx && audioCtx.state === 'suspended'){ audioCtx.resume(); }
}
function tone(freq, start, dur, type, peak){
if(!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime + start);
gain.gain.setValueAtTime(0, audioCtx.currentTime + start);
gain.gain.linearRampToValueAtTime(peak || 0.15, audioCtx.currentTime + start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + start + dur);
osc.connect(gain).connect(audioCtx.destination);
osc.start(audioCtx.currentTime + start);
osc.stop(audioCtx.currentTime + start + dur + 0.05);
}
function playTone(name){
if(muted) return;
ensureAudio();
if(!audioCtx) return;
if(name==='pop'){ tone(880,0,.12,'triangle',0.16); tone(1320,.04,.1,'triangle',0.1); }
else if(name==='blow'){ tone(300,0,.25,'sine',0.14); tone(180,.05,.3,'sine',0.1); }
else if(name==='cut'){ tone(500,0,.08,'square',0.07); tone(700,.06,.08,'square',0.05); }
else if(name==='chime'){ tone(523.25,0,.5,'sine',0.15); tone(659.25,.12,.5,'sine',0.13); tone(783.99,.24,.6,'sine',0.13); }
else if(name==='open'){ tone(440,0,.15,'sine',0.12); tone(660,.08,.2,'sine',0.1); }
}
/* ---------- particles ---------- */
function burstConfetti(x, y, count){
for(let i=0;i<(count||10);i++){
const p = document.createElement('span');
p.className = 'confetti-piece';
const angle = Math.random()*Math.PI*2;
const dist = 36 + Math.random()*70;
const dx = Math.cos(angle)*dist;
const dy = Math.sin(angle)*dist - 26;
p.style.setProperty('--dx', dx+'px');
p.style.setProperty('--dy', dy+'px');
p.style.setProperty('--rot', (Math.random()*360)+'deg');
p.style.left = x+'px';
p.style.top = y+'px';
p.style.background = CONFETTI_COLORS[Math.floor(Math.random()*CONFETTI_COLORS.length)];
document.body.appendChild(p);
p.addEventListener('animationend', function(){ p.remove(); });
}
}
function spawnFallingConfetti(x){
const p = document.createElement('span');
p.className = 'confetti-fall';
p.style.left = x+'px';
p.style.background = CONFETTI_COLORS[Math.floor(Math.random()*CONFETTI_COLORS.length)];
p.style.setProperty('--drift', (Math.random()*60-30)+'px');
document.body.appendChild(p);
p.addEventListener('animationend', function(){ p.remove(); });
}
function spawnSmoke(x, y){
const s = document.createElement('span');
s.className = 'smoke-puff';
s.style.left = (x-7)+'px';
s.style.top = y+'px';
document.body.appendChild(s);
s.addEventListener('animationend', function(){ s.remove(); });
}
/* ---------- build interactive scenes ---------- */
function buildBalloons(){
balloonField.innerHTML = '';
poppedCount = 0;
for(let i=0;i<CONFIG.balloonCount;i++){
const color = BALLOON_COLORS[i % BALLOON_COLORS.length];
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'balloon';
btn.setAttribute('aria-label', 'Pop balloon '+(i+1));
btn.style.setProperty('--float-delay', (i*0.35)+'s');
btn.style.setProperty('--sway-dur', (3.6 + (i%3)*0.5)+'s');
btn.innerHTML =
'<svg class="balloon-svg" viewBox="0 0 60 68" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'+
'<ellipse cx="30" cy="28" rx="26" ry="30" fill="'+color+'"/>'+
'<ellipse cx="20" cy="15" rx="6" ry="9" fill="rgba(255,255,255,.28)"/>'+
'<path d="M25 56 L35 56 L30 65 Z" fill="'+color+'"/>'+
'</svg>'+
'<span class="balloon-string" aria-hidden="true"></span>';
btn.addEventListener('click', function(){ popBalloon(btn); }, { once:true });
balloonField.appendChild(btn);
}
balloonCounter.textContent = '0 of '+CONFIG.balloonCount+' popped';
}
function popBalloon(btn){
if(btn.classList.contains('popped')) return;
btn.classList.add('popped');
const rect = btn.getBoundingClientRect();
burstConfetti(rect.left+rect.width/2, rect.top+rect.height/2, 10);
playTone('pop');
poppedCount++;
balloonCounter.textContent = poppedCount+' of '+CONFIG.balloonCount+' popped';
if(poppedCount >= CONFIG.balloonCount){
setTimeout(function(){ goToScene(3); }, 900);
}
}
function buildCandles(){
candleField.innerHTML = '';
blownCount = 0;
wishMsg.classList.remove('show');
for(let i=0;i<CONFIG.candleCount;i++){
const c = document.createElement('button');
c.type = 'button';
c.className = 'candle';
c.setAttribute('aria-label', 'Blow out candle '+(i+1));
c.style.setProperty('--flicker-delay', (i*0.22)+'s');
c.innerHTML = '<span class="flame"></span><span class="wick"></span><span class="candle-stick"></span>';
c.addEventListener('click', function(){ blowCandle(c); }, { once:true });
candleField.appendChild(c);
}
}
function blowCandle(c){
if(c.classList.contains('out')) return;
c.classList.add('out');
const rect = c.getBoundingClientRect();
spawnSmoke(rect.left+rect.width/2, rect.top);
playTone('blow');
blownCount++;
if(blownCount >= CONFIG.candleCount){
setTimeout(function(){
wishMsg.classList.add('show');
playTone('chime');
}, 300);
setTimeout(function(){ goToScene(4); }, 2200);
}
}
function buildLetter(){
let html = '';
CONFIG.letter.forEach(function(line, i){
const cls = i===0 ? 'letter-line letter-greeting' : 'letter-line';
html += '<p class="'+cls+'" style="--i:'+i+'">'+escapeHTML(line)+'</p>';
});
html += '<p class="letter-sign" style="--i:'+CONFIG.letter.length+'">With love,<br>'+escapeHTML(CONFIG.fromName)+'</p>';
letterCard.innerHTML = html;
}
function restartLetterAnim(){
const els = letterCard.querySelectorAll('.letter-line, .letter-sign');
els.forEach(function(el){
el.style.animation = 'none';
void el.offsetWidth;
el.style.animation = '';
});
}
cakeCutBtn.addEventListener('click', function(){
if(cakeCutDone) return;
cakeCutDone = true;
cakeCutBtn.classList.add('cut');
playTone('cut');
setTimeout(function(){ fortuneNote.classList.add('show'); }, 350);
});
/* ---------- finale confetti ---------- */
function startFinaleConfetti(){
stopFinaleConfetti();
finaleInterval = setInterval(function(){
spawnFallingConfetti(Math.random()*window.innerWidth);
}, 220);
}
function stopFinaleConfetti(){
if(finaleInterval){ clearInterval(finaleInterval); finaleInterval = null; }
}
/* ---------- navigation ---------- */
function updateProgress(n){
progress.style.opacity = (n>=2 && n<=5) ? '1' : '0';
dots.forEach(function(d){
const val = parseInt(d.dataset.dot, 10);
d.classList.toggle('is-active', val===n);
d.classList.toggle('is-done', val<n);
});
}
function goToScene(n){
scenes.forEach(function(s){ s.classList.remove('active'); });
scenes[n].classList.add('active');
currentScene = n;
updateProgress(n);
if(n===5){ restartLetterAnim(); }
if(n===6){ startFinaleConfetti(); } else { stopFinaleConfetti(); }
window.scrollTo(0,0);
}
function applyConfig(){
document.title = 'A Birthday, Just for '+CONFIG.name+' 🎂';
coverName.textContent = CONFIG.name;
coverSub.textContent = CONFIG.ticketLine;
heroName.textContent = CONFIG.name;
heroSub.textContent = CONFIG.heroLine;
fortuneText.textContent = CONFIG.fortune;
finaleName.textContent = CONFIG.name;
finaleSub.textContent = CONFIG.finaleLine;
}
function resetExperience(){
buildBalloons();
buildCandles();
cakeCutDone = false;
cakeCutBtn.classList.remove('cut');
fortuneNote.classList.remove('show');
goToScene(0);
}
muteBtn.addEventListener('click', function(){
muted = !muted;
muteBtn.setAttribute('aria-pressed', String(muted));
muteBtn.innerHTML = muted ? ICON_MUTED : ICON_SOUND;
});
openBtn.addEventListener('click', function(){
playTone('open');
goToScene(1);
});
document.querySelectorAll('[data-next]').forEach(function(btn){
btn.addEventListener('click', function(){ goToScene(currentScene+1); });
});
replayBtn.addEventListener('click', resetExperience);
/* ---------- init ---------- */
muteBtn.innerHTML = ICON_SOUND;
applyConfig();
buildLetter();
buildBalloons();
buildCandles();
goToScene(0);
</script>
</body>
</html>