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 …'; 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 ⌁' : 'try the reading again ↻'; 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 = '
⌁

There’s nothing to decide yet.

The answer only appears when the apology has been made with care.
'; $('#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 = `

for him, with no trapdoor

${safeName}, she brought
the honest version.

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.

Either answer can be kind. The kindest one is the true one.

`; $('#decision').classList.add('is-open'); setTimeout(() => scrollToElement($('#decision')), 120); $('#acceptButton').addEventListener('click', () => { decisionContent.innerHTML = `
♥

a soft yes

Okay. Let’s try
the real way.

Not back to before. Forward, with the promises visible and the door unlocked from both sides.

— a second chance is a new chapter
`; decisionStatus.textContent = 'a new chapter'; burstHearts(); $('#resetButton').addEventListener('click', () => window.location.reload()); }); $('#declineButton').addEventListener('click', () => { decisionContent.innerHTML = `
♡

a clear answer

Not now is still
an honest answer.

Thank you for not turning a tender moment into a performance. Some doors need more time. Some need a kind goodbye.

— take care of the truth
`; 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();