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.for him, with no trapdoor
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.
a soft yes
Not back to before. Forward, with the promises visible and the door unlocked from both sides.
a clear answer
Thank you for not turning a tender moment into a performance. Some doors need more time. Some need a kind goodbye.