Spaces:
Running
Running
Download src/main.js from Samfredoly/TOUSE: direct link, hf CLI and curl.
- Browser
- Download file 23 kB
-
https://huggingface.co/spaces/Samfredoly/TOUSE/resolve/main/src/main.js
- Command line
-
hf download hf://spaces/Samfredoly/TOUSE/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/Samfredoly/TOUSE/resolve/main/src/main.js
23 kB
| import './styles.css'; | |
| const state = { | |
| promises: [], | |
| name: '', | |
| apology: '', | |
| consent: false, | |
| sincerity: null, | |
| sincerityPending: false, | |
| submissionId: '', | |
| sealed: false, | |
| ownerUnlocked: false, | |
| unlocked: false, | |
| sound: false, | |
| music: false, | |
| }; | |
| const $ = (selector) => document.querySelector(selector); | |
| const $$ = (selector) => [...document.querySelectorAll(selector)]; | |
| const promiseNotes = $('#promiseNotes'); | |
| const bouquet = $('#bouquet'); | |
| const selectedCount = $('#selectedCount'); | |
| const nameInput = $('#herName'); | |
| const apologyInput = $('#apologyText'); | |
| const consentCheck = $('#consentCheck'); | |
| const rateButton = $('#rateButton'); | |
| const sincerityState = $('#sincerityState'); | |
| const sincerityResult = $('#sincerityResult'); | |
| const unlockButton = $('#unlockButton'); | |
| const unlockHint = $('#unlockHint'); | |
| const decisionContent = $('#decisionContent'); | |
| const decisionStatus = $('#decisionStatus'); | |
| const toast = $('#toast'); | |
| const journeySteps = $$('.journey-step'); | |
| const journeySections = $$('[data-journey-section]'); | |
| const journeyProgress = $('#journeyProgress'); | |
| const heroArtWrap = $('.hero-art-wrap'); | |
| const heroArtCard = $('.hero-art-card'); | |
| const backgroundMusic = $('#backgroundMusic'); | |
| const musicToggle = $('.music-toggle'); | |
| const journeyLabels = ['notice the gap', 'rebuild the bridge', 'make it real', 'leave room']; | |
| function setMusicState(isPlaying, blocked = false) { | |
| state.music = isPlaying; | |
| if (!musicToggle) return; | |
| musicToggle.setAttribute('aria-pressed', String(isPlaying)); | |
| musicToggle.setAttribute('aria-label', isPlaying ? 'Pause background music' : 'Play background music'); | |
| musicToggle.querySelector('span:last-child').textContent = isPlaying ? 'music on' : blocked ? 'tap for music' : 'music off'; | |
| } | |
| async function startMusic(showBlocked = true) { | |
| if (!backgroundMusic) return false; | |
| try { | |
| backgroundMusic.volume = 0.42; | |
| await backgroundMusic.play(); | |
| setMusicState(true); | |
| return true; | |
| } catch { | |
| setMusicState(false, showBlocked); | |
| return false; | |
| } | |
| } | |
| musicToggle?.addEventListener('click', async () => { | |
| if (state.music) { | |
| backgroundMusic.pause(); | |
| setMusicState(false); | |
| showToast('The room is quiet again.'); | |
| } else if (await startMusic(false)) { | |
| showToast('The room has a soundtrack now.'); | |
| } | |
| }); | |
| // Try audible autoplay first. If the browser blocks it, the first gesture unlocks music. | |
| startMusic(); | |
| window.addEventListener('pointerdown', () => { | |
| if (!state.music) startMusic(false); | |
| }, { once: true, passive: true }); | |
| function showToast(message) { | |
| toast.textContent = message; | |
| toast.classList.add('is-visible'); | |
| window.clearTimeout(showToast.timer); | |
| showToast.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); | |
| } | |
| function setAnswerFieldsDisabled(disabled) { | |
| nameInput.disabled = disabled; | |
| apologyInput.disabled = disabled; | |
| consentCheck.disabled = disabled; | |
| $$('#promiseNotes button').forEach((button) => { button.disabled = disabled; }); | |
| } | |
| function setJourneyStep(step) { | |
| const index = Math.max(0, Math.min(journeySteps.length - 1, step)); | |
| journeySteps.forEach((item, itemIndex) => item.classList.toggle('is-current', itemIndex === index)); | |
| journeyProgress.style.width = `${((index + 1) / journeySteps.length) * 100}%`; | |
| const chapterLabel = $('.chapter-label'); | |
| if (chapterLabel) chapterLabel.textContent = journeyLabels[index]; | |
| document.documentElement.style.setProperty('--journey-step', index + 1); | |
| } | |
| function scrollToElement(element, block = 'start') { | |
| element?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block }); | |
| } | |
| journeySteps.forEach((step) => { | |
| step.addEventListener('click', () => { | |
| const target = $(step.dataset.journeyTarget); | |
| if (!target) return; | |
| target.classList.remove('journey-pulse'); | |
| requestAnimationFrame(() => target.classList.add('journey-pulse')); | |
| scrollToElement(target); | |
| window.setTimeout(() => target.classList.remove('journey-pulse'), 900); | |
| }); | |
| }); | |
| const journeyObserver = new IntersectionObserver((entries) => { | |
| entries.forEach((entry) => { | |
| if (entry.isIntersecting) setJourneyStep(Number(entry.target.dataset.journeySection) - 1); | |
| }); | |
| }, { rootMargin: '-28% 0px -54% 0px', threshold: 0 }); | |
| journeySections.forEach((section) => journeyObserver.observe(section)); | |
| setJourneyStep(0); | |
| if (heroArtWrap && heroArtCard && window.matchMedia('(pointer: fine)').matches) { | |
| heroArtWrap.addEventListener('pointermove', (event) => { | |
| const rect = heroArtWrap.getBoundingClientRect(); | |
| const x = ((event.clientX - rect.left) / rect.width - .5) * 2; | |
| const y = ((event.clientY - rect.top) / rect.height - .5) * 2; | |
| heroArtCard.style.setProperty('--tilt-y', `${x * 4}deg`); | |
| heroArtCard.style.setProperty('--tilt-x', `${-y * 4}deg`); | |
| }); | |
| heroArtWrap.addEventListener('pointerleave', () => { | |
| heroArtCard.style.setProperty('--tilt-y', '0deg'); | |
| heroArtCard.style.setProperty('--tilt-x', '0deg'); | |
| }); | |
| } | |
| function renderBouquet() { | |
| selectedCount.textContent = state.promises.length; | |
| bouquet.replaceChildren(); | |
| if (!state.promises.length) { | |
| const empty = document.createElement('span'); | |
| empty.className = 'bouquet-empty'; | |
| empty.textContent = 'Your honest ones will land here.'; | |
| bouquet.appendChild(empty); | |
| } else { | |
| state.promises.forEach((promise, index) => { | |
| const pill = document.createElement('span'); | |
| pill.className = 'bouquet-pill'; | |
| const number = document.createElement('i'); | |
| number.textContent = String(index + 1).padStart(2, '0'); | |
| pill.append(number, document.createTextNode(promise)); | |
| bouquet.appendChild(pill); | |
| }); | |
| } | |
| $$('.promise-note[data-promise]').forEach((note) => { | |
| const selected = state.promises.includes(note.dataset.promise); | |
| note.classList.toggle('is-selected', selected); | |
| note.querySelector('.note-check').textContent = selected ? '✓' : '○'; | |
| }); | |
| updateUnlockState(); | |
| } | |
| function updateUnlockState() { | |
| state.name = nameInput.value.trim(); | |
| state.apology = apologyInput.value.trim(); | |
| state.consent = consentCheck.checked; | |
| if (state.sealed && !state.ownerUnlocked) { | |
| rateButton.disabled = true; | |
| unlockButton.disabled = true; | |
| unlockHint.textContent = 'Submitted. The bridge is sealed until the owner opens it.'; | |
| unlockHint.classList.remove('ready'); | |
| if (state.unlocked) relockDecision(); | |
| return; | |
| } | |
| const hasAnswered = state.promises.length >= 3 && state.name.length > 1 && state.apology.length >= 20 && state.consent; | |
| rateButton.disabled = !hasAnswered || state.sincerityPending || state.sincerity !== null; | |
| const ready = hasAnswered && state.sincerity !== null && state.ownerUnlocked; | |
| unlockButton.disabled = !ready; | |
| if (ready) { | |
| unlockHint.textContent = 'That’s everything. The room is ready for his answer.'; | |
| unlockHint.classList.add('ready'); | |
| } else { | |
| const missing = []; | |
| if (state.promises.length < 3) missing.push(`${3 - state.promises.length} more promise${3 - state.promises.length === 1 ? '' : 's'}`); | |
| if (state.name.length <= 1) missing.push('your name'); | |
| if (state.apology.length < 20) missing.push('the hard part in your own words'); | |
| if (!state.consent) missing.push('the brave little box'); | |
| if (hasAnswered && state.sincerity === null) missing.push('the sincerity reading'); | |
| if (state.sincerity !== null && !state.ownerUnlocked) missing.push('the private owner key'); | |
| unlockHint.textContent = `Still needed: ${missing.join(' · ')}.`; | |
| unlockHint.classList.remove('ready'); | |
| } | |
| if (!ready && state.unlocked) relockDecision(); | |
| } | |
| function setResultText(element, text) { | |
| element.textContent = text; | |
| } | |
| function invalidateSincerity() { | |
| if (!state.sincerity && !state.ownerUnlocked) return; | |
| state.sincerity = null; | |
| state.submissionId = ''; | |
| state.sealed = false; | |
| state.ownerUnlocked = false; | |
| setAnswerFieldsDisabled(false); | |
| sincerityResult.replaceChildren(); | |
| sincerityState.textContent = 'waiting for the whole answer'; | |
| sincerityState.classList.remove('has-result'); | |
| $('#name').classList.remove('is-sealed'); | |
| $('#promises').classList.remove('is-sealed'); | |
| } | |
| function renderOwnerLock() { | |
| sincerityResult.replaceChildren(); | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'private-lock reveal-in'; | |
| const title = document.createElement('strong'); | |
| title.textContent = 'sealed for his eyes'; | |
| const copy = document.createElement('p'); | |
| copy.textContent = 'Her exact words and the sincerity read are tucked away. Enter the owner key to view the answer.'; | |
| const row = document.createElement('div'); | |
| row.className = 'private-key-row'; | |
| const input = document.createElement('input'); | |
| input.id = 'ownerPassword'; | |
| input.type = 'password'; | |
| input.autocomplete = 'current-password'; | |
| input.placeholder = 'owner key'; | |
| input.maxLength = 200; | |
| const button = document.createElement('button'); | |
| button.id = 'ownerUnlockButton'; | |
| button.className = 'button button-dark'; | |
| button.type = 'button'; | |
| button.textContent = 'open private room'; | |
| row.append(input, button); | |
| const status = document.createElement('span'); | |
| status.id = 'ownerUnlockStatus'; | |
| status.className = 'private-key-status'; | |
| status.textContent = 'only the owner key can open this'; | |
| wrap.append(title, copy, row, status); | |
| sincerityResult.appendChild(wrap); | |
| sincerityState.textContent = 'submitted / waiting for his key'; | |
| sincerityState.classList.add('has-result'); | |
| } | |
| function appendPrivateAnswer(parent, label, value) { | |
| const block = document.createElement('div'); | |
| block.className = 'private-answer'; | |
| const heading = document.createElement('span'); | |
| heading.className = 'private-answer-label'; | |
| heading.textContent = label; | |
| const text = document.createElement('p'); | |
| text.textContent = value; | |
| block.append(heading, text); | |
| parent.appendChild(block); | |
| } | |
| function renderSincerity(result) { | |
| sincerityResult.replaceChildren(); | |
| if (!state.ownerUnlocked) { | |
| renderOwnerLock(); | |
| return; | |
| } | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'sincerity-reading reveal-in'; | |
| const score = document.createElement('div'); | |
| score.className = 'sincerity-score'; | |
| setResultText(score, `${result.score}`); | |
| const outOf = document.createElement('span'); | |
| outOf.textContent = '/100'; | |
| score.appendChild(outOf); | |
| const body = document.createElement('div'); | |
| body.className = 'sincerity-body'; | |
| const label = document.createElement('strong'); | |
| setResultText(label, result.label); | |
| const read = document.createElement('p'); | |
| setResultText(read, result.read); | |
| const signal = document.createElement('span'); | |
| signal.className = 'sincerity-signal'; | |
| setResultText(signal, `care signal: ${result.careSignal}`); | |
| const source = document.createElement('span'); | |
| source.className = 'sincerity-source'; | |
| setResultText(source, result.source === 'model' ? 'model impression · still not a lie detector' : 'offline fallback · provider did not return a model read'); | |
| body.append(label, read, signal, source); | |
| wrap.append(score, body); | |
| sincerityResult.appendChild(wrap); | |
| const dossier = document.createElement('div'); | |
| dossier.className = 'private-dossier reveal-in'; | |
| const dossierTitle = document.createElement('span'); | |
| dossierTitle.className = 'mini-kicker'; | |
| dossierTitle.textContent = 'her answers / exactly as submitted'; | |
| dossier.appendChild(dossierTitle); | |
| appendPrivateAnswer(dossier, 'name', state.name); | |
| appendPrivateAnswer(dossier, 'the hard part', state.apology); | |
| appendPrivateAnswer(dossier, 'promises', state.promises.join(' · ')); | |
| sincerityResult.appendChild(dossier); | |
| sincerityState.textContent = result.source === 'model' ? `${result.confidence} confidence / keep talking` : 'offline draft / keep talking'; | |
| sincerityState.classList.add('has-result'); | |
| } | |
| async function requestSincerity() { | |
| if (rateButton.disabled || state.sincerityPending) return; | |
| state.sincerityPending = true; | |
| rateButton.disabled = true; | |
| setAnswerFieldsDisabled(true); | |
| rateButton.innerHTML = 'reading the shape of it <span class="button-arrow">…</span>'; | |
| sincerityState.textContent = 'listening between the lines'; | |
| try { | |
| const response = await fetch('/api/sincerity', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name: state.name, promises: state.promises, apology: state.apology, consent: state.consent }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || 'The reading did not arrive.'); | |
| state.sincerity = { sealed: true }; | |
| state.submissionId = payload.submissionId; | |
| state.sealed = true; | |
| state.ownerUnlocked = false; | |
| $('#name').classList.add('is-sealed'); | |
| $('#promises').classList.add('is-sealed'); | |
| state.name = ''; | |
| state.promises = []; | |
| state.apology = ''; | |
| state.consent = false; | |
| nameInput.value = ''; | |
| apologyInput.value = ''; | |
| consentCheck.checked = false; | |
| renderBouquet(); | |
| renderSincerity(payload); | |
| showToast('The mirror has said its imperfect piece.'); | |
| } catch (error) { | |
| state.sincerity = null; | |
| state.submissionId = ''; | |
| setAnswerFieldsDisabled(false); | |
| sincerityState.textContent = 'the mirror went a little foggy'; | |
| showToast(error.message || 'Could not read the room.'); | |
| } finally { | |
| state.sincerityPending = false; | |
| rateButton.innerHTML = state.sincerity ? 'sealed for the owner <span class="button-arrow">⌁</span>' : 'try the reading again <span class="button-arrow">↻</span>'; | |
| updateUnlockState(); | |
| } | |
| } | |
| async function restorePersistedAnswer() { | |
| try { | |
| const response = await fetch('/api/latest-submission', { cache: 'no-store' }); | |
| if (!response.ok) return; | |
| const payload = await response.json(); | |
| if (!payload.available || !payload.submissionId) return; | |
| state.submissionId = payload.submissionId; | |
| state.sincerity = { sealed: true }; | |
| state.sealed = true; | |
| state.ownerUnlocked = false; | |
| setAnswerFieldsDisabled(true); | |
| $('#name').classList.add('is-sealed'); | |
| $('#promises').classList.add('is-sealed'); | |
| renderSincerity(state.sincerity); | |
| updateUnlockState(); | |
| } catch { | |
| // A temporary discovery failure should not prevent a new answer from being written. | |
| } | |
| } | |
| function relockDecision() { | |
| state.unlocked = false; | |
| decisionStatus.textContent = 'locked until it’s honest'; | |
| decisionStatus.classList.remove('is-open'); | |
| decisionContent.innerHTML = '<div class="decision-lock"><span>⌁</span><p>There’s nothing to decide yet.</p><small>The answer only appears when the apology has been made with care.</small></div>'; | |
| $('#decision').classList.remove('is-open'); | |
| } | |
| function addPromise(promise, noteEl) { | |
| if (!promise) return; | |
| if (state.promises.includes(promise)) { | |
| state.promises = state.promises.filter((item) => item !== promise); | |
| showToast('Promise tucked away for now.'); | |
| } else if (state.promises.length >= 3) { | |
| showToast('Three honest promises are enough for one heart.'); | |
| return; | |
| } else { | |
| state.promises.push(promise); | |
| showToast('That one feels worth keeping.'); | |
| } | |
| if (noteEl) noteEl.classList.add('pop'); | |
| invalidateSincerity(); | |
| renderBouquet(); | |
| if (state.promises.length === 3) { | |
| showToast('The bridge has three planks. Keep going.'); | |
| window.setTimeout(() => scrollToElement($('#name'), 'center'), 420); | |
| } | |
| } | |
| promiseNotes.addEventListener('click', (event) => { | |
| const note = event.target.closest('.promise-note'); | |
| if (!note) return; | |
| if (note.id === 'addPromise') { | |
| const custom = window.prompt('Write one promise in your own words:'); | |
| if (custom?.trim()) { | |
| const value = custom.trim().slice(0, 90); | |
| if (!state.promises.includes(value) && state.promises.length < 3) { | |
| state.promises.push(value); | |
| invalidateSincerity(); | |
| renderBouquet(); | |
| showToast('Your words are the ones that matter most.'); | |
| if (state.promises.length === 3) window.setTimeout(() => scrollToElement($('#name'), 'center'), 420); | |
| } else if (state.promises.length >= 3) { | |
| showToast('You already have three. Keep them close.'); | |
| } | |
| } | |
| return; | |
| } | |
| addPromise(note.dataset.promise, note); | |
| }); | |
| nameInput.addEventListener('input', () => { | |
| invalidateSincerity(); | |
| const name = nameInput.value.trim(); | |
| $('#nameHint').textContent = name ? `Okay, ${name}. That makes it real.` : 'A name makes a promise feel held.'; | |
| updateUnlockState(); | |
| }); | |
| apologyInput.addEventListener('input', () => { | |
| const length = apologyInput.value.trim().length; | |
| $('#answerHint').textContent = length ? `${length}/20 minimum — specificity is a form of care.` : 'Say what changed, not only what you miss.'; | |
| invalidateSincerity(); | |
| updateUnlockState(); | |
| }); | |
| consentCheck.addEventListener('change', () => { invalidateSincerity(); updateUnlockState(); }); | |
| rateButton.addEventListener('click', requestSincerity); | |
| sincerityResult.addEventListener('click', async (event) => { | |
| const button = event.target.closest('#ownerUnlockButton'); | |
| if (!button) return; | |
| const input = $('#ownerPassword'); | |
| const status = $('#ownerUnlockStatus'); | |
| button.disabled = true; | |
| status.textContent = 'checking the key…'; | |
| try { | |
| const response = await fetch('/api/owner-unlock', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password: input.value, submissionId: state.submissionId }), | |
| }); | |
| const payload = await response.json(); | |
| if (!response.ok) throw new Error(payload.error || 'That key did not work.'); | |
| const submission = payload.submission; | |
| state.name = submission.name; | |
| state.promises = submission.promises; | |
| state.apology = submission.apology; | |
| state.sealed = false; | |
| nameInput.value = state.name; | |
| apologyInput.value = state.apology; | |
| state.sincerity = submission.reading; | |
| state.ownerUnlocked = true; | |
| consentCheck.checked = true; | |
| setAnswerFieldsDisabled(true); | |
| renderBouquet(); | |
| renderSincerity(state.sincerity); | |
| updateUnlockState(); | |
| showToast('Private room open. The answer is yours to hold.'); | |
| } catch (error) { | |
| status.textContent = error.message || 'That key did not work.'; | |
| input.select(); | |
| button.disabled = false; | |
| } | |
| }); | |
| $$('[data-scroll]').forEach((button) => { | |
| button.addEventListener('click', () => scrollToElement($(button.dataset.scroll), 'center')); | |
| }); | |
| unlockButton.addEventListener('click', () => { | |
| if (unlockButton.disabled) return; | |
| state.unlocked = true; | |
| decisionStatus.textContent = 'the room is open'; | |
| decisionStatus.classList.add('is-open'); | |
| const safeName = state.name.replace(/[<>]/g, ''); | |
| decisionContent.innerHTML = ` | |
| <div class="decision-open reveal-in"> | |
| <p class="eyebrow"><span class="eyebrow-dot coral"></span> for him, with no trapdoor</p> | |
| <h2>${safeName}, she brought<br /><em>the honest version.</em></h2> | |
| <p class="decision-lede">She named what she’d do differently, signed it with her own name, and said she’ll respect your answer. You don’t owe a yes because someone finally said the right words.</p> | |
| <div class="decision-actions"> | |
| <button class="button button-dark" type="button" id="acceptButton">accept the second chance <span class="button-arrow">↗</span></button> | |
| <button class="button button-outline" type="button" id="declineButton">not yet / not this time</button> | |
| </div> | |
| <p class="decision-note">Either answer can be kind. The kindest one is the true one.</p> | |
| </div>`; | |
| $('#decision').classList.add('is-open'); | |
| setTimeout(() => scrollToElement($('#decision')), 120); | |
| $('#acceptButton').addEventListener('click', () => { | |
| decisionContent.innerHTML = `<div class="decision-result accepted reveal-in"><div class="result-heart">♥</div><p class="eyebrow">a soft yes</p><h2>Okay. Let’s try<br /><em>the real way.</em></h2><p>Not back to before. Forward, with the promises visible and the door unlocked from both sides.</p><div class="tiny-signoff">— a second chance is a new chapter</div><button class="text-button" type="button" id="resetButton">read it again <span>↻</span></button></div>`; | |
| decisionStatus.textContent = 'a new chapter'; | |
| burstHearts(); | |
| $('#resetButton').addEventListener('click', () => window.location.reload()); | |
| }); | |
| $('#declineButton').addEventListener('click', () => { | |
| decisionContent.innerHTML = `<div class="decision-result declined reveal-in"><div class="result-heart muted">♡</div><p class="eyebrow">a clear answer</p><h2>Not now is still<br /><em>an honest answer.</em></h2><p>Thank you for not turning a tender moment into a performance. Some doors need more time. Some need a kind goodbye.</p><div class="tiny-signoff">— take care of the truth</div><button class="text-button" type="button" id="resetButton">read it again <span>↻</span></button></div>`; | |
| decisionStatus.textContent = 'truth, held gently'; | |
| $('#resetButton').addEventListener('click', () => window.location.reload()); | |
| }); | |
| }); | |
| function burstHearts() { | |
| const symbols = ['♥', '♡', '✦', '·', '♥', '✧']; | |
| symbols.forEach((symbol, index) => { | |
| const heart = document.createElement('span'); | |
| heart.className = 'burst-heart'; | |
| heart.textContent = symbol; | |
| heart.style.setProperty('--x', `${(Math.random() - 0.5) * 260}px`); | |
| heart.style.setProperty('--y', `${-70 - Math.random() * 160}px`); | |
| heart.style.setProperty('--delay', `${index * 70}ms`); | |
| $('#decision').appendChild(heart); | |
| setTimeout(() => heart.remove(), 1800); | |
| }); | |
| } | |
| let ticking = false; | |
| function updateParallax() { | |
| const scrollY = window.scrollY; | |
| $$('[data-parallax]').forEach((el) => { | |
| const speed = Number(el.dataset.parallax || 0.1); | |
| const rect = el.getBoundingClientRect(); | |
| const distance = rect.top + rect.height / 2 - window.innerHeight / 2; | |
| el.style.setProperty('--parallax-y', `${-distance * speed}px`); | |
| }); | |
| ticking = false; | |
| } | |
| window.addEventListener('scroll', () => { | |
| if (!ticking) { | |
| window.requestAnimationFrame(updateParallax); | |
| ticking = true; | |
| } | |
| }, { passive: true }); | |
| updateParallax(); | |
| $('.sound-toggle').addEventListener('click', (event) => { | |
| state.sound = !state.sound; | |
| event.currentTarget.setAttribute('aria-pressed', String(state.sound)); | |
| $('#app').classList.toggle('motion-muted', !state.sound); | |
| event.currentTarget.querySelector('span:last-child').textContent = state.sound ? 'motion mode' : 'still mode'; | |
| showToast(state.sound ? 'The bridge is moving again.' : 'Still mode, still romantic.'); | |
| }); | |
| const observer = new IntersectionObserver((entries) => { | |
| entries.forEach((entry) => { | |
| if (entry.isIntersecting) entry.target.classList.add('is-visible'); | |
| }); | |
| }, { threshold: 0.12 }); | |
| $$('.reveal').forEach((el) => observer.observe(el)); | |
| renderBouquet(); | |
| restorePersistedAnswer(); | |