// 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 =
`
`;
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();