// PAREIDOLIA — main.js // App shell: the menagerie wall (zero-GPU landing state), the capture rite, // and the post-awakening reveal. State lives here; the LIFE engine // (overlay.js) and the latency theater (seance.js) are sibling modules with // fixed contracts — we import them dynamically so the wall still renders // while they are being built in parallel (and so a broken module can never // take the whole Space down with it). // // createOverlay(containerEl, record, imgEl, opts) -> {playLine, setIdle, destroy} // runSeance(containerEl, imgUrl) -> {resolve(record), fail(msg)} import { createCaptureSession, awaken, retryAwaken, isMockMode, isBusyResult, QUOTA_MESSAGE, QUOTA_MESSAGE_CROWDED, HF_LOGIN_URL, } from "./capture.js"; // ---------------------------------------------------------------- contracts let createOverlay = null; let runSeance = null; /** Quiet stand-ins so the wall is never hostage to a sibling module. The * dev markers only paint when overlay.js is genuinely absent (parallel * build), letting fixture feature coords be verified by eye. */ function fallbackOverlay(containerEl, record, imgEl) { const marks = []; for (const f of record.features || []) { const m = document.createElement("div"); m.className = "devmark" + (f.role === "mouth" ? " devmark--mouth" : ""); const s = Math.max(0.025, f.size || 0.05); m.style.left = `${f.cx * 100}%`; m.style.top = `${f.cy * 100}%`; m.style.width = `${s * 100}%`; m.style.aspectRatio = f.role === "mouth" ? "2 / 1" : "1 / 1"; (imgEl.parentElement || containerEl).appendChild(m); marks.push(m); } return { playLine() {}, setIdle() {}, destroy() { marks.forEach((m) => m.remove()); }, }; } function fallbackSeance(containerEl) { containerEl.innerHTML = `

the medium is looking…

`; return { resolve() { containerEl.innerHTML = ""; }, fail(message) { const p = containerEl.querySelector("p"); if (p) p.textContent = message; }, }; } async function loadSiblings() { try { ({ createOverlay } = await import("./overlay.js")); } catch { createOverlay = fallbackOverlay; console.warn("overlay.js absent — dev markers active"); } try { ({ runSeance } = await import("./seance.js")); } catch { runSeance = fallbackSeance; console.warn("seance.js absent — plain veil active"); } } // ---------------------------------------------------------------- elements const els = Object.fromEntries( ["wall", "empty", "count", "wakeBtn", "wakeBtnFloat", "qr", "qrCode", "toast", "captureScreen", "captureStage", "captureCancel", "revealScreen", "revealTitle", "revealPhoto", "revealImg", "seanceLayer", "revealScrim", "revealName", "revealNameText", "revealGrudge", "revealChoice", "addBtn", "restBtn", "revealFail", "revealFailText", "failBack"] .map((id) => [id, document.getElementById(id)]), ); const MOCK = isMockMode(); const state = { records: [], overlays: new Map(), // record.id -> overlay handle cardEls: new Map(), // record.id -> card element interacted: false, muttering: null, captureSession: null, pending: null, // {record, record_token, dataUrl} revealOverlay: null, busy: null, // {seance, photo, retries} while a busy/retry panel is up }; // ---------------------------------------------------------------- helpers const displayName = (r) => `the ${[r.condition, r.object].filter(Boolean).join(" ")}`.toLowerCase(); const voiceLabel = (r) => [r.persona?.archetype?.replace(/^the_/, "the "), r.persona?.voice?.replace(/_/g, " ")] .filter(Boolean).join(" · "); function audioUrlFor(record) { if (record.grudge_audio_b64) return `data:audio/wav;base64,${record.grudge_audio_b64}`; return record.audio_url || null; } // every awakened object has a door of its own: the /o/{id} share page const shareUrlFor = (record) => `${location.origin}/o/${record.id}`; /** Copy to the clipboard; async API first, execCommand séance as fallback. * Resolves true on success — never throws at the visitor. */ async function copyText(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { /* permission veil — try the old rite below */ } try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0"; document.body.appendChild(ta); ta.select(); const ok = document.execCommand("copy"); ta.remove(); return ok; } catch { return false; } } /** A quiet chain-link glyph (static path data, no record strings). */ function linkIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "1.8"); svg.setAttribute("stroke-linecap", "round"); svg.setAttribute("stroke-linejoin", "round"); svg.setAttribute("aria-hidden", "true"); for (const d of [ "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", ]) { const p = document.createElementNS("http://www.w3.org/2000/svg", "path"); p.setAttribute("d", d); svg.appendChild(p); } return svg; } let toastTimer = 0; /** Quiet bottom toast. An optional `action` ({label, onClick}) adds a single * pressable verb — used for "copy its link" after a publish. All content via * createElement/textContent; server strings never become HTML. */ function toast(message, action = null) { els.toast.textContent = message; els.toast.classList.toggle("toast--action", Boolean(action)); if (action) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "toast__action"; btn.textContent = action.label; btn.addEventListener("click", (e) => { e.stopPropagation(); action.onClick(); }); els.toast.appendChild(btn); } els.toast.classList.add("is-on"); clearTimeout(toastTimer); toastTimer = setTimeout(() => els.toast.classList.remove("is-on"), action ? 7000 : 4200); } function setCount(n) { els.count.textContent = n === 0 ? "the wall is listening" : n === 1 ? "one object is awake" : `${n} objects are awake`; } // ------------------------------------------------- patience (container naps) // The Space's ZeroGPU container recycles itself (~90s of downtime). Mid-nap, // card images die with connection resets and the wall fetch / SSE stream can // fail — and used to stay broken until a manual refresh. Everything network- // touched now retries quietly on the same short backoff; while an image is // away, a porcelain shimmer holds its seat (never the broken-image glyph). const RETRY_DELAYS = [2000, 6000, 15000]; /** Retry a card photo with backoff + cache-buster when its load fails. * Inline data:/blob: photos never travel the network — left alone. After the * last attempt the shimmer simply stays: a quiet placeholder, not an error. */ function attachImgRetry(img, url, photoEl) { if (!url || /^(data|blob):/i.test(url)) return; let attempt = 0; img.addEventListener("error", () => { photoEl.classList.add("is-waiting"); if (attempt >= RETRY_DELAYS.length) return; setTimeout(() => { if (!img.isConnected) return; // the card left the wall while we waited img.src = `${url}${url.includes("?") ? "&" : "?"}r=${Date.now()}`; }, RETRY_DELAYS[attempt++]); }); img.addEventListener("load", () => { attempt = 0; // a recovered image earns a fresh budget photoEl.classList.remove("is-waiting"); }); } // ---------------------------------------------------------------- the wall function makeCard(record, { fresh = false } = {}) { const node = document.getElementById("cardTemplate").content.firstElementChild.cloneNode(true); const img = node.querySelector("img"); attachImgRetry(img, record.image_url, node.querySelector(".card__photo")); img.src = record.image_url; img.alt = displayName(record); node.querySelector(".card__name").textContent = displayName(record); node.querySelector(".card__grudge").textContent = `“${record.lines?.grudge ?? ""}”`; node.querySelector(".card__voice").textContent = voiceLabel(record); // quiet share affordance — only records the server gave a door to (an id); // legacy fixtures without one simply keep their silence if (record.id) { const share = document.createElement("button"); share.type = "button"; share.className = "card__share"; share.title = "copy its link"; share.setAttribute("aria-label", `copy link to ${displayName(record)}`); share.appendChild(linkIcon()); share.addEventListener("click", async (e) => { e.stopPropagation(); // the icon copies; it never makes the card speak const ok = await copyText(shareUrlFor(record)); whisper(node, ok ? "link copied" : "the clipboard declined", 2400); }); node.querySelector(".card__caption").appendChild(share); } if (fresh) { node.classList.add("card--new"); setTimeout(() => node.classList.remove("card--new"), 6000); } // mount the overlay immediately — overlay.js tolerates a photo that has no // pixels yet (it listens for the img load itself, then lays out + samples) try { const ov = createOverlay(node.querySelector(".card__photo"), record, img, { mode: "card" }); state.overlays.set(record.id, ov); } catch (err) { console.warn("overlay mount failed", err); } node.addEventListener("click", () => { state.interacted = true; speak(record, node); }); return node; } function speak(record, cardEl) { const ov = state.overlays.get(record.id); try { ov?.playLine(audioUrlFor(record)); } catch { /* the wall keeps its composure */ } // no bubble here — the caption + moving mouth already carry the grudge; // the whisper bubble belongs to ambient mutters only const bubble = cardEl.querySelector(".card__whisper"); if (bubble) { clearTimeout(bubble._t); bubble.classList.remove("is-on"); } } function whisper(cardEl, text, holdMs = 4200) { if (!text) return; const bubble = cardEl.querySelector(".card__whisper"); bubble.textContent = text; bubble.classList.add("is-on"); clearTimeout(bubble._t); bubble._t = setTimeout(() => bubble.classList.remove("is-on"), holdMs); } function addRecord(record, { fresh = false, prepend = false } = {}) { if (state.cardEls.has(record.id)) return; const card = makeCard(record, { fresh }); if (!fresh) card.style.setProperty("--enter-delay", `${Math.min(state.cardEls.size * 0.07, 0.8)}s`); state.cardEls.set(record.id, card); if (prepend) { state.records.unshift(record); els.wall.prepend(card); } else { state.records.push(record); els.wall.appendChild(card); } els.empty.hidden = state.records.length > 0; setCount(state.records.length); } let wallRetry = 0; async function loadWall() { let records = []; try { if (MOCK) { const res = await fetch("./mock/records.json"); records = (await res.json()).records; } else { const res = await fetch("/api/menagerie"); if (!res.ok) throw new Error(`menagerie ${res.status}`); const body = await res.json(); records = body.records ?? body ?? []; } } catch (err) { console.warn("wall fetch failed:", err); if (wallRetry < RETRY_DELAYS.length) { // the container is mid-recycle — whisper, wait, knock again els.count.textContent = "the wall is waking…"; setTimeout(loadWall, RETRY_DELAYS[wallRetry++]); return; } setCount(state.records.length); // whatever already made it stays counted els.empty.hidden = state.records.length > 0; return; } wallRetry = 0; // newest face first — the wall reads like a guestbook. Server records carry // a numeric `ts` (epoch seconds); dev fixtures carry ISO `created_at`. const recency = (r) => typeof r.ts === "number" ? r.ts : r.created_at ? (Date.parse(r.created_at) || 0) / 1000 : 0; records = [...records].sort((a, b) => recency(b) - recency(a)); for (const r of records) addRecord(r); setCount(state.records.length); els.empty.hidden = state.records.length > 0; } // ambient life: at most one whispered mutter every ~20s, only after the // visitor has touched the page (autoplay etiquette — and good manners). function startAmbientMutters() { setInterval(() => { if (!state.interacted || document.hidden || !els.captureScreen.hidden || !els.revealScreen.hidden || state.records.length === 0) return; const r = state.records[Math.floor(Math.random() * state.records.length)]; const card = state.cardEls.get(r.id); if (card) whisper(card, r.lines?.mutter, 4600); // records published after June 12 carry the mutter as AUDIO too — the // whisper becomes a voice (mouth syncs via the overlay's analyser). // Same interaction gate as everything audible; absent on old records. if (card && r.mutter_audio) { try { state.overlays.get(r.id)?.playLine(r.mutter_audio); } catch { /* the wall keeps its composure */ } } }, 20000); window.addEventListener("pointerdown", () => { state.interacted = true; }, { once: true }); } // live wall: new awakenings arrive over SSE function startStream() { if (MOCK) return; const onAwakened = (payload) => { const record = payload.record ?? payload; if (record?.id && record?.features) { addRecord(record, { fresh: true, prepend: true }); toast(`${displayName(record)} just woke up`); } }; let streamRetry = 0; const open = () => { let es; try { es = new EventSource("/api/stream"); } catch { return; } es.addEventListener("awakened", (e) => { try { onAwakened(JSON.parse(e.data)); } catch { /* malformed whisper */ } }); es.onmessage = (e) => { try { const m = JSON.parse(e.data); if (m.type === "awakened") onAwakened(m); } catch { /* heartbeats, hellos */ } }; es.onopen = () => { streamRetry = 0; }; es.onerror = () => { // EventSource heals transient drops itself; only a CLOSED stream is // dead (the container is mid-recycle). Reload the whole wall when we // return — addRecord dedupes — so nothing that woke meanwhile is lost. if (es.readyState !== EventSource.CLOSED) return; if (streamRetry >= RETRY_DELAYS.length) return; // it sleeps; a refresh revives it els.count.textContent = "the wall is waking…"; setTimeout(() => { loadWall(); open(); }, RETRY_DELAYS[streamRetry++]); }; }; open(); } // desktop QR — judges sit at desks; their phones do the haunting function paintQr() { if (!window.qrcode) return; try { const qr = window.qrcode(0, "M"); qr.addData(location.href); qr.make(); els.qrCode.innerHTML = qr.createSvgTag({ cellSize: 3, margin: 0, scalable: true }); els.qrCode.querySelectorAll("rect, path").forEach((n) => { if ((n.getAttribute("fill") || "").toLowerCase() !== "white") n.setAttribute("fill", "#efe6d4"); else n.setAttribute("fill", "transparent"); }); els.qr.hidden = false; } catch { /* a wall without a QR is still a wall */ } } // ---------------------------------------------------------------- capture function showScreen(which) { els.captureScreen.hidden = which !== "capture"; els.revealScreen.hidden = which !== "reveal"; document.body.style.overflow = which ? "hidden" : ""; } function openCapture() { state.interacted = true; showScreen("capture"); state.captureSession = createCaptureSession(els.captureStage, { onPhoto: (photo) => beginSeance(photo), }); } function closeCapture() { state.captureSession?.destroy(); state.captureSession = null; showScreen(null); } // ---------------------------------------------------------------- séance + reveal async function beginSeance(photo) { state.captureSession?.destroy(); state.captureSession = null; // our own copy of the photo sits UNDER the séance layer; whatever the // theater does above it, the reveal always has a stable img to haunt. els.revealImg.src = photo.dataUrl; els.seanceLayer.classList.remove("is-done"); els.seanceLayer.innerHTML = ""; els.revealScrim.classList.remove("is-on"); els.revealName.classList.remove("is-on"); els.revealChoice.classList.remove("is-on"); els.revealFail.classList.remove("is-on", "reveal__fail--busy"); els.revealFail.querySelector(".reveal__busy")?.remove(); els.revealFail.querySelector(".reveal__signin")?.remove(); state.busy = null; els.revealTitle.textContent = "the medium is looking…"; showScreen("reveal"); let seance; try { seance = runSeance(els.seanceLayer, photo.dataUrl); } catch { seance = fallbackSeance(els.seanceLayer); } let out; try { out = await awaken(photo.b64); } catch (err) { seance.fail?.(err.message); showFailure(err.message); return; } // a TRANSIENT busy wall (ZeroGPU per-IP quota / 429) is NOT a broken app and // NOT a soul declining — the same photo waits and 'try again' re-submits it. if (isBusyResult(out)) { state.busy = { seance, photo, retries: 0 }; seance.busy?.(out.reason); showBusy(out.reason, 0); return; } if (out.refused) { const reason = out.reason || "It is already awake."; seance.fail?.(reason); showFailure(reason); return; } proceedReveal(seance, photo, out); } /** The spirits answered. Hand the record to the séance + LIFE engine; stage the * reveal around the overlay's own timing. Shared by the first awaken and any * successful retry from the busy panel. */ function proceedReveal(seance, photo, out) { state.busy = null; els.revealFail.classList.remove("is-on"); const record = out.record; record.image_url = record.image_url || photo.dataUrl; state.pending = { record, record_token: out.record_token, dataUrl: photo.dataUrl }; seance.resolve?.(record); els.revealTitle.textContent = "it was in there all along"; // resolve() opens with ONE record-derived whisper (seance.js WHISPER_BEAT_MS) // — the medium names what it found — THEN the candlelight lifts. const whisperBeat = (runSeance && runSeance.WHISPER_BEAT_MS) || 2100; setTimeout(() => els.seanceLayer.classList.add("is-done"), whisperBeat); // mist clears → eyes emerge → FIRST BLINK (~700ms, the money frame) → // the grudge. The overlay owns its timing; we stage around it. setTimeout(() => { try { state.revealOverlay = createOverlay(els.revealPhoto, record, els.revealImg, { mode: "reveal" }); } catch (err) { console.warn("reveal overlay failed", err); state.revealOverlay = fallbackOverlay(els.revealPhoto, record, els.revealImg); } setTimeout(() => { try { state.revealOverlay?.playLine(audioUrlFor(record)); } catch { /* silence is also a voice */ } els.revealNameText.textContent = displayName(record); els.revealGrudge.textContent = `“${record.lines?.grudge ?? ""}”`; els.revealScrim.classList.add("is-on"); els.revealName.classList.add("is-on"); }, 1500); setTimeout(() => els.revealChoice.classList.add("is-on"), 4200); }, whisperBeat + 1300); } /** * The BUSY panel: an honest, retryable state for a transient ZeroGPU quota/429 * wall (distinct from the generic veil). Shows the server's reason in serif, a * prominent 'try again' (re-submits the SAME photo via retryAwaken — no camera * re-open), and a quieter 'sign in to Hugging Face ↗' link. After ~2 failed * retries the copy softens. All DOM via createElement/textContent — server * reason strings NEVER become innerHTML. * * @param {string} reason the server's busy line (or default) * @param {number} retries how many retries have already failed (softens copy) */ function showBusy(reason, retries) { els.revealTitle.textContent = "the spirits are overwhelmed"; els.revealFail.classList.add("reveal__fail--busy"); // soften after two crowded attempts; otherwise speak the server's own line const crowded = retries >= 2; els.revealFailText.textContent = crowded ? QUOTA_MESSAGE_CROWDED : (reason && String(reason).trim()) || QUOTA_MESSAGE; // rebuild the action row each time (state changes: copy, gathering spinner) els.revealFail.querySelector(".reveal__busy")?.remove(); const box = document.createElement("div"); box.className = "reveal__busy"; const tryAgain = document.createElement("button"); tryAgain.type = "button"; tryAgain.className = "cta reveal__retry"; tryAgain.textContent = "try again"; const gathering = document.createElement("p"); gathering.className = "reveal__gathering"; gathering.textContent = "the spirits gather…"; const signin = document.createElement("a"); signin.className = "reveal__signin"; signin.href = HF_LOGIN_URL; signin.target = "_blank"; signin.rel = "noopener"; signin.textContent = "sign in to Hugging Face ↗"; tryAgain.addEventListener("click", () => doRetry(tryAgain, box)); box.append(tryAgain, gathering, signin); els.revealFailText.after(box); // the back-to-the-wall verb already lives in #revealFail (failBack); keep it setTimeout(() => els.revealFail.classList.add("is-on"), 700); } /** Re-submit the retained photo. While in flight: 'try again' disables and the * 'the spirits gather…' line shows. On a real record → reveal proceeds; still * busy → re-show busy with the (possibly softened) copy; a hard break → the * generic veil. */ async function doRetry(btn, box) { const ctx = state.busy; if (!ctx) return; btn.disabled = true; box.classList.add("is-gathering"); let out; try { out = await retryAwaken(); } catch (err) { // a genuine break surfaced mid-retry — fall back to the honest veil ctx.seance.fail?.(err.message); state.busy = null; els.revealFail.classList.remove("reveal__fail--busy"); showFailure(err.message); return; } if (isBusyResult(out)) { ctx.retries += 1; box.classList.remove("is-gathering"); showBusy(out.reason, ctx.retries); return; } if (out.refused) { const reason = out.reason || "It is already awake."; ctx.seance.fail?.(reason); state.busy = null; els.revealFail.classList.remove("reveal__fail--busy"); showFailure(reason); return; } // the spirits answered on retry — clear the busy chrome and reveal els.revealFail.classList.remove("reveal__fail--busy"); els.revealFail.querySelector(".reveal__busy")?.remove(); proceedReveal(ctx.seance, ctx.photo, out); } function showFailure(message) { state.busy = null; els.revealFail.classList.remove("reveal__fail--busy"); els.revealFail.querySelector(".reveal__busy")?.remove(); els.revealTitle.textContent = "the spirits declined"; els.revealFailText.textContent = message; // a quota fail must hand the visitor the door, not just the poetry: // the line under the fail copy carries a real sign-in link (built with // DOM nodes, never innerHTML — error strings can come from the server) els.revealFail.querySelector(".reveal__signin")?.remove(); if (message === QUOTA_MESSAGE) { const hint = document.createElement("p"); hint.className = "reveal__signin"; hint.style.cssText = "margin-top:.4rem;font-size:.95em;opacity:.9"; const link = document.createElement("a"); link.href = HF_LOGIN_URL; link.target = "_blank"; link.rel = "noopener"; link.textContent = "sign in to Hugging Face"; link.style.cssText = "color:inherit;text-decoration:underline"; hint.append(link, document.createTextNode(" — the spirits answer the signed-in first")); els.revealFailText.after(hint); } setTimeout(() => els.revealFail.classList.add("is-on"), 900); } function teardownReveal() { try { state.revealOverlay?.destroy(); } catch { /* already gone */ } state.revealOverlay = null; els.seanceLayer.innerHTML = ""; els.revealScrim.classList.remove("is-on"); els.revealFail.classList.remove("is-on", "reveal__fail--busy"); els.revealFail.querySelector(".reveal__busy")?.remove(); els.revealFail.querySelector(".reveal__signin")?.remove(); els.revealImg.removeAttribute("src"); state.pending = null; state.busy = null; showScreen(null); } async function publishPending() { const pending = state.pending; if (!pending) return; els.addBtn.disabled = true; try { let published = pending.record; if (!MOCK) { const res = await fetch("/api/menagerie", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ record_token: pending.record_token }), }); if (!res.ok) { throw new Error(res.status === 429 ? "the wall asks you to slow down — six souls an hour is plenty" : "the wall would not take it — try again in a moment"); } // the server returns the persisted record itself (id, ts, media URLs); // using its id is what dedupes the SSE 'awakened' echo of our own publish const body = await res.json(); published = body.record ?? body ?? published; } published.id = published.id || `local-${Date.now()}`; published.image_url = published.image_url || pending.dataUrl; teardownReveal(); addRecord(published, { fresh: true, prepend: true }); window.scrollTo({ top: 0, behavior: "smooth" }); const url = shareUrlFor(published); toast(`${displayName(published)} has joined the menagerie`, { label: "copy its link", onClick: async () => { const ok = await copyText(url); toast(ok ? "link copied" : "the clipboard declined"); }, }); } catch (err) { toast(err.message); } finally { els.addBtn.disabled = false; } } // ---------------------------------------------------------------- boot async function boot() { await loadSiblings(); paintQr(); await loadWall(); startAmbientMutters(); startStream(); els.wakeBtn.addEventListener("click", openCapture); els.wakeBtnFloat.addEventListener("click", openCapture); els.captureCancel.addEventListener("click", closeCapture); els.addBtn.addEventListener("click", publishPending); els.restBtn.addEventListener("click", teardownReveal); els.failBack.addEventListener("click", teardownReveal); // a quiet hook for the screenshot harness window.__pareidolia = { get count() { return state.records.length; }, whisperFirst() { const r = state.records[0]; if (r) whisper(state.cardEls.get(r.id), r.lines?.mutter, 60000); }, }; } boot();