// app.jsx — Mother's Day Trivia for Demetria const { useState, useEffect, useRef, useCallback } = React; // ───────────────────────────── Tweakable defaults ───────────────────────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "momName": "Demetria", "palette": "teal", "dark": false, "spinSpeed": 3000 }/*EDITMODE-END*/; const PALETTES = { teal: { primary: "#1F6F73", primaryDeep: "#16585C", primarySoft: "#7FB4B6", primaryTint: "#DDEDEC", accent: "#4F7A52", accentDeep: "#36603A", accentTint: "#E1ECDD" }, rose: { primary: "#C4456B", primaryDeep: "#A03555", primarySoft: "#E89AB1", primaryTint: "#F7DCE5", accent: "#7A8B5C", accentDeep: "#5C6A44", accentTint: "#E6EBD9" }, amber: { primary: "#C4762E", primaryDeep: "#A05E1F", primarySoft: "#E5B47A", primaryTint: "#F4E2C5", accent: "#2A6F70", accentDeep: "#1F5859", accentTint: "#D8E8E8" }, indigo: { primary: "#3A5A8A", primaryDeep: "#2A4368", primarySoft: "#9AB0CC", primaryTint: "#DCE4F1", accent: "#7B5C8B", accentDeep: "#5E4569", accentTint: "#E5DDEC" }, }; // ───────────────────────────── Sound effects ───────────────────────────── // Tiny Web Audio SFX synthesizer — no asset files needed. const sfxCtx = (() => { let ctx = null; return () => { if (!ctx) { const Ctx = window.AudioContext || window.webkitAudioContext; if (Ctx) ctx = new Ctx(); } if (ctx && ctx.state === "suspended") ctx.resume(); return ctx; }; })(); function playSFX(kind, idx = 0) { const ctx = sfxCtx(); if (!ctx) return; const now = ctx.currentTime; const master = ctx.createGain(); master.gain.value = 0.18; master.connect(ctx.destination); const blip = (freq, when, dur, type = "sine", vol = 1) => { const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = type; o.frequency.setValueAtTime(freq, when); g.gain.setValueAtTime(0, when); g.gain.linearRampToValueAtTime(vol, when + 0.005); g.gain.exponentialRampToValueAtTime(0.001, when + dur); o.connect(g).connect(master); o.start(when); o.stop(when + dur + 0.02); }; if (kind === "correct") { // Bright triadic upward arpeggio (C-E-G-C) blip(523.25, now + 0.00, 0.18, "triangle", 0.9); // C5 blip(659.25, now + 0.07, 0.18, "triangle", 0.9); // E5 blip(783.99, now + 0.14, 0.20, "triangle", 0.9); // G5 blip(1046.5, now + 0.22, 0.30, "triangle", 1.0); // C6 // Subtle sine sparkle on top blip(2093.0, now + 0.22, 0.25, "sine", 0.35); } else if (kind === "incorrect") { // Soft minor descent — gentle, not punishing blip(392.00, now + 0.00, 0.18, "sine", 0.8); // G4 blip(329.63, now + 0.10, 0.22, "sine", 0.8); // E4 blip(293.66, now + 0.22, 0.30, "sine", 0.8); // D4 } else if (kind === "coin") { // Classic two-pitch coin "ting" const base = 988 + (idx % 3) * 60; // varies slightly per coin blip(base, now + 0.00, 0.07, "square", 0.45); blip(base * 1.5, now + 0.06, 0.18, "square", 0.45); // metallic sparkle blip(base * 3, now + 0.06, 0.10, "sine", 0.18); } else if (kind === "needle-down") { // Vinyl pop + scratch when needle drops onto record // Sharp transient const click = ctx.createOscillator(); const cg = ctx.createGain(); click.type = "square"; click.frequency.setValueAtTime(80, now); cg.gain.setValueAtTime(0.4, now); cg.gain.exponentialRampToValueAtTime(0.001, now + 0.04); click.connect(cg).connect(master); click.start(now); click.stop(now + 0.05); // Short crackle burst (filtered noise) const buf = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { const t = i / data.length; data[i] = (Math.random() * 2 - 1) * (1 - t) * (Math.random() < 0.15 ? 1 : 0.25); } const noise = ctx.createBufferSource(); noise.buffer = buf; const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 1500; const ng = ctx.createGain(); ng.gain.setValueAtTime(0.6, now); ng.gain.exponentialRampToValueAtTime(0.001, now + 0.5); noise.connect(hp).connect(ng).connect(master); noise.start(now); noise.stop(now + 0.55); } else if (kind === "needle-up") { // Brief upward-pitched scratch as needle lifts off const buf = ctx.createBuffer(1, ctx.sampleRate * 0.25, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = (Math.random() * 2 - 1) * 0.6; } const noise = ctx.createBufferSource(); noise.buffer = buf; const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.setValueAtTime(800, now); bp.frequency.exponentialRampToValueAtTime(3500, now + 0.18); bp.Q.value = 6; const ng = ctx.createGain(); ng.gain.setValueAtTime(0.5, now); ng.gain.exponentialRampToValueAtTime(0.001, now + 0.22); noise.connect(bp).connect(ng).connect(master); noise.start(now); noise.stop(now + 0.25); } else if (kind === "cashin") { // Cash-register "kerching" — short low thunk, brassy bell, sparkle, two coin pings. blip(180, now + 0.00, 0.05, "square", 0.45); // drawer thunk blip(1318.51, now + 0.04, 0.45, "triangle", 0.55); // E6 — bell body blip(1975.53, now + 0.05, 0.50, "sine", 0.40); // B6 — bell harmonic blip(2637.02, now + 0.10, 0.30, "sine", 0.30); // E7 — sparkle on top blip(987.77, now + 0.22, 0.14, "square", 0.30); // B5 — coin clink 1 blip(1567.98, now + 0.32, 0.16, "square", 0.28); // G6 — coin clink 2 } else if (kind === "scrub") { // Tape-style FF/RW whoosh — a short pitched noise sweep const dir = idx >= 0 ? 1 : -1; // 1 = ff, -1 = rewind const buf = ctx.createBuffer(1, ctx.sampleRate * 0.18, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { data[i] = (Math.random() * 2 - 1) * 0.7; } const noise = ctx.createBufferSource(); noise.buffer = buf; const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.Q.value = 4; const fStart = dir > 0 ? 1200 : 3000; const fEnd = dir > 0 ? 3000 : 1200; bp.frequency.setValueAtTime(fStart, now); bp.frequency.exponentialRampToValueAtTime(fEnd, now + 0.14); const ng = ctx.createGain(); ng.gain.setValueAtTime(0.0, now); ng.gain.linearRampToValueAtTime(0.45, now + 0.02); ng.gain.exponentialRampToValueAtTime(0.001, now + 0.16); noise.connect(bp).connect(ng).connect(master); noise.start(now); noise.stop(now + 0.18); } } // ───────────────────────────── Main App ───────────────────────────── function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // Apply palette to CSS vars useEffect(() => { const p = PALETTES[t.palette] || PALETTES.teal; const r = document.documentElement.style; r.setProperty("--primary", p.primary); r.setProperty("--rose", p.primary); r.setProperty("--rose-deep", p.primaryDeep); r.setProperty("--rose-soft", p.primarySoft); r.setProperty("--rose-tint", p.primaryTint); r.setProperty("--accent", p.accent); r.setProperty("--sage", p.accent); r.setProperty("--sage-deep", p.accentDeep); r.setProperty("--sage-tint", p.accentTint); r.setProperty("--dur-spin", `${t.spinSpeed}ms`); }, [t.palette, t.spinSpeed]); // ── Game state ── const [screen, setScreen] = useState("welcome"); // welcome, crate, playing, question, shop const [currentId, setCurrentId] = useState(null); const [played, setPlayed] = useState({}); // { id: {correct: bool} } const [picked, setPicked] = useState(null); const [audioPlaying, setAudioPlaying] = useState(false); const [notes, setNotes] = useState(0); const [purchased, setPurchased] = useState({}); // { itemId: true } const [redeemed, setRedeemed] = useState({}); // { itemId: true } const [redeemingItem, setRedeemingItem] = useState(null); const [pillBump, setPillBump] = useState(false); const [confirmItem, setConfirmItem] = useState(null); const [pendingPurchase, setPendingPurchase] = useState(null); const [flyingNotes, setFlyingNotes] = useState([]); const [crateSide, setCrateSide] = useState("A"); // Bumped each time the user clicks the side toggle, so the Crate's // scroll effect re-fires even if crateSide hasn't actually changed. const [crateScrollTick, setCrateScrollTick] = useState(0); // Tracks whether the user has completed each turntable tutorial step at // least once. Used to disable the pulsing instruction after first success. const [tutorialDone, setTutorialDone] = useState({ sleeve: false, loaded: false }); // True once the user has dismissed the first-visit greeting popup. const [firstVisitGreeted, setFirstVisitGreeted] = useState(false); // Whether the user is allowed to advance from the welcome screen. Closes // briefly while the greeting plays — opens at 1/8 of duration (or when the // audio ends/errors). Defaults open so non-first visits never block. const [greetingGateOpen, setGreetingGateOpen] = useState(true); // Holds the greeting Audio element so we can stop it when the user clicks // "Drop the needle" or otherwise leaves the welcome screen. const greetingAudioRef = useRef(null); const current = QUESTIONS.find(q => q.id === currentId); const total = QUESTIONS.length; const playedCount = Object.keys(played).length; // Persist to localStorage so refresh doesn't reset useEffect(() => { const saved = localStorage.getItem("mdt_state"); if (saved) { try { const s = JSON.parse(saved); if (s.played) setPlayed(s.played); if (typeof s.notes === "number") setNotes(s.notes); if (s.purchased) setPurchased(s.purchased); if (s.redeemed) setRedeemed(s.redeemed); if (s.tutorialDone) setTutorialDone(td => ({ ...td, ...s.tutorialDone })); if (typeof s.firstVisitGreeted === "boolean") setFirstVisitGreeted(s.firstVisitGreeted); } catch {} } }, []); useEffect(() => { localStorage.setItem("mdt_state", JSON.stringify({ played, notes, purchased, redeemed, tutorialDone, firstVisitGreeted })); }, [played, notes, purchased, redeemed, tutorialDone, firstVisitGreeted]); // (Audio "finishing" is now driven inside via the tonearm interaction.) // ── Actions ── const pickRecord = useCallback((id) => { const q = QUESTIONS.find(qq => qq.id === id); if (!q) return; // Block locked bonus tracks if (q.bonus) { const unlocked = QUESTIONS.filter(qq => !qq.bonus).every(qq => { const p = played[qq.id]; return p && p.correct; }); if (!unlocked) return; } setCurrentId(id); setPicked(null); setScreen("playing"); setAudioPlaying(true); }, [played]); const answer = useCallback((i) => { setPicked(i); const wasCorrect = !!(played[current.id] && played[current.id].correct); if (i === current.correct) { playSFX("correct"); // Only award notes / fly the celebration the first time they get it right. if (!wasCorrect) { setNotes(n => n + 5); setPillBump(true); setTimeout(() => setPillBump(false), 600); launchFlyingNotes(); } setPlayed(p => ({ ...p, [current.id]: { correct: true } })); } else { playSFX("incorrect"); // Don't downgrade a previously-correct answer if they replay and miss. if (!wasCorrect) { setPlayed(p => ({ ...p, [current.id]: { correct: false } })); } } }, [current, played]); const launchFlyingNotes = () => { // Find pill location relative to viewport, and origin near center const pill = document.querySelector(".notes-pill"); if (!pill) return; const pr = pill.getBoundingClientRect(); const ox = window.innerWidth / 2; const oy = window.innerHeight / 2; const flies = []; for (let k = 0; k < 5; k++) { flies.push({ id: Date.now() + k, x: ox + (Math.random() - 0.5) * 60, y: oy + (Math.random() - 0.5) * 40, dx: (pr.left + pr.width / 2) - ox + (Math.random() - 0.5) * 30, dy: (pr.top + pr.height / 2) - oy + (Math.random() - 0.5) * 16, delay: k * 70, }); // Schedule a coin "ting" for each note's arrival setTimeout(() => playSFX("coin", k), 700 + k * 70); } setFlyingNotes(flies); setTimeout(() => setFlyingNotes([]), 1400); }; const continueAfterReveal = () => { const triviaTotal = QUESTIONS.filter(q => !q.bonus).length; const triviaPlayed = QUESTIONS.filter(q => !q.bonus && played[q.id]).length; if (triviaPlayed >= triviaTotal) { setScreen("shop"); } else { setScreen("crate"); } setPicked(null); }; const buy = (item) => { if (purchased[item.id] || notes < item.cost) return; setPendingPurchase(item); }; const finalizePurchase = () => { const item = pendingPurchase; if (!item) return; if (purchased[item.id] || notes < item.cost) { setPendingPurchase(null); return; } setNotes(n => n - item.cost); setPurchased(p => ({ ...p, [item.id]: true })); setPendingPurchase(null); setConfirmItem(item); playSFX("cashin"); }; const redeem = (item) => { if (!purchased[item.id] || redeemed[item.id]) return; setRedeemed(r => ({ ...r, [item.id]: true })); }; const restart = () => { setPlayed({}); setNotes(0); setPurchased({}); setRedeemed({}); setPicked(null); setCurrentId(null); setScreen("welcome"); setCrateSide("A"); setTutorialDone({ sleeve: false, loaded: false }); setFirstVisitGreeted(false); setGreetingGateOpen(true); stopGreetingAudio(); localStorage.removeItem("mdt_state"); }; const stopGreetingAudio = () => { const a = greetingAudioRef.current; if (!a) return; try { a.pause(); a.currentTime = 0; a.src = ""; } catch (e) {} greetingAudioRef.current = null; }; const dismissGreeting = () => { setFirstVisitGreeted(true); // Close the "Drop the needle" gate while the greeting plays. Opens again // at 1/8 of the duration (or on natural end / playback error / audio // failure) so the user can't bail before they've heard a beat or two. setGreetingGateOpen(false); // Auto-play the greeting. The user click that fired this handler counts // as the user gesture browsers require for autoplay, so .play() should resolve. try { const a = new Audio("assets/audio/greeting.mp3"); a.volume = 0.95; greetingAudioRef.current = a; const openGate = () => setGreetingGateOpen(true); a.addEventListener("timeupdate", () => { const d = a.duration; // Known + finite duration → wait until 1/8 elapsed. // Unknown / VBR mp3s without header duration → fall back to 3 s of // playback so we never strand the user. if (isFinite(d) && d > 0) { if (a.currentTime >= d / 8) openGate(); } else if (a.currentTime >= 3) { openGate(); } }); a.addEventListener("ended", () => { openGate(); if (greetingAudioRef.current === a) greetingAudioRef.current = null; }); a.addEventListener("error", openGate); a.play().catch((e) => { console.warn("Greeting audio could not auto-play:", e); openGate(); }); } catch (e) { console.warn("Greeting audio failed:", e); setGreetingGateOpen(true); } }; const markTutorial = (step) => { setTutorialDone(td => td[step] ? td : { ...td, [step]: true }); }; const toggleCrateSide = () => { setCrateSide(s => (s === "A" ? "B" : "A")); setCrateScrollTick(n => n + 1); }; const triviaTotal = QUESTIONS.filter(q => !q.bonus).length; const triviaPlayedCount = QUESTIONS.filter(q => !q.bonus && played[q.id]).length; const allPlayed = triviaPlayedCount >= triviaTotal; // Bonus tracks unlock only when EVERY regular track has been answered correctly const bonusUnlocked = QUESTIONS.filter(q => !q.bonus).every(q => played[q.id] && played[q.id].correct); const isDark = t.dark; return (
{/* Topbar — never on welcome */} {screen !== "welcome" && ( ) : screen === "shop" ? : screen === "redeem" ? setScreen("shop")} /> : screen === "playing" ? setScreen("crate")} /> : screen === "question" ? setScreen("crate")} /> : null } right={
setScreen("welcome")} /> setScreen("shop")} />
} /> )} {/* Screen body */} {screen === "welcome" && ( { stopGreetingAudio(); setScreen("crate"); }} t={t} setTweak={setTweak} canStart={greetingGateOpen} /> )} {screen === "welcome" && !firstVisitGreeted && ( )} {screen === "crate" && ( setScreen("shop")} notes={notes} crateSide={crateSide} scrollTick={crateScrollTick} /> )} {screen === "playing" && current && ( { setAudioPlaying(false); if (current && current.bonus) { setScreen("crate"); } else { setScreen("question"); } }} onAdvance={() => { setAudioPlaying(false); if (current && current.bonus) { // Bonus track: mark as "played" (no correctness), go back to crate. setPlayed(p => ({ ...p, [current.id]: { bonus: true } })); setScreen("crate"); } else { setScreen("question"); } }} /> )} {screen === "question" && current && ( !q.bonus && played[q.id]).length >= QUESTIONS.filter(q => !q.bonus).length} /> )} {screen === "shop" && ( setScreen("crate")} onPlayAgain={restart} onRedeem={() => setScreen("redeem")} onBonus={() => { if (!bonusUnlocked) return; setCrateSide("B"); setCrateScrollTick(n => n + 1); setScreen("crate"); }} momName={t.momName} /> )} {screen === "redeem" && ( setRedeemingItem(item)} onBack={() => setScreen("shop")} /> )} {/* Flying notes */} {flyingNotes.map(f => ( ))} {/* Buy confirmation — appears BEFORE notes are deducted */} {pendingPurchase && ( setPendingPurchase(null)} onConfirm={finalizePurchase} /> )} {/* Press celebration — appears AFTER purchase is finalized */} {confirmItem && ( setConfirmItem(null)} momName={t.momName} /> )} {/* Redeem certificate modal */} {redeemingItem && ( setRedeemingItem(null)} onRedeem={() => redeem(redeemingItem)} /> )} {/* Tweaks panel */} setTweak("momName", v)} /> p.primary)} onChange={(hex) => { const key = Object.keys(PALETTES).find(k => PALETTES[k].primary === hex) || "teal"; setTweak("palette", key); }} /> setTweak("dark", v)} /> setTweak("spinSpeed", v)} />
); } // ───────────────────────────── Welcome ───────────────────────────── function Welcome({ momName, onStart, t, setTweak, canStart = true }) { return (
A Mother's Day Pressing · 2026

Hi {momName}.
We made a few records for you.

5 tracks. Each one is a question you should know. Get them right and earn music notes. These notes are currency in the shop on Side B.

{/* Inline tweak strip — palette / dark / spin speed */}
Palette
{Object.keys(PALETTES).map(key => (
Dark mode
Spin speed
setTweak("spinSpeed", 4500 - Number(e.target.value))} /> {Math.round(60000 / t.spinSpeed)} rpm
Voices on the records
Elijah · Lorelei · Jackson · Brandon
); } // ───────────────────────────── Crate ───────────────────────────── function Crate({ played, allPlayed, bonusUnlocked, onPick, onShop, notes, crateSide = "A", scrollTick = 0 }) { const sideA = QUESTIONS.filter(q => q.side === "A"); const sideB = QUESTIONS.filter(q => q.side === "B"); const triviaTotal = QUESTIONS.filter(q => !q.bonus).length; const playedCount = QUESTIONS.filter(q => !q.bonus && played[q.id]).length; const screenRef = useRef(null); const sideADividerRef = useRef(null); const sideBDividerRef = useRef(null); // When the topbar side toggle fires, scroll the appropriate divider into view. useEffect(() => { if (scrollTick === 0) return; // skip on initial mount const target = crateSide === "B" ? sideBDividerRef.current : sideADividerRef.current; if (target && target.scrollIntoView) { target.scrollIntoView({ behavior: "smooth", block: "start" }); } }, [crateSide, scrollTick]); return (
The Crate

Pick a track.

{playedCount === 0 && "FIVE tracks waiting. Each correct answer is worth five notes. Answer them all correctly and unlock the hidden tracks on Side B."} {playedCount > 0 && playedCount < triviaTotal && `${playedCount} of ${triviaTotal} played. ${notes} notes in the bank.`} {allPlayed && "That's all of the tracks. Head to the shop on Side B."}

Side A
{sideA.map(q => ( onPick(q.id)} /> ))}
Side B
{sideB.map(q => ( onPick(q.id)} /> ))} {allPlayed && (
End of the game

The shop is open.

You earned {notes} notes. Spend them on certificates.

)} {!allPlayed && playedCount > 0 && (
Skip ahead

Want to peek at the shop?

You can spend notes anytime. The shop stays open between tracks.

)}
); } // ───────────────────────────── Now Playing (tactile) ───────────────────────────── // Stages: // 1. "sleeve" — vinyl peeking out of its sleeve, drag it to the platter // 2. "loaded" — vinyl on platter, idle (paused). Drag the tonearm to play. // 3. "playing" — tonearm dropped, record spinning, audio "playing" function Playing({ q, onSkip, onAdvance, tutorialDone = { sleeve: false, loaded: false }, markTutorial = () => {} }) { const [stage, setStage] = useState("sleeve"); // sleeve | loaded | playing // First-time tutorial: mark each step done as the user completes it. // sleeve→loaded ⇒ they figured out how to drop the record. // loaded→playing ⇒ they figured out how to drop the needle. useEffect(() => { if (stage === "loaded" && !tutorialDone.sleeve) markTutorial("sleeve"); if (stage === "playing" && !tutorialDone.loaded) markTutorial("loaded"); }, [stage, tutorialDone.sleeve, tutorialDone.loaded, markTutorial]); const pulseInstruction = (stage === "sleeve" && !tutorialDone.sleeve) || (stage === "loaded" && !tutorialDone.loaded); const [vinylPos, setVinylPos] = useState(null); // {x,y} in stage coords (pointer position) while dragging const [armAngle, setArmAngle] = useState(-26); // -26 = parked, -6 = playing const [armDragging, setArmDragging] = useState(false); const [progress, setProgress] = useState(0); // 0..1, audio progress const [durationSec, setDurationSec] = useState(0); // seconds — populated once metadata is read const [coverOpen, setCoverOpen] = useState(false); // sleeve-art lightbox const stageAreaRef = useRef(null); const stageRef = useRef(null); const platterRef = useRef(null); const tonearmRef = useRef(null); const sleeveOnStageRef = useRef(null); const animRef = useRef(null); // Drag state via refs (avoids stale-closure issues on window listeners). // `source` records where the drag started so we can route the drop: // "sleeve" → vinyl is being put on the platter // "platter" → vinyl is being removed back into the sleeve const dragRef = useRef({ active: false, source: null }); const [dragSource, setDragSource] = useState(null); const armDragRef = useRef(false); // Audio progress is driven by the HTMLAudioElement timeupdate (see useEffect below). // Auto-advance is handled by the audio "ended" event — no separate RAF timer needed. const dragging = vinylPos !== null; // ── Vinyl drag — sleeve→platter and platter→sleeve ── const startVinylDrag = (e, source) => { e.preventDefault(); e.stopPropagation(); if (!stageAreaRef.current) return; const r = stageAreaRef.current.getBoundingClientRect(); dragRef.current = { active: true, source }; setDragSource(source); setVinylPos({ x: e.clientX - r.left, y: e.clientY - r.top }); }; // Pull the record OUT of the sleeve onto the platter. const onVinylPointerDown = (e) => { if (stage !== "sleeve") return; startVinylDrag(e, "sleeve"); }; // Pull the record OFF the platter back into the sleeve. Only allowed when // loaded and the tonearm is parked (stage !== "playing"). const onDiscPointerDown = (e) => { if (stage !== "loaded") return; startVinylDrag(e, "platter"); }; // Window-level listeners while dragging vinyl useEffect(() => { if (!dragging) return; const onMove = (e) => { if (!dragRef.current.active || !stageAreaRef.current) return; const r = stageAreaRef.current.getBoundingClientRect(); setVinylPos({ x: e.clientX - r.left, y: e.clientY - r.top }); }; const onUp = (e) => { if (!dragRef.current.active) return; const source = dragRef.current.source; dragRef.current = { active: false, source: null }; const platter = platterRef.current?.getBoundingClientRect(); const sleeveRect = sleeveOnStageRef.current?.getBoundingClientRect(); setVinylPos(null); setDragSource(null); // Drop near platter → vinyl ends up on the platter (loaded). if (platter) { const cx = platter.left + platter.width / 2; const cy = platter.top + platter.height / 2; const dist = Math.hypot(e.clientX - cx, e.clientY - cy); if (dist < platter.width * 0.6) { setStage("loaded"); return; } } // Drop over the sleeve → vinyl returns to the sleeve. Only meaningful // when the source was the platter — sleeve→sleeve is a no-op anyway. if (sleeveRect && source === "platter") { const sx = sleeveRect.left + sleeveRect.width / 2; const sy = sleeveRect.top + sleeveRect.height / 2; const inside = e.clientX >= sleeveRect.left && e.clientX <= sleeveRect.right && e.clientY >= sleeveRect.top && e.clientY <= sleeveRect.bottom; const near = Math.hypot(e.clientX - sx, e.clientY - sy) < sleeveRect.width * 0.9; if (inside || near) { setStage("sleeve"); return; } } // Otherwise: no state change — the vinyl visually springs back to wherever it came from. }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, [dragging]); // ── Tonearm drag (window-level) ── // We capture the offset between (current arm angle) and (pointer angle relative to pivot) // at pointerdown, so the head follows the pointer 1:1 instead of snapping to it. const armOffsetRef = useRef(0); const computePivot = () => { const p = platterRef.current?.getBoundingClientRect(); if (!p) return null; const tonearmH = p.width * 0.46 / 0.7; // aspect-ratio: 0.7 // Tonearm CSS: top: -4%; right: 4%; transform-origin: 100% 14% return { x: p.right - p.width * 0.04, y: p.top - p.height * 0.04 + tonearmH * 0.14, }; }; const onArmPointerDown = (e) => { if (stage === "sleeve") return; e.preventDefault(); e.stopPropagation(); const piv = computePivot(); if (piv) { const pointerDeg = Math.atan2(e.clientY - piv.y, e.clientX - piv.x) * 180 / Math.PI - 90; armOffsetRef.current = armAngle - pointerDeg; } else { armOffsetRef.current = 0; } armDragRef.current = true; setArmDragging(true); }; useEffect(() => { if (!armDragging) return; const onMove = (e) => { if (!armDragRef.current) return; e.preventDefault(); const piv = computePivot(); if (!piv) return; const dx = e.clientX - piv.x; const dy = e.clientY - piv.y; if (Math.abs(dx) < 2 && Math.abs(dy) < 2) return; let deg = Math.atan2(dy, dx) * 180 / Math.PI - 90 + armOffsetRef.current; if (!Number.isFinite(deg)) return; deg = Math.max(-30, Math.min(4, deg)); setArmAngle(deg); }; const onUp = () => { armDragRef.current = false; setArmDragging(false); // Snap based on final angle setArmAngle(prev => { if (prev > -16) { // dropped on the record setStage(s => { if (s === "sleeve") return s; if (s !== "playing") playSFX("needle-down"); return "playing"; }); return -6; } else { setStage(s => { if (s === "playing") { playSFX("needle-up"); return "loaded"; } return s; }); return -26; } }); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, [armDragging]); const showSleeve = stage === "sleeve"; const showLoaded = stage !== "sleeve"; const isPlaying = stage === "playing"; // Synthesized warm vinyl tone via Web Audio — plays softly while needle is on the record. // Real audio playback via HTMLAudioElement using assets/audio/track-{id}.mp3. // The audio element lives for as long as the track does — pulling the // tonearm off pauses rather than tearing it down, so position is preserved. const audioRef = useRef(null); const realDurationRef = useRef(0); // Create / dispose audio when the track changes useEffect(() => { if (!q) return; const audioSrc = `assets/audio/track-${q.id}.mp3`; const a = new Audio(audioSrc); a.volume = 0; audioRef.current = a; realDurationRef.current = 0; const updateProgress = () => { const d = realDurationRef.current || (isFinite(a.duration) && a.duration > 0 ? a.duration : 0); if (d > 0) { setProgress(Math.min(1, a.currentTime / d)); setDurationSec(prev => (prev !== d ? d : prev)); } }; // VBR scan on a side channel — doesn't interfere with playback element const scan = new Audio(); scan.preload = "metadata"; const tryReadScan = () => { const d = scan.duration; if (isFinite(d) && d > 0) { realDurationRef.current = d; setDurationSec(prev => (prev !== d ? d : prev)); return true; } return false; }; scan.addEventListener("loadedmetadata", () => { if (!tryReadScan()) { try { scan.currentTime = 1e101; scan.addEventListener("timeupdate", function once() { scan.removeEventListener("timeupdate", once); tryReadScan(); }); } catch (e) {} } }); scan.addEventListener("durationchange", tryReadScan); scan.src = audioSrc; const onEnded = () => { onAdvance && onAdvance(); }; a.addEventListener("ended", onEnded); a.addEventListener("timeupdate", updateProgress); a.addEventListener("durationchange", updateProgress); return () => { a.removeEventListener("ended", onEnded); a.removeEventListener("timeupdate", updateProgress); a.removeEventListener("durationchange", updateProgress); try { a.pause(); } catch (e) {} try { a.src = ""; } catch (e) {} try { scan.src = ""; } catch (e) {} audioRef.current = null; }; }, [q && q.id]); // Play / pause based on isPlaying — preserves currentTime so lifting the arm pauses. useEffect(() => { const a = audioRef.current; if (!a) return; let cancelled = false; let fadeTimer; const fade = (toVol, dur = 250) => { if (fadeTimer) clearInterval(fadeTimer); const from = a.volume; const steps = 10; const dv = (toVol - from) / steps; let i = 0; fadeTimer = setInterval(() => { i++; a.volume = Math.max(0, Math.min(1, from + dv * i)); if (i >= steps) { clearInterval(fadeTimer); fadeTimer = null; } }, dur / steps); }; if (isPlaying) { a.play().then(() => { if (!cancelled) fade(1.0); }).catch((e) => { console.warn("Audio play blocked or failed:", e); }); } else { fade(0, 200); // pause after fade completes const t = setTimeout(() => { try { a.pause(); } catch (e) {} }, 220); return () => { cancelled = true; clearTimeout(t); if (fadeTimer) clearInterval(fadeTimer); }; } return () => { cancelled = true; if (fadeTimer) clearInterval(fadeTimer); }; }, [isPlaying]); // Reset progress whenever the track changes useEffect(() => { setProgress(0); setDurationSec(0); }, [q && q.id]); // Close the cover lightbox on Escape useEffect(() => { if (!coverOpen) return; const onKey = (e) => { if (e.key === "Escape") setCoverOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [coverOpen]); // Progress is driven by the real audio's timeupdate event (see audio useEffect above). // No synthetic timer needed. return (
Track {q.n} · Side {q.side}
{stage === "sleeve" && <>Slide the record out and onto the turntable.} {stage === "loaded" && <>Now drop the needle to start playing.} {stage === "playing" && <>Playing {q.title}}
{/* Stage area — sleeve on the left, turntable on the right */}
{/* Sleeve */}
{/* Vinyl peeks BEHIND the sleeve-art */} {showSleeve && !dragging && (
)}
setCoverOpen(true)} role="button" tabIndex={0} aria-label={`Enlarge cover for ${q.title}`} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setCoverOpen(true); } }} > { e.currentTarget.style.display = 'none'; }} />
SIDE {q.side}
{q.title}
{/* Turntable */}
{!showLoaded &&
Drop the
record
}
{showLoaded && !(dragging && dragSource === "platter") && (
{ e.currentTarget.style.display = 'none'; }} />
Track {q.n} · Side {q.side}
{q.title}
)}
{/* Tonearm — draggable */}
{/* Floating vinyl while dragging — absolutely positioned in stage */} {dragging && vinylPos && (
)}
{/* Track meta + status */}
{q.title}
{q.voiced && (
{q.bonus ? q.voiced : `Voiced by ${q.voiced}`}
)}
{/* Progress bar — always visible; thumb + live times appear when loaded */} {(() => { const fmt = (sec) => { if (!isFinite(sec) || sec < 0) return "—:—"; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60).toString().padStart(2, "0"); return `${m}:${s}`; }; const elapsedSec = durationSec > 0 ? progress * durationSec : 0; const remainingSec = durationSec > 0 ? Math.max(0, durationSec - elapsedSec) : 0; const showTimes = showLoaded && durationSec > 0; return (
{showTimes ? fmt(elapsedSec) : "—:—"}
{ const a = audioRef.current; const d = realDurationRef.current || (a && isFinite(a.duration) && a.duration > 0 ? a.duration : 0); if (!a || d <= 0) return; const bar = e.currentTarget; bar.setPointerCapture(e.pointerId); const seek = (clientX) => { const r = bar.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); const prev = a.currentTime; const next = ratio * d; try { a.currentTime = next; } catch (err) {} setProgress(ratio); if (Math.abs(next - prev) > 0.4) { playSFX("scrub", next > prev ? 1 : -1); } }; seek(e.clientX); const onMove = (ev) => seek(ev.clientX); const onUp = (ev) => { bar.removeEventListener("pointermove", onMove); bar.removeEventListener("pointerup", onUp); bar.removeEventListener("pointercancel", onUp); try { bar.releasePointerCapture(ev.pointerId); } catch (err) {} }; bar.addEventListener("pointermove", onMove); bar.addEventListener("pointerup", onUp); bar.addEventListener("pointercancel", onUp); }} >
{showLoaded && (
)}
{showTimes ? `-${fmt(remainingSec)}` : "—:—"}
); })()}
{!q.bonus && }
{/* Cover-art lightbox */} {coverOpen && (
setCoverOpen(false)}>
e.stopPropagation()} role="dialog" aria-label={`Cover for ${q.title}`} > {q.title} { e.currentTarget.style.display = 'none'; }} />
Track {q.n} · Side {q.side}
{q.title}
)}
); } // ───────────────────────────── Question screen ───────────────────────────── function QuestionScreen({ q, picked, onAnswer, onContinue, isLast }) { const correct = picked === q.correct; return (
Your answer

What do you remember?

{picked != null && (
{correct ? "Of course you knew." : "Close. Not quite."}
{correct ? q.rightNote : q.wrongNote}
{correct && (
+5 music notes added to your stash.
)}
)} {picked != null && (
)}
); } // ───────────────────────────── Shop ───────────────────────────── function Shop({ notes, purchased, redeemed = {}, allPlayed, bonusUnlocked, onBuy, onBack, onPlayAgain, onRedeem, onBonus, momName }) { const ownedCount = Object.keys(purchased).length; return (
Side B · The Shop

Cash in, {momName}

Each certificate is a real, redeemable promise from me.

♪ {notes}
Notes in the bank
{allPlayed ? <>You played all 5 records. : <>Earn 5 more by playing the next track or play again to try again.}
{SHOP_ITEMS.map(item => ( = item.cost} onBuy={() => onBuy(item)} /> ))}
{bonusUnlocked ? "Bonus tracks · unlocked" : "Bonus tracks · locked"}

{bonusUnlocked ? "There are some hidden tracks on Side B." : "Get every question right to unlock the bonus tracks."}

{bonusUnlocked ? "Pulled from the cutting-room floor just for you." : "Replay to try again. The bonus side opens when the records are perfect."}

{bonusUnlocked ? : }
Mom Certs:

"For the greatest mom to walk planet Earth."

You've pressed {ownedCount} certificate{ownedCount === 1 ? "" : "s"}. I will honor any certificate presented to me.

{ownedCount > 0 && ( )} {!allPlayed && } {allPlayed && }
Pressed with love · May 2026
Elijah · Lorelei · Jackson · Brandon
); } // ───────────────────────────── First-visit greeting popup ───────────────────────────── // Gates the welcome screen on the very first visit. Dismissing it auto-plays // assets/audio/greeting.mp3. Backdrop clicks are intentionally ignored — the // user has to acknowledge with the "I'm Ready!" button. function GreetingPopup({ onReady }) { return (
e.stopPropagation()}>
Before we start

Turn up your volume.

For the best experience, grab some headphones.

Are you ready?

); } // ───────────────────────────── Buy Confirmation Modal ───────────────────────────── // Shown BEFORE a purchase is finalized — gives the user a chance to back out // before notes are deducted and the kerching SFX fires. function BuyConfirm({ item, notes, onConfirm, onCancel, momName }) { const remaining = Math.max(0, notes - item.cost); return (
e.stopPropagation()}>
Press this certificate?
{item.glyph}

{item.title}

{item.serif}

{item.blurb}

Cost ♪ {item.cost} note{item.cost === 1 ? "" : "s"}
After ♪ {remaining} note{remaining === 1 ? "" : "s"}
); } // ───────────────────────────── Confirm Purchase Modal ───────────────────────────── function ConfirmPurchase({ item, onClose, momName }) { return (
e.stopPropagation()}>
Certificate pressed
{item.glyph}

{item.title}

{item.serif}

Redeemable by {momName}, anytime. Signed: Brandon

); } // ───────────────────────────── Redeem List Screen ───────────────────────────── function RedeemList({ purchased, redeemed, onPick, onBack }) { const owned = SHOP_ITEMS.filter(item => purchased[item.id]); const readyCount = owned.filter(item => !redeemed[item.id]).length; return (
Cash in · Redeem

Your certificates.

{owned.length === 0 ? "Nothing pressed yet. Earn notes and buy a certificate first." : readyCount > 0 ? `${readyCount} ready to redeem. Tap one to call it in.` : "Every certificate has been redeemed. Thanks, Mom."}

{owned.length === 0 ? (

The shelf is empty.

Head back to the shop, spend some notes, then come back here.

) : (
{owned.map(item => { const isRedeemed = !!redeemed[item.id]; return ( ); })}
)} {owned.length > 0 && (
Tap a card · I'll handle the rest
)}
); } // ───────────────────────────── Redeem Confirm Modal ───────────────────────────── function RedeemConfirm({ item, redeemed, onRedeem, onClose, momName }) { return (
e.stopPropagation()}>
{redeemed ? "Already redeemed" : "Ready to redeem?"}
{item.glyph}

{item.title}

{item.serif}

{item.blurb}

Redeemable by {momName}. Signed: Brandon

{redeemed ? ( ) : ( <> )}
); } ReactDOM.createRoot(document.getElementById("root")).render();