forked from SahilGogna/v-day
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
626 lines (544 loc) · 22.4 KB
/
Copy pathscript.js
File metadata and controls
626 lines (544 loc) · 22.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
// =========================================================
// Move 3 — PERSONALIZE HERE
// 1. Set RECIPIENT_NAME to show a "Dear <name>," greeting (leave "" to hide it).
// 2. Edit LETTER_LINES — each string is one line, typed out before the question.
// 3. Drop seal-break.mp3 + chime.mp3 into a /sounds folder (see sounds/README.txt).
// =========================================================
const RECIPIENT_NAME = "Trouble"; // placeholder pet name; swap for a real name later
const LETTER_LINES = [
"i know we've been annoying each other a lot lately.",
"every time i'm around you, i just want to pick a fight just to talk to you.",
"so here it goes…"
];
window.RECIPIENT_NAME = RECIPIENT_NAME; // read by analytics.js for the report card
// ---- Sound effects: short one-shots that live in /sounds. Missing files fail
// silently (play() rejects), so the site works fine until you add them. --------
function makeSfx(src, vol) {
const a = new Audio(src);
a.volume = vol;
a.preload = 'auto';
return a;
}
const sealSfx = makeSfx('sounds/seal-break.wav', 0.5);
const chimeSfx = makeSfx('sounds/chime.wav', 0.45);
function playSfx(a) {
if (!a) return;
try { a.currentTime = 0; a.play().catch(() => {}); } catch (e) {}
}
// ---- Haptics: a subtle buzz on mobile. No-op where unsupported. --------------
function buzz(pattern) {
if (navigator.vibrate) { try { navigator.vibrate(pattern); } catch (e) {} }
}
// ---- Tactile press feedback: a quick scale/glow on every button press. This
// is the visual stand-in for haptics on iOS (no web vibration) and for sound
// when the phone is on silent. Pointer events cover mouse + touch + pen. -------
function wireTactile() {
const press = e => e.currentTarget.classList.add('is-pressed');
const release = e => e.currentTarget.classList.remove('is-pressed');
document.querySelectorAll('#yes-btn, #no-btn, .food-btn, #music-toggle').forEach(b => {
b.classList.add('tactile');
b.addEventListener('pointerdown', press);
b.addEventListener('pointerup', release);
b.addEventListener('pointerleave', release);
b.addEventListener('pointercancel', release);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireTactile);
} else {
wireTactile();
}
// ---- The letter: type the lines out, then reveal the question + controls. ----
let letterStarted = false, letterFinished = false, letterSafety = 0;
function finishLetter() {
if (letterFinished) return;
letterFinished = true;
if (letterSafety) clearTimeout(letterSafety);
const bodyEl = document.getElementById('letter-body');
const container = document.getElementById('main-container');
if (bodyEl) {
bodyEl.textContent = LETTER_LINES.join('\n');
bodyEl.classList.remove('typing');
}
if (container) container.classList.add('letter-revealed');
}
function startLetterSequence() {
if (letterStarted) return;
letterStarted = true;
const greetEl = document.getElementById('letter-greeting');
const bodyEl = document.getElementById('letter-body');
const container = document.getElementById('main-container');
if (!bodyEl || !container) return;
if (greetEl && RECIPIENT_NAME) greetEl.textContent = `Dear ${RECIPIENT_NAME},`;
const fullText = LETTER_LINES.join('\n');
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Reduced motion (or no letter): show everything at once, controls stay visible.
if (reduce || !fullText) {
if (greetEl) greetEl.classList.add('show');
bodyEl.textContent = fullText;
return;
}
container.classList.add('letter-active'); // hold the question hidden
requestAnimationFrame(() => { if (greetEl) greetEl.classList.add('show'); });
bodyEl.classList.add('typing');
let i = 0;
const typeNext = () => {
if (letterFinished) return; // safety net already revealed everything
bodyEl.textContent = fullText.slice(0, i);
if (i >= fullText.length) { finishLetter(); return; }
const ch = fullText[i];
i++;
// Natural cadence: pause at line breaks and sentence punctuation.
let delay = 34;
if (ch === '\n') delay = 340;
else if (ch === '.' || ch === '…') delay = 200;
else if (ch === ',') delay = 140;
setTimeout(typeNext, delay);
};
setTimeout(typeNext, 600); // let the card settle before writing begins
// Safety: guarantee the controls appear even if something stalls the typing.
letterSafety = setTimeout(finishLetter, 600 + fullText.length * 90 + 2500);
}
// Smoothly resume state if loaded from Safari/Mobile Back-Forward Cache
window.addEventListener('pageshow', function (event) {
if (event.persisted) {
// Instead of reloading, just attempt to resume playing the music if it was playing
if (musicPlaying) {
music.play().catch(() => { });
}
}
});
// Handle back button smoothly
window.addEventListener('popstate', (event) => {
const hash = window.location.hash;
if (hash === '#yes') {
// She pressed back from the final selection screen to the options screen.
// Both are the SAME yes-container (only the menu/message differ), so there's
// no card swap to animate — just make sure it's the visible screen.
document.getElementById('main-container').style.display = 'none';
document.getElementById('yes-container').style.display = 'block';
// Restore the options menu
document.getElementById('options-menu').style.display = 'flex';
const fm = document.getElementById('final-msg');
fm.innerHTML = 'Now the most important question... What are we gonna do afterall?';
// Revert to the default (token) message colour by dropping the success class.
fm.classList.remove('is-confirmed');
} else if (hash === '' || hash === '#') {
// She pressed back from the options screen, all the way to the first page
swapScreens(
document.getElementById('yes-container'),
document.getElementById('main-container')
);
music.currentTime = 26; // reset music to verse
if (musicPlaying) music.play().catch(() => { });
}
});
// Cross-fade between the two full-screen cards: ease the current one out, then
// reveal the next (its #id selector re-runs the contentReveal entrance). Honours
// reduced motion with an instant swap, and is guarded against double-firing.
function swapScreens(outEl, inEl, onShown) {
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let done = false;
const finish = () => {
if (done) return;
done = true;
outEl.removeEventListener('animationend', finish);
outEl.classList.remove('is-leaving');
outEl.style.display = 'none';
inEl.style.display = 'block';
if (typeof onShown === 'function') onShown();
};
if (reduce) { finish(); return; }
outEl.classList.add('is-leaving');
outEl.addEventListener('animationend', finish);
setTimeout(finish, 600); // fallback if animationend is missed
}
const gifStages = [
"https://media.tenor.com/EBV7OT7ACfwAAAAj/u-u-qua-qua-u-quaa.gif", // 0 normal
"https://media1.tenor.com/m/uDugCXK4vI4AAAAd/chiikawa-hachiware.gif", // 1 confused
"https://media.tenor.com/f_rkpJbH1s8AAAAj/somsom1012.gif", // 2 pleading
"https://media.tenor.com/OGY9zdREsVAAAAAj/somsom1012.gif", // 3 sad
"https://media1.tenor.com/m/WGfra-Y_Ke0AAAAd/chiikawa-sad.gif", // 4 sadder
"https://media.tenor.com/CivArbX7NzQAAAAj/somsom1012.gif", // 5 devastated
"https://media.tenor.com/5_tv1HquZlcAAAAj/chiikawa.gif", // 6 very devastated
"https://media1.tenor.com/m/uDugCXK4vI4AAAAC/chiikawa-hachiware.gif" // 7 crying runaway
]
const noMessages = [
"No",
"Are you sure? 🥺",
"I'll let you win all the arguments! 🫣",
"I'll buy u hairpins! 🌸",
"Come on, at least for coffee? ☕",
"Please? 🥺",
"I'll take aesthetic photos of you! 📸",
"You can't escape me anyway 😜"
]
const yesTeasePokes = [
"try saying no first... I bet you want to know what happens 😏",
"go on, hit no... just once 👀",
"trust me, you're missing out 😈",
"click no, I dare you 😏"
]
let yesTeasedCount = 0
let noClickCount = 0
let runawayEnabled = false
let musicPlaying = true
const catGif = document.getElementById('cat-gif');
catGif.addEventListener('contextmenu', e => e.preventDefault());
// Prevent right‑click download on any media element (audio, video, img)
document.addEventListener('contextmenu', e => {
if (e.target.matches('audio, video, img')) {
e.preventDefault();
}
});
const yesBtn = document.getElementById('yes-btn');
const noBtn = document.getElementById('no-btn');
const music = document.getElementById('bg-music');
music.volume = 0.3;
// --- Design-token palette: single source of truth for JS-driven colours so
// nothing hardcodes a rogue green/pink. Reads the CSS custom properties. ---
function palette() {
const s = getComputedStyle(document.documentElement);
const v = (n, fallback) => (s.getPropertyValue(n).trim() || fallback);
return {
rose: v('--rose', '#ff8fa3'),
roseDeep: v('--rose-deep', '#e0607e'),
roseSoft: v('--rose-soft', '#ffb3c1'),
gold: v('--gold', '#e8b48f'),
mint: v('--mint', '#2fd27d'),
white: '#ffffff',
};
}
// --- TASK 1: Yes-button growth, bounded to the card ---
// Asymptotic growth — each call closes a fraction of the REMAINING headroom, so
// the button keeps growing but slows smoothly as it nears the cap (no abrupt
// stop). A measured clamp then guarantees it never exceeds the card width.
let yesGrow = 0;
let yesBase = null;
function growYesButton(step) {
if (!yesBase) {
const cs = getComputedStyle(yesBtn);
yesBase = { font: parseFloat(cs.fontSize), padY: parseFloat(cs.paddingTop), padX: parseFloat(cs.paddingLeft) };
}
yesGrow = yesGrow + (1 - yesGrow) * step; // approaches but never reaches 1
const card = yesBtn.closest('.container') || document.getElementById('main-container');
const ccs = getComputedStyle(card);
const innerW = card.clientWidth - parseFloat(ccs.paddingLeft) - parseFloat(ccs.paddingRight);
const small = window.innerWidth < 600;
// Caps scale with the card so the button stays inside the composition.
const maxFont = Math.min(small ? 56 : 96, innerW * 0.17);
const maxPadX = Math.min(small ? 54 : 110, innerW * 0.11);
const maxPadY = small ? 34 : 60;
const font = yesBase.font + (maxFont - yesBase.font) * yesGrow;
const padX = yesBase.padX + (maxPadX - yesBase.padX) * yesGrow;
const padY = yesBase.padY + (maxPadY - yesBase.padY) * yesGrow;
yesBtn.style.fontSize = font + 'px';
yesBtn.style.padding = padY + 'px ' + padX + 'px';
// Hard guarantee: never wider than the card content box.
const maxW = innerW * 0.92;
if (yesBtn.offsetWidth > maxW) {
const k = maxW / yesBtn.offsetWidth;
yesBtn.style.fontSize = (font * k) + 'px';
yesBtn.style.padding = (padY * k) + 'px ' + (padX * k) + 'px';
}
// Climax glow once it is dominant and we're in the runaway endgame.
if (runawayEnabled && yesGrow > 0.85) {
yesBtn.classList.add('yes-glow-active');
}
}
// Cache for fetched audio blobs
const audioCache = new Map();
async function loadAudio(url) {
if (audioCache.has(url)) return audioCache.get(url);
try {
const resp = await fetch(url);
const blob = await resp.blob();
const objUrl = URL.createObjectURL(blob);
audioCache.set(url, objUrl);
return objUrl;
} catch (e) {
console.error('Failed to load audio', url, e);
return url; // fallback to original URL
}
}
// Load audio (and initial GIF) on DOMContentLoaded
document.addEventListener('DOMContentLoaded', async () => {
const musicUrl = await loadAudio('music/this_love taylor swift.mp3');
music.src = musicUrl;
music.addEventListener('contextmenu', e => e.preventDefault());
if (gifStages.length > 0) {
const firstUrl = await loadGif(gifStages[0]);
catGif.src = firstUrl;
}
});
// Cache for fetched audio blobs
// Duplicate audio loader and init removed – single loadAudio implementation above handles fetching audio as a Blob URL.
// Preserve music playback position when page is hidden or user switches tabs
let savedMusicTime = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (!music.paused) {
savedMusicTime = music.currentTime;
music.pause();
}
} else {
if (musicPlaying) {
music.currentTime = savedMusicTime || 0;
music.play().catch(() => {});
}
}
});
// Stop music entirely when the user navigates away or closes the tab
window.addEventListener('pagehide', () => {
if (!music.paused) {
music.pause();
}
savedMusicTime = 0;
});
// Gesture-critical side effects (music start + motion permission). Must run
// synchronously inside a user gesture so autoplay/permission prompts aren't
// blocked. Split out so the 3D envelope (P4) can run these on tap and defer the
// overlay hide until its open animation finishes.
function primeExperience() {
// A soft seal-break as the envelope opens (within the user gesture, so it's
// allowed to play). No-op until sounds/seal-break.mp3 exists.
playSfx(sealSfx);
buzz(20);
// Ensure background music is loaded (already set on DOMContentLoaded) and start at promised position
music.currentTime = 26;
music.muted = false;
music.play().catch(() => {});
// Try to attach motion permissions via our sensors module
if (typeof requestMotionPermissions === 'function') {
requestMotionPermissions();
}
}
// Hides the intro overlay → triggers the P2 reveal of the card beneath.
// Idempotent: safe to call from the 3D timeline, the safety timeout, or fallback.
function revealCard() {
const overlay = document.getElementById('surprise-overlay');
if (overlay) {
overlay.classList.add('hidden');
}
// Begin the letter once the card is uncovered (idempotent).
startLetterSequence();
}
// Unchanged behaviour: the inline onclick + reduced-motion/fallback path.
function openSurprise() {
revealCard();
primeExperience();
}
// Expose for the envelope module (loaded as an ES module, separate scope).
window.primeExperience = primeExperience;
window.revealCard = revealCard;
window.openSurprise = openSurprise;
function toggleMusic() {
if (musicPlaying) {
music.pause()
musicPlaying = false
document.getElementById('music-toggle').textContent = '🔇'
} else {
music.muted = false
music.play()
musicPlaying = true
document.getElementById('music-toggle').textContent = '🔊'
}
}
function handleYesClick() {
if (!runawayEnabled) {
// Tease her to try No first
const msg = yesTeasePokes[Math.min(yesTeasedCount, yesTeasePokes.length - 1)]
yesTeasedCount++
showTeaseMessage(msg)
return
}
// Cross-fade from the question card to the YES view within the same page.
// Confetti blooms as the YES screen settles in, not during the fade-out.
swapScreens(
document.getElementById('main-container'),
document.getElementById('yes-container'),
launchConfetti
);
// A warm chime + celebratory buzz on the big yes.
if (musicPlaying) playSfx(chimeSfx);
buzz([30, 40, 60]);
// Push state for back button handling
history.pushState({ view: 'yes' }, '', '#yes');
// Jump music to finale
music.currentTime = 67; // finale at 1:07
if (!musicPlaying) toggleMusic();
}
function showTeaseMessage(msg) {
let toast = document.getElementById('tease-toast')
toast.textContent = msg
toast.classList.add('show')
clearTimeout(toast._timer)
toast._timer = setTimeout(() => toast.classList.remove('show'), 2500)
}
function handleNoClick() {
noClickCount++
buzz(18) // light tap feedback on each dodge
// Cycle through guilt-trip messages
const msgIndex = Math.min(noClickCount, noMessages.length - 1)
noBtn.textContent = noMessages[msgIndex]
// Grow the Yes button bigger each time — bounded to the card (TASK 1).
growYesButton(0.34);
// Shrink No button to contrast
if (noClickCount >= 2) {
const noSize = parseFloat(window.getComputedStyle(noBtn).fontSize)
noBtn.style.fontSize = `${Math.max(noSize * 0.85, 10)}px`
}
// Swap cat GIF through stages
const gifIndex = Math.min(noClickCount, gifStages.length - 1)
swapGif(gifStages[gifIndex])
// Runaway starts at click 5
if (noClickCount >= 5 && !runawayEnabled) {
enableRunaway()
runawayEnabled = true
}
}
// --- Optimized GIF loading (network fetch + caching) ---
const gifCache = new Map();
async function loadGif(url) {
if (gifCache.has(url)) return gifCache.get(url);
try {
const resp = await fetch(url);
const blob = await resp.blob();
const objUrl = URL.createObjectURL(blob);
gifCache.set(url, objUrl);
return objUrl;
} catch (e) {
console.error('Failed to load GIF', url, e);
return url; // fallback to original URL
}
}
// (The first-stage GIF is set in the DOMContentLoaded handler above, alongside
// the audio load — no duplicate initialiser needed here.)
// Updated swapGif to use async loading and caching
async function swapGif(src) {
const gifUrl = await loadGif(src);
catGif.style.opacity = '0';
setTimeout(() => {
catGif.src = gifUrl;
catGif.style.opacity = '1';
}, 200);
}
function enableRunaway() {
noBtn.addEventListener('mouseover', runAway)
noBtn.addEventListener('touchstart', runAway, { passive: true })
}
function runAway() {
// Grow YES every time they try to hover NO — bounded to the card (TASK 1).
growYesButton(0.16);
const margin = 20
const btnW = noBtn.offsetWidth
const btnH = noBtn.offsetHeight
const maxX = window.innerWidth - btnW - margin
const maxY = window.innerHeight - btnH - margin
const randomX = Math.random() * maxX + margin / 2
const randomY = Math.random() * maxY + margin / 2
noBtn.style.position = 'fixed'
noBtn.style.left = `${randomX}px`;
noBtn.style.top = `${randomY}px`;
noBtn.style.zIndex = '50';
}
// --- ANTI-CHEAT (ZOOM PREVENTION) ---
// Prevent pinch-to-zoom on mobile
document.addEventListener('touchstart', function (e) {
if (e.touches.length > 1) {
e.preventDefault();
showTeaseMessage("Uh-uh-uh, u can't cheat here! 😉");
}
}, { passive: false });
// Prevent pinch-to-zoom movement
document.addEventListener('touchmove', function (e) {
if (e.touches.length > 1) {
e.preventDefault();
}
}, { passive: false });
// Prevent double-tap to zoom on mobile
let lastTouchEnd = 0;
document.addEventListener('touchend', function (e) {
let now = (new Date()).getTime();
if (now - lastTouchEnd <= 300) {
e.preventDefault();
showTeaseMessage("Uh-uh-uh, u can't cheat here! 😉");
}
lastTouchEnd = now;
}, { passive: false });
// Prevent Ctrl + Scroll zoom on desktop
document.addEventListener('wheel', function (e) {
if (e.ctrlKey) {
e.preventDefault();
showTeaseMessage("Uh-uh-uh, u can't cheat here! 😉");
}
}, { passive: false });
// Prevent Ctrl + +/- keys zoom on desktop
document.addEventListener('keydown', function (e) {
if (e.ctrlKey && (e.key === '=' || e.key === '-' || e.key === '+' || e.key === '0')) {
e.preventDefault();
showTeaseMessage("Uh-uh-uh, u can't cheat here! 😉");
}
}, { passive: false });
function launchConfetti() {
// Palette-matched, romantic rather than carnival — sourced from design tokens.
const p = palette();
const colors = [p.rose, p.roseDeep, p.roseSoft, p.gold, p.white, p.mint];
// Calm physics: softer launch, real gravity, gentle drift, larger slow petals.
const base = { colors, gravity: 0.8, scalar: 1.1, drift: 0.4, decay: 0.92, ticks: 260 };
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
// A single, gentle bloom — celebratory but still.
confetti({ ...base, particleCount: 40, spread: 70, startVelocity: 26, origin: { x: 0.5, y: 0.4 } });
return;
}
// Opening bloom from the heart of the card.
confetti({ ...base, particleCount: 120, spread: 90, startVelocity: 38, origin: { x: 0.5, y: 0.35 } });
const duration = 6000;
const end = Date.now() + duration;
let tick = 0;
// Gentle, choreographed afterglow: soft side drifts that ease down over time,
// with an occasional slow rising puff from below.
const interval = setInterval(() => {
const remaining = end - Date.now();
if (remaining <= 0) {
clearInterval(interval);
return;
}
tick++;
const intensity = Math.max(0.3, remaining / duration);
const count = Math.round(22 * intensity);
confetti({ ...base, particleCount: count, angle: 60, spread: 50, startVelocity: 32, origin: { x: 0, y: 0.65 } });
confetti({ ...base, particleCount: count, angle: 120, spread: 50, startVelocity: 32, origin: { x: 1, y: 0.65 } });
if (tick % 3 === 0) {
confetti({ ...base, particleCount: 14, spread: 110, startVelocity: 26, origin: { x: 0.5, y: 0.72 } });
}
}, 350);
}
function selectFood(choice) {
const menu = document.getElementById('options-menu');
const msg = document.getElementById('final-msg');
buzz(25)
// Trigger explicit report via our analytics module
if (typeof sendFinalReport === 'function') {
sendFinalReport(choice, noClickCount, yesTeasedCount);
}
confetti({
particleCount: 80,
spread: 80,
startVelocity: 32,
gravity: 0.8,
scalar: 1.1,
drift: 0.4,
decay: 0.92,
ticks: 240,
origin: { y: 0.62 },
colors: (() => { const p = palette(); return [p.mint, p.rose, p.roseSoft, p.gold, p.white]; })()
});
// Push new state so pressing "back" will return to the options page
history.pushState({ view: 'final' }, '', '#final');
menu.style.display = 'none';
msg.innerHTML = `Awesome! Get ready for <strong>${choice}</strong>. Can't wait! ❤️`;
// Success colour comes from a token-driven class, not a hardcoded green.
msg.classList.add('is-confirmed');
}