TOUSE / src /main.js
Manus Agent
Persist and protect cross-browser answer viewing
5d4cb85
Raw History Blame Contribute Delete
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();