File size: 27,124 Bytes
772074b
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89dbee1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
89dbee1
 
 
 
772074b
89dbee1
 
 
 
 
 
 
 
 
 
 
 
772074b
 
89dbee1
772074b
 
 
 
 
 
 
 
de59038
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
 
 
 
 
de59038
772074b
 
 
 
 
89dbee1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de59038
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
de59038
 
 
 
 
 
 
 
772074b
 
de59038
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5643daf
 
 
 
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
de59038
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
de59038
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
 
 
 
 
 
772074b
 
 
 
 
 
 
8382377
 
 
 
 
 
 
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8382377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772074b
8382377
 
 
772074b
 
12d838f
 
 
 
 
 
 
 
 
8382377
12d838f
 
 
 
 
 
 
772074b
 
 
 
 
 
 
 
8382377
 
 
772074b
 
8382377
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89dbee1
 
 
 
 
 
 
 
772074b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
// 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 =
    `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
       background:rgba(11,10,9,.45)"><p style="font-style:italic;color:#efe6d4;opacity:.8;
       text-shadow:0 1px 8px #000;animation:pulse 2.4s ease-in-out infinite">the medium is looking…</p></div>`;
  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();