PageSourceSearch

https://slopstation.ai/app.js?v=59c7ce0

js slopstation.ai collected 2026-10-05 15:05:10 UTC 179,964 bytes, 3,859 lines download raw bytes

1// 1800.show — one room, ascii webcams, chat.
2// Everyone drops into /main. Up to 6 peers (mesh cap); 4×4 tile grid.
3// Phone verify via Twilio. Google/Apple sign-in stubs are visible in the
4// modal but disabled until wired.
5
6import { startLiveRender } from "/ascii.js";
7import { Mesh } from "/peer.js";
8import { wantsCamera, acquireCamera, releaseCamera, sharedStream } from "/cam-store.js";
9import { makeFaceCropper } from "/face.js";
10import { renderSymbolicFace } from "/face-symbolic.js";
11
12const ROOM = "main";
13
14const logEl       = document.getElementById("log");
15const camsEl      = document.getElementById("cams");
16const camsTopEl   = document.getElementById("camsTop");
17// camStart is re-rendered by renderMeTile on signed-in/out transitions, so
18// rely on live lookup + delegation instead of a stashed DOM ref.
19function getCamStartBtn() { return document.getElementById("camStart"); }
20const selfCanvas  = document.getElementById("selfCanvas");
21const composer    = document.getElementById("composer");
22const msgInput    = document.getElementById("msg");
23const micBtn      = document.getElementById("micBtn");
24const picBtn      = document.getElementById("picBtn");
25const picInput    = document.getElementById("picInput");
26const sendBtn     = document.getElementById("sendBtn");
27// Send button lives outside the form (it's in the right sidebar now so the
28// textarea can span full width). Wire it to trigger the same submit flow.
29// submitComposer: single entry point for "send the current textarea". The
30// composer is a <div> now (not a form) so iOS Safari's form-navigation
31// keyboard bar doesn't show up — meaning composer.requestSubmit() doesn't
32// exist either. Both Enter and the sidebar send button call this instead.
33async function submitComposer() {
34    const v = msgInput.value.trim(); if (!v) return;
35    msgInput.value = "";
36    resizeComposer();
37    slashPopup.hidden = true;
38    if (v.startsWith("/")) return handleSlash(v);
39    if (!account) { openVerifyModal(); return; }
40    const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text: v };
41    renderChat(out);
42    if (!ws) connectSignaling();
43    const trySend = () => {
44        if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out));
45        else setTimeout(trySend, 100);
46    };
47    trySend();
48}
49sendBtn?.addEventListener("click", submitComposer);
50const slashPopup  = document.getElementById("slashPopup");
51const meTileEl    = document.getElementById("meTile");
52const meTileBody  = document.getElementById("meTileBody");
53const meProfileBtn = document.getElementById("meProfileBtn");
54// Persistent "open profile" affordance in the corner of the me-tile so the
55// panel is always reachable even while the cam is on.
56meProfileBtn?.addEventListener("click", (e) => {
57    e.stopPropagation();
58    if (account) openAvatarPicker();
59});
60const modal       = document.getElementById("verifyModal");
61const modalClose  = document.getElementById("modalClose");
62
63let account = null;
64let stopRender = null;
65let mesh = null, ws = null, myPeerId = null;
66
67function esc(s) { return String(s).replace(/[&<>]/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;" }[c])); }
68
69// Shared AudioContext for every level-meter on the page. Created lazily on
70// the first call, which runs inside a user gesture (camera start click),
71// satisfying the autoplay policy.
72let audioCtx = null;
73function makeLevelMeter(stream) {
74    try {
75        if (!stream?.getAudioTracks?.().length) return () => 0;
76        if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
77        // Resume in case the browser auto-suspended the context (e.g. tab
78        // was backgrounded). Suspended context → getByteTimeDomainData returns
79        // flat 128 → RMS=0 → mouth never moves.
80        if (audioCtx.state === "suspended") audioCtx.resume();
81        const src = audioCtx.createMediaStreamSource(stream);
82        const analyser = audioCtx.createAnalyser();
83        analyser.fftSize = 512;
84        analyser.smoothingTimeConstant = 0.3;
85        src.connect(analyser);
86        const buf = new Uint8Array(analyser.fftSize);
87        let smoothed = 0;
88        return () => {
89            analyser.getByteTimeDomainData(buf);
90            let sum = 0;
91            for (let i = 0; i < buf.length; i++) {
92                const v = (buf[i] - 128) / 128;
93                sum += v * v;
94            }
95            const rms = Math.sqrt(sum / buf.length);
96            smoothed = smoothed * 0.5 + rms * 0.5;
97            return smoothed;
98        };
99    } catch (e) {
100        console.warn("[18:audio] level meter failed", e);
101        return () => 0;
102    }
103}
104// Avatar urls can be still images (.png/.webp) OR looping videos
105// (.mp4 from /u/pvid/<id>.mp4 or /u/gen/...mp4). Render a <video> for
106// video extensions, autoplay/muted/loop/playsinline so it acts as an
107// animated thumbnail. Returns an HTML fragment with the requested
108// container class, sized via CSS like the still-image variant.
109function isVideoAvatar(url) {
110    return /\.(mp4|webm|mov)(\?|$)/i.test(url || "");
111}
112function avatarHtml(url, klass = "") {
113    if (!url) return "";
114    if (isVideoAvatar(url)) {
115        return `<video class="${esc(klass)} avatar-video" data-autoloop="1" src="${esc(url)}" muted loop playsinline preload="metadata"></video>`;
116    }
117    return `<div class="${esc(klass)}" style="background-image:url(${esc(url)})"></div>`;
118}
119
120// Global pause-when-offscreen observer for any <video data-autoloop>
121// elements (avatars, suno videos, chat attachments). Decoding 1000+
122// videos simultaneously kills scroll fps; only the videos visible in
123// the viewport keep playing, the rest pause and don't decode frames.
124const videoVisibility = new IntersectionObserver((entries) => {
125    for (const e of entries) {
126        const v = e.target;
127        if (e.isIntersecting) {
128            v.play?.().catch(() => {});
129        } else {
130            try { v.pause?.(); } catch {}
131        }
132    }
133}, { rootMargin: "100px" });
134function watchAutoloopVideo(v) { try { videoVisibility.observe(v); } catch {} }
135// Mutation observer scans newly-added video[data-autoloop] elements
136// anywhere in the document and registers them with the visibility
137// observer. Fires once at boot for any pre-existing nodes too.
138new MutationObserver((muts) => {
139    for (const m of muts) for (const n of m.addedNodes) {
140        if (n.nodeType !== 1) continue;
141        if (n.matches?.("video[data-autoloop]")) watchAutoloopVideo(n);
142        n.querySelectorAll?.("video[data-autoloop]").forEach(watchAutoloopVideo);
143    }
144}).observe(document.body, { childList: true, subtree: true });
145document.querySelectorAll("video[data-autoloop]").forEach(watchAutoloopVideo);
146
147function linkify(escaped) {
148    return escaped.replace(/(https?:\/\/[^\s<]+)/g, (url) => {
149        try {
150            const u = new URL(url);
151            const host = u.hostname.replace(/^www\./, "");
152            return `<a class="url-btn" href="${esc(url)}" target="_blank" rel="noopener noreferrer">[${esc(host)}]</a>`;
153        } catch { return url; }
154    });
155}
156
157// Stable todo id: sha1(msg_id:line_index), first 16 hex chars.
158// Matches the server's todoId() in todos.js so state lines up.
159async function todoIdFor(msgId, lineIndex) {
160    const enc = new TextEncoder().encode(`${msgId}:${lineIndex}`);
161    const h = await crypto.subtle.digest("SHA-1", enc);
162    return Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
163}
164
165// Parse a message body into HTML:
166//   - lines beginning "- <text>" become checkbox todos with data-todo-id
167//   - everything else passes through linkify/esc
168//   - raw \n stays in the output; .msg .body has white-space: pre-wrap
169//     so the browser renders the breaks. Saves tokens (bots don't need
170//     to emit <br>) and avoids HTML-escaping the linebreaks.
171// Async because we need subtle.digest for the ids.
172async function renderMessageBody(msgId, text) {
173    if (!text) return "";
174    const lines = String(text).split(/\r?\n/);
175    const out = [];
176    for (let i = 0; i < lines.length; i++) {
177        const line = lines[i];
178        const m = line.match(/^(\s*)-\s+(?!\s)(.+?)\s*$/);
179        if (m && m[2]) {
180            const indent = m[1].replace(/ /g, "&nbsp;");
181            const id = await todoIdFor(msgId, i);
182            const state = todoState.get(id);
183            const checked = state?.checked ? " checked" : "";
184            const byAttr = state?.checked_by ? ` title="checked by @${esc(state.checked_by)}"` : "";
185            out.push(`${indent}<label class="todo-item"${byAttr}>` +
186                     `<input type="checkbox" class="todo-check" data-todo-id="${id}"${checked}>` +
187                     `<span class="todo-text">${linkify(esc(m[2]))}</span>` +
188                     `</label>`);
189        } else {
190            out.push(linkify(esc(line)));
191        }
192    }
193    return out.join("\n");
194}
195
196// Local mirror of todo state (for renders that arrive before the state
197// snapshot lands or before the WS catches up). Populated by the snapshot
198// fetch below AND by incoming todo-toggle WS messages.
199const todoState = new Map();   // id → { checked, checked_by }
200
201async function refreshTodoState() {
202    try {
203        const r = await fetch("/api/todos/flat");
204        const j = await r.json();
205        if (!j.ok) return;
206        todoState.clear();
207        for (const t of j.todos || []) {
208            if (t.id) todoState.set(t.id, { checked: !!t.checked, checked_by: t.checked_by || null });
209        }
210    } catch {}
211}
212function applyTodoToggle(id, checked, by) {
213    todoState.set(id, { checked: !!checked, checked_by: by || null });
214    for (const cb of document.querySelectorAll(`.todo-check[data-todo-id="${id}"]`)) {
215        cb.checked = !!checked;
216        const label = cb.closest(".todo-item");
217        if (label) {
218            label.title = by ? `checked by @${by}` : "";
219            label.classList.toggle("done", !!checked);
220        }
221    }
222}
223// Delegated: clicking any todo checkbox POSTs the new state. The WS
224// broadcast that comes back applies the class/title update; the local
225// click already flipped the input itself.
226document.addEventListener("change", async (e) => {
227    const cb = e.target.closest(".todo-check");
228    if (!cb) return;
229    if (!account) { cb.checked = !cb.checked; openVerifyModal(); return; }
230    const id = cb.dataset.todoId;
231    if (!id) return;
232    try {
233        await fetch(`/api/todos/${encodeURIComponent(id)}/toggle`, {
234            method: "POST",
235            headers: { "Content-Type": "application/json" },
236            body: JSON.stringify({ checked: cb.checked }),
237        });
238    } catch {}
239});
240
241// ───────── account ─────────
242async function loadAccount() {
243    try {
244        const r = await fetch("/api/account/me");
245        const j = await r.json();
246        account = j.account || null;
247        renderMeTile(j);
248        return j;
249    } catch { return { verified: false }; }
250}
251
252// The top-right "me" square always reflects one of three states:
253//   signed in + cam on    → the self ASCII canvas (handled by startCamera)
254//   signed in + cam off   → avatar image if set, else username centered
255//   not signed in         → sign-in button taking the whole square
256function renderMeTile(meInfo) {
257    // Keep selfCanvas in place if cam is on — don't clobber the render.
258    if (selfCanvas && !selfCanvas.hidden && selfCanvas.parentElement === meTileBody) return;
259    meTileBody.innerHTML = "";
260    if (!account) {
261        meTileBody.innerHTML = `
262            <button class="cam-start" id="camStart" type="button">
263                <pre class="happy" aria-hidden="true">
264 ______
265/      \\
266| o  o |
267|  ..  |
268|  --  |
269\\______/
270</pre>
271                <span class="cam-start-text">${meInfo?.verified ? "pick username" : "sign in"}</span>
272            </button>`;
273        meTileBody.querySelector(".cam-start").addEventListener("click", async () => {
274            if (meInfo?.verified) await pickUsername();
275            else openVerifyModal();
276        });
277        updateCamLauncher();
278        return;
279    }
280    if (account.avatarUrl) {
281        meTileBody.innerHTML = `${avatarHtml(account.avatarUrl, "me-avatar")}
282             <span class="self-name">${esc(account.username)}</span>`;
283    } else {
284        meTileBody.innerHTML =
285            `<span class="self-name">${esc(account.username)}</span>`;
286    }
287    updateCamLauncher();
288}
289
290// The cam-launcher tile lives outside the me-tile (see index.html), as a
291// second square cell in the cam column. It shows only when the user is
292// signed in AND the self-cam is currently off; a running cam replaces it.
293const camLauncherEl = document.getElementById("camLauncher");
294function updateCamLauncher() {
295    if (!camLauncherEl) return;
296    const camOn = !!(selfCanvas && !selfCanvas.hidden);
297    camLauncherEl.hidden = !account || camOn;
298}
299camLauncherEl?.addEventListener("click", () => {
300    if (!account) return;
301    startCamera();
302});
303// Click the me-tile body (anywhere but on a cam-start button or the profile
304// corner button) → profile panel. Cam-start has its own handler; meProfileBtn
305// is wired above and stops propagation.
306meTileBody.addEventListener("click", (e) => {
307    if (!account) return;
308    if (e.target.closest(".cam-start")) return;
309    // Clicking my own me-tile now opens my public myspace-style
310    // profile (same view everyone else sees). The legacy avatar
311    // picker lives behind the ⚙ icon — meProfileBtn above.
312    openProfilePanel(account.username);
313});
314modalClose.addEventListener("click", () => { modal.hidden = true; });
315
316// Disable hidden inputs so iOS doesn't show prev/next arrows.
317function setVerifyReachable(reachable) {
318    for (const id of ["verifyPhone", "verifyCode"]) {
319        const el = document.getElementById(id);
320        if (!el) continue;
321        el.tabIndex = reachable ? 0 : -1;
322        el.disabled = !reachable;
323    }
324}
325setVerifyReachable(false);
326new MutationObserver(() => setVerifyReachable(!modal.hidden))
327    .observe(modal, { attributes: true, attributeFilter: ["hidden"] });
328
329// ───────── verify modal ─────────
330const LAST_PHONE_KEY = "18.phone";
331const verifyPhoneEl = document.getElementById("verifyPhone");
332const verifyCodeEl  = document.getElementById("verifyCode");
333
334// Open the verify modal and focus the first visible input so users can
335// start typing immediately — less tapping on phones.
336function openVerifyModal() {
337    modal.hidden = false;
338    setTimeout(() => {
339        if (document.getElementById("sendForm").hidden) {
340            verifyCodeEl.focus();
341            verifyCodeEl.select?.();
342        } else {
343            verifyPhoneEl.focus();
344        }
345    }, 60);
346}
347try {
348    const saved = localStorage.getItem(LAST_PHONE_KEY);
349    if (saved) verifyPhoneEl.value = saved;
350} catch {}
351
352document.getElementById("sendForm").addEventListener("submit", async (e) => {
353    e.preventDefault();
354    const m = document.getElementById("verifyMsg"); m.textContent = "sending…";
355    const phone = verifyPhoneEl.value;
356    const r = await fetch("/api/verify/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone }) });
357    const j = await r.json();
358    if (!r.ok) { m.textContent = "error: " + (j.error || "unknown"); return; }
359    try { localStorage.setItem(LAST_PHONE_KEY, phone); } catch {}
360    m.textContent = `code sent to ${phone}`;
361    document.getElementById("sendForm").hidden = true;
362    document.getElementById("checkForm").hidden = false;
363    setTimeout(() => verifyCodeEl.focus(), 50);
364
365    if ("OTPCredential" in window) {
366        const ac = new AbortController();
367        document.getElementById("checkForm").addEventListener("submit", () => ac.abort(), { once: true });
368        try {
369            const otp = await navigator.credentials.get({ otp: { transport: ["sms"] }, signal: ac.signal });
370            if (otp?.code) {
371                verifyCodeEl.value = otp.code;
372                document.getElementById("checkForm").dispatchEvent(new Event("submit", { cancelable: true }));
373            }
374        } catch {}
375    }
376});
377document.getElementById("checkForm").addEventListener("submit", async (e) => {
378    e.preventDefault();
379    const m = document.getElementById("verifyMsg"); m.textContent = "checking…";
380    const r = await fetch("/api/verify/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone: verifyPhoneEl.value, code: verifyCodeEl.value }) });
381    if (!r.ok) { m.textContent = "invalid code"; return; }
382    m.textContent = "verified"; modal.hidden = true;
383    await loadAccount();
384    if (!account) await pickUsername();
385    // Reset for next time
386    document.getElementById("sendForm").hidden = false;
387    document.getElementById("checkForm").hidden = true;
388    verifyCodeEl.value = "";
389    // If the user got here from the Google-link flow, finish the link
390    // now that we have a valid session.
391    await maybeFinishGoogleLink();
392    await maybeFinishParasceneLink();
393    // Reconnect WS with new identity so chat has a stable "from".
394    reconnectWs();
395});
396async function pickUsername() {
397    while (true) {
398        const u = prompt("pick a username (3–20, lowercase letters/numbers/_):");
399        if (!u) continue;
400        const r = await fetch("/api/account/claim", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u }) });
401        const j = await r.json();
402        if (r.ok) { account = j.account; await loadAccount(); return; }
403        alert("username error: " + (j.error || "unknown"));
404    }
405}
406
407// ───────── google sign-in / link modal ─────────
408//
409// The OAuth callback sets `?google_link_pending=1` when an unknown
410// Google identity arrives without a session. We show a 2-button modal:
411//   - "create new account" with a username input → POST create-account
412//   - "sign in with phone to link" → opens the existing verify modal,
413//      then POSTs finish-link once the user is signed in.
414//
415// Other URL flags handled here:
416//   ?signed_in=1         — Google login worked, just clean the URL
417//   ?google_linked=1     — added Google to existing account, brief msg
418//   ?auth_error=…        — surface an error in the verify modal
419function cleanAuthQueryString() {
420    const u = new URL(location.href);
421    let dirty = false;
422    for (const k of [
423        "signed_in", "google_link_pending", "google_linked",
424        "parascene_link_pending", "parascene_linked", "auth_error",
425    ]) {
426        if (u.searchParams.has(k)) { u.searchParams.delete(k); dirty = true; }
427    }
428    if (dirty) history.replaceState(null, "", u.toString());
429}
430
431async function handleAuthLandings() {
432    const params = new URLSearchParams(location.search);
433    if (params.get("auth_error")) {
434        const msg = document.getElementById("verifyMsg");
435        if (msg) msg.textContent = "sign-in error: " + params.get("auth_error");
436        cleanAuthQueryString();
437        return;
438    }
439    if (params.get("google_linked") === "1" || params.get("parascene_linked") === "1") {
440        const msg = document.getElementById("verifyMsg");
441        if (msg) msg.textContent = params.get("parascene_linked") === "1" ? "parascene linked." : "google linked.";
442        cleanAuthQueryString();
443        await loadAccount();
444        return;
445    }
446    if (params.get("signed_in") === "1") {
447        cleanAuthQueryString();
448        await loadAccount();
449        reconnectWs();
450        return;
451    }
452    if (params.get("google_link_pending") === "1") {
453        cleanAuthQueryString();
454        openGoogleLinkModal();
455        return;
456    }
457    if (params.get("parascene_link_pending") === "1") {
458        cleanAuthQueryString();
459        openParasceneLinkModal();
460    }
461}
462
463function openGoogleLinkModal() {
464    const m = document.getElementById("googleLinkModal");
465    if (m) m.hidden = false;
466}
467function closeGoogleLinkModal() {
468    const m = document.getElementById("googleLinkModal");
469    if (m) m.hidden = true;
470}
471function openParasceneLinkModal(suggestedUsername) {
472    const m = document.getElementById("parasceneLinkModal");
473    if (!m) return;
474    m.hidden = false;
475    if (suggestedUsername) {
476        const inp = document.getElementById("parasceneNewUsername");
477        if (inp && !inp.value) {
478            // Sanitize: lowercase, drop non-[a-z0-9_], trim to 20 chars,
479            // ensure leading char matches [a-z]. Caller may pass a
480            // display name that won't pass the form pattern as-is.
481            const cleaned = String(suggestedUsername).toLowerCase()
482                .replace(/[^a-z0-9_]/g, "").replace(/^[^a-z]+/, "").slice(0, 20);
483            if (cleaned.length >= 3) inp.value = cleaned;
484        }
485    }
486}
487function closeParasceneLinkModal() {
488    const m = document.getElementById("parasceneLinkModal");
489    if (m) m.hidden = true;
490}
491
492document.getElementById("googleLinkClose")?.addEventListener("click", closeGoogleLinkModal);
493document.getElementById("googleNewForm")?.addEventListener("submit", async (e) => {
494    e.preventDefault();
495    const u = document.getElementById("googleNewUsername").value.trim();
496    if (!u) return;
497    const r = await fetch("/api/auth/google/create-account", {
498        method: "POST",
499        headers: { "Content-Type": "application/json" },
500        body: JSON.stringify({ username: u }),
501    });
502    const j = await r.json().catch(() => ({}));
503    if (!r.ok) { alert("couldn't create account: " + (j.error || r.status)); return; }
504    closeGoogleLinkModal();
505    await loadAccount();
506    reconnectWs();
507});
508document.getElementById("googleLinkPhone")?.addEventListener("click", async () => {
509    closeGoogleLinkModal();
510    // Stash a flag so once the verify modal succeeds, we POST finish-link.
511    window.__googleLinkAfterVerify = true;
512    openVerifyModal();
513});
514// After phone verify succeeds, if the user came from the Google link
515// flow, finish the link by attaching the pending Google identity to
516// the freshly-authenticated account.
517async function maybeFinishGoogleLink() {
518    if (!window.__googleLinkAfterVerify) return;
519    window.__googleLinkAfterVerify = false;
520    try {
521        const r = await fetch("/api/auth/google/finish-link", { method: "POST" });
522        if (r.ok) console.log("[google] linked to existing account");
523        else console.warn("[google/finish-link]", await r.text());
524    } catch (e) {
525        console.warn("[google/finish-link]", e.message);
526    }
527}
528
529// ───────── parascene sign-in / link modal ─────────
530//
531// Same shape as the Google flow, but the user starts via a popup
532// window so they never visually leave slopstation. The popup hosts
533// parascene's hosted /auth page, which has signup AND login tabs —
534// so brand-new users can create a parascene account in the popup.
535//
536// On success the callback (server-side) returns a tiny HTML page
537// that postMessages back here and closes itself. We listen for
538// {type:"parascene-auth"} messages and route by .result:
539//   signed_in    → reload account, reconnect ws
540//   linked       → already-authed user added parascene to their acct
541//   link_pending → open the link/create-account modal
542//   error        → surface in the verify modal's msg area
543function startParasceneSignIn() {
544    const url = "/api/auth/parascene?popup=1";
545    const w = 480, h = 720;
546    const left = Math.max(0, (screen.availWidth - w) / 2 + (screen.availLeft || 0));
547    const top  = Math.max(0, (screen.availHeight - h) / 2 + (screen.availTop || 0));
548    const popup = window.open(url, "parascene-auth",
549        `width=${w},height=${h},left=${Math.round(left)},top=${Math.round(top)},menubar=no,toolbar=no,location=no,status=no`);
550    if (!popup) {
551        // Popup blocked — fall back to same-tab redirect.
552        location.href = "/api/auth/parascene";
553    }
554}
555window.addEventListener("message", async (ev) => {
556    if (ev.origin !== location.origin) return;
557    const d = ev.data;
558    if (!d || d.type !== "parascene-auth") return;
559    if (d.result === "signed_in") {
560        const m = document.getElementById("verifyModal"); if (m) m.hidden = true;
561        await loadAccount();
562        reconnectWs();
563    } else if (d.result === "linked") {
564        const msg = document.getElementById("verifyMsg");
565        if (msg) msg.textContent = "parascene linked.";
566        await loadAccount();
567    } else if (d.result === "link_pending") {
568        const m = document.getElementById("verifyModal"); if (m) m.hidden = true;
569        openParasceneLinkModal(d.suggestedUsername);
570    } else if (d.result === "error") {
571        const msg = document.getElementById("verifyMsg");
572        if (msg) msg.textContent = "parascene sign-in error: " + (d.error || "unknown");
573    }
574});
575document.getElementById("parasceneSignInBtn")?.addEventListener("click", startParasceneSignIn);
576document.getElementById("parasceneLinkClose")?.addEventListener("click", closeParasceneLinkModal);
577document.getElementById("parasceneNewForm")?.addEventListener("submit", async (e) => {
578    e.preventDefault();
579    const u = document.getElementById("parasceneNewUsername").value.trim();
580    if (!u) return;
581    const r = await fetch("/api/auth/parascene/create-account", {
582        method: "POST",
583        headers: { "Content-Type": "application/json" },
584        body: JSON.stringify({ username: u }),
585    });
586    const j = await r.json().catch(() => ({}));
587    if (!r.ok) { alert("couldn't create account: " + (j.error || r.status)); return; }
588    closeParasceneLinkModal();
589    await loadAccount();
590    reconnectWs();
591});
592document.getElementById("parasceneLinkPhone")?.addEventListener("click", () => {
593    closeParasceneLinkModal();
594    window.__parasceneLinkAfterVerify = true;
595    openVerifyModal();
596});
597async function maybeFinishParasceneLink() {
598    if (!window.__parasceneLinkAfterVerify) return;
599    window.__parasceneLinkAfterVerify = false;
600    try {
601        const r = await fetch("/api/auth/parascene/finish-link", { method: "POST" });
602        if (r.ok) console.log("[parascene] linked to existing account");
603        else console.warn("[parascene/finish-link]", await r.text());
604    } catch (e) {
605        console.warn("[parascene/finish-link]", e.message);
606    }
607}
608
609// ───────── camera ─────────
610async function startCamera() {
611    console.log("[18:cam] startCamera begin", { ua: navigator.userAgent, hasFaceDetector: "FaceDetector" in window });
612    const me = await loadAccount();
613    console.log("[18:cam] account", { verified: me.verified, account: !!me.account });
614    if (!me.verified) { openVerifyModal(); return; }
615    if (!me.account)  { await pickUsername(); }
616    try { await acquireCamera(); }
617    catch (e) { console.error("[18:cam] acquireCamera failed", e); say("camera err: " + e.message, "err"); return; }
618    const stream = sharedStream();
619    console.log("[18:cam] stream", {
620        active: stream?.active,
621        tracks: stream?.getTracks().map(t => ({ kind: t.kind, enabled: t.enabled, muted: t.muted, readyState: t.readyState, settings: t.getSettings?.() })),
622    });
623    let v = document.querySelector("video[data-18cam]");
624    if (!v) {
625        v = document.createElement("video");
626        v.srcObject = stream;
627        v.autoplay = true;
628        v.muted = true;
629        v.playsInline = true;
630        v.setAttribute("playsinline", "");       // iOS requires attr not just prop
631        v.setAttribute("webkit-playsinline", ""); // older iOS
632        // iOS Safari throttles/pauses display:none videos, so decoded frames
633        // stop flowing into drawImage. Keep the element on-page and merely
634        // invisible.
635        v.style.cssText = "position:absolute;
635left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;";
636        v.dataset["18cam"] = "1";
637        document.body.appendChild(v);
638    }
639    try { await v.play(); console.log("[18:cam] video.play ok"); }
640    catch (e) { console.warn("[18:cam] video.play rejected", e); }
641    // Clear the inner body only (keep the me-tile's profile button wrapper).
642    meTileBody.innerHTML = "";
643    selfCanvas.hidden = false;
644    selfCanvas.className = "ascii-canvas self";
645    meTileBody.appendChild(selfCanvas);
646    updateSelfMicVisual();
647    updateCamLauncher();
648    camsEl.classList.add("on");
649    if (stopRender) stopRender();
650    const selfFace = makeFaceCropper(v);
651    console.log("[18:cam] faceCropper", { hasDetector: selfFace.hasDetector });
652    await new Promise((resolve) => {
653        if (v.readyState >= 2 && v.videoWidth) return resolve();
654        const onReady = () => { v.removeEventListener("loadeddata", onReady); resolve(); };
655        v.addEventListener("loadeddata", onReady);
656        setTimeout(resolve, 800);
657    });
658    console.log("[18:cam] video ready", {
659        videoWidth: v.videoWidth, videoHeight: v.videoHeight,
660        readyState: v.readyState, paused: v.paused, currentTime: v.currentTime,
661    });
662    // Render the symbolic face always. Chrome dropped FaceDetector so the
663    // real-tracking gate was locking everyone into pixel-ASCII. Without a
664    // tracker, the symbolic renderer falls back to sampling the center of
665    // the frame — crude but at least draws the face and picks up blinks
666    // when the user is roughly centered. Real cross-browser tracking
667    // requires MediaPipe (not wired).
668    console.log("[18:cam] renderer → symbolic (native FaceDetector present:", selfFace.hasDetector, ")");
669    const selfLevel = makeLevelMeter(stream);
670    stopRender = startLiveRender(v, selfCanvas, {
671        fps: 12,
672        // mirror:true so the self glyph tracks like a mirror — you move
673        // right in the room, the glyph moves right on screen.
674        mirror: true,
675        getCrop: () => selfFace.cropRect(),
676        getAudioLevel: selfLevel,
677        render: renderSymbolicFace,
678    });
679    if (!ws) connectSignaling();
680    // Attach the newly-acquired tracks to every existing peer connection.
681    // Perfect-negotiation kicks in via negotiationneeded and renegotiates.
682    mesh?.attachLocalStream(sharedStream());
683}
684function stopCamera() {
685    stopRender?.(); stopRender = null;
686    releaseCamera();
687    document.querySelector("video[data-18cam]")?.remove();
688    selfCanvas.hidden = true;
689    selfCanvas.remove();
690    // Restore the me-tile's default rendering (username / avatar / sign-in).
691    renderMeTile({ verified: !!account, account });
692    if (!peerTiles.size) camsEl.classList.remove("on");
693    mesh?.detachLocalStream();
694}
695// Event-delegated cam-start click. Fires only when the user is already
696// signed in — sign-in + pick-username paths wire their own handler in
697// renderMeTile (where we know which label to show).
698meTileBody.addEventListener("click", (e) => {
699    if (!e.target.closest("#camStart")) return;
700    if (account) startCamera();
701});
702
703// Click your own tile to toggle your mic on/off. (Used to stop the camera
704// entirely — moved the stop-cam gesture out of the click path so mic
705// toggling isn't destructive.)
706function toggleSelfMic() {
707    const s = sharedStream();
708    const track = s?.getAudioTracks?.()[0];
709    if (!track) { say("no mic track", "err"); return; }
710    track.enabled = !track.enabled;
711    updateSelfMicVisual();
712}
713function updateSelfMicVisual() {
714    const s = sharedStream();
715    const track = s?.getAudioTracks?.()[0];
716    const off = track && !track.enabled;
717    selfCanvas.classList.toggle("muted", !!off);
718    const who = account?.username ? "@" + account.username : "you";
719    selfCanvas.title = `${who} — ${off ? "mic muted (click to unmute)" : "click to mute mic"}`;
720}
721// Stop the click from bubbling to meTileEl (which would open the profile
722// panel). Mic toggle is the primary self-cam gesture; profile panel
723// opens via the me-tile's background (i.e. when cam is OFF).
724selfCanvas.addEventListener("click", (e) => { e.stopPropagation(); toggleSelfMic(); });
725
726// ───────── peer tiles ─────────
727const peerTiles = new Map();
728function labelFor(peerId) {
729    const name = peerNames.get(peerId);
730    return name ? "@" + name : peerId.slice(0, 6);
731}
732function labelTile(peerId) {
733    // If the peer just acquired a username, create their tile now —
734    // anonymous peers without a username don't get tiles at all.
735    const name = peerNames.get(peerId);
736    if (!peerTiles.has(peerId) && name) {
737        addTile(peerId);
738        return;
739    }
740    const t = peerTiles.get(peerId);
741    if (!t) return;
742    const lbl = labelFor(peerId);
743    t.canvas.title = lbl;
744    if (t.nameLabel) t.nameLabel.textContent = lbl;
745    t.username = peerNames.get(peerId) || null;
746}
747// Dedup + anonymous filter:
748//   1. Peers without a username (just browser-fingerprint IDs like
749//      "ecaf30") don't get a tile — they're wasted sidebar space.
750//      When/if they sign in, peer-joined with a name repaints via
751//      labelTile.
752//   2. A peer sharing our own username (a second tab/device) is
753//      skipped to avoid double-rendering self.
754//   3. A peer sharing another already-rendered peer's username is
755//      skipped (same account multiple connections).
756function shouldSkipPeer(peerId) {
757    const name = peerNames.get(peerId);
758    if (!name) return true;
759    if (account?.username && name.toLowerCase() === account.username.toLowerCase()) return true;
760    for (const [otherId, tile] of peerTiles) {
761        if (otherId === peerId) continue;
762        if (tile.username && tile.username.toLowerCase() === name.toLowerCase()) return true;
763    }
764    return false;
765}
766function addTile(peerId) {
767    if (peerTiles.has(peerId)) return peerTiles.get(peerId);
768    if (shouldSkipPeer(peerId)) return null;
769    camsEl.classList.add("on");
770    // Wrap the canvas so we can overlay a username label for peers whose
771    // cam is off. The label mirrors how bot tiles display their name.
772    const wrap = document.createElement("div");
773    wrap.className = "peer-tile";
774    const canvas = document.createElement("canvas");
775    canvas.className = "ascii-canvas";
776    canvas.title = labelFor(peerId) + " — click to mute";
777    wrap.appendChild(canvas);
778    const nameLabel = document.createElement("span");
779    nameLabel.className = "peer-name";
780    nameLabel.textContent = labelFor(peerId);
781    wrap.appendChild(nameLabel);
782    camsTopEl.appendChild(wrap);
783    const video = document.createElement("video");
784    video.autoplay = true;
785    video.playsInline = true;
786    video.muted = false;
787    video.setAttribute("playsinline", "");
788    video.setAttribute("webkit-playsinline", "");
789    video.style.cssText = "position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;";
790    document.body.appendChild(video);
791    canvas.addEventListener("click", () => togglePeerMute(peerId));
792    const t = { canvas, video, wrap, nameLabel, stop: null, hasVideo: false, username: peerNames.get(peerId) || null };
793    peerTiles.set(peerId, t);
794    return t;
795}
796function togglePeerMute(peerId) {
797    const t = peerTiles.get(peerId); if (!t) return;
798    t.video.muted = !t.video.muted;
799    t.canvas.classList.toggle("muted", t.video.muted);
800    const name = labelFor(peerId);
801    t.canvas.title = name + (t.video.muted ? " — muted (click to unmute)" : " — click to mute");
802}
803function dropTile(peerId) {
804    const t = peerTiles.get(peerId); if (!t) return;
805    t.stop?.();
806    if (t.wrap) t.wrap.remove(); else t.canvas.remove();
807    t.video.remove();
808    peerTiles.delete(peerId);
809    if (!peerTiles.size && selfCanvas.hidden) {
810        camsEl.classList.remove("on");
811    }
812}
813function wireStream(peerId, stream) {
814    const t = addTile(peerId); if (!t) return;
815    t.video.srcObject = stream; t.video.play().catch(() => {});
816    const peerFace = makeFaceCropper(t.video);
817    t.stop?.();
818    // Peers render as the symbolic ASCII face too — the single MediaPipe
819    // FaceLandmarker in face.js is a shared singleton, so each tile drives
820    // its own cropRect() against its own video and gets per-peer eye-blink
821    // state in cropInfo.eyeState.
822    const peerLevel = makeLevelMeter(stream);
823    t.stop = startLiveRender(t.video, t.canvas, {
824        fps: 10, mirror: false,
825        getCrop: () => peerFace.cropRect(),
826        getAudioLevel: peerLevel,
827        render: renderSymbolicFace,
828    });
829    t.hasVideo = true;
830    // Hide the username overlay once a live face tile is rendering — the
831    // tile itself is the identifier at that point. Keep it available via
832    // CSS `.peer-tile:has(.ascii-canvas.active) .peer-name { display: none }`
833    // isn't portable enough yet, so flip it with a class instead.
834    t.wrap?.classList.add("has-video");
835}
836
837// ───────── signaling ─────────
838// Mesh lifecycle: we always want a mesh once we've joined, even without a cam,
839// so peers with cam can deliver their video to us. Turning the cam on after the
840// fact tears down and recreates the mesh so our local tracks get attached to
841// every peer connection (simpler than mid-negotiation addTrack choreography).
842let joinedInfo = null;       // last "joined" payload, kept for re-creation
843let peersKnown = new Set();  // peers currently present per the signaling state
844const peerNames = new Map(); // peerId → @username (best-effort label on tiles)
845
846function createMesh() {
847    if (mesh || !joinedInfo) return;
848    mesh = new Mesh({
849        selfId: joinedInfo.peerId, ws,
850        iceServers: joinedInfo.iceServers || [],
851        getLocalStream: () => sharedStream(),
852        onPeer: addTile, onPeerGone: dropTile, onStream: wireStream,
853        log: (...a) => console.log("[mesh]", ...a),
854    });
855    // Initiate to every known peer. If we have no local stream, the PC ends up
856    // receive-only (addPeer adds tracks only when a stream exists); incoming
857    // offers from peers who do have cams still land on our onSignal path.
858    for (const p of peersKnown) mesh.addPeer(p, true);
859}
860
861function connectSignaling() {
862    const proto = location.protocol === "https:" ? "wss:" : "ws:";
863    ws = new WebSocket(`${proto}//${location.host}/signal`);
864    ws.onopen = () => {
865        resetReconnectBackoff();
866        ws.send(JSON.stringify({ t: "join", room: ROOM, name: account?.username || null }));
867    };
868    ws.onmessage = async (ev) => {
869        let m;
869 try { m = JSON.parse(ev.data); } catch { return; }
870        // Re-broadcast on document so listeners that need to survive
871        // WS reconnects (the DM panel, feed grid) attach to the
872        // document instead of a specific ws instance.
873        try { document.dispatchEvent(new CustomEvent("slop:ws-msg", { detail: m })); } catch {}
874        if (m.t === "joined") {
875            myPeerId = m.peerId;
876            joinedInfo = m;
877            // m.peers can be array-of-{peerId,username} (current server) or
878            // array-of-peerId strings (older server). Normalize.
879            const rawPeers = m.peers || [];
880            peersKnown = new Set();
881            for (const p of rawPeers) {
882                if (typeof p === "string") { peersKnown.add(p); }
883                else if (p?.peerId) {
884                    peersKnown.add(p.peerId);
885                    if (p.username) peerNames.set(p.peerId, p.username);
886                }
887            }
888            createMesh();
889        } else if (m.t === "peer-joined") {
890            peersKnown.add(m.peerId);
891            if (m.username) peerNames.set(m.peerId, m.username);
892            mesh?.addPeer(m.peerId, false);
893            labelTile(m.peerId);
894        } else if (m.t === "peer-left") {
895            peersKnown.delete(m.peerId);
896            peerNames.delete(m.peerId);
897            mesh?.removePeer(m.peerId); dropTile(m.peerId);
898        } else if (m.t === "signal") {
899            if (!mesh) createMesh();  // signal arriving before mesh existed
900            await mesh?.onSignal(m.from, m.data);
901        } else if (m.t === "chat-history")  {
902            for (const h of (m.messages || [])) {
903                renderChat(h);
904                if (h.gen_id) seenGenIds.add(String(h.gen_id));
905            }
906            refreshFeedRailDimming();
907            // Boot-path reveal: once the initial history has rendered,
908            // scroll to bottom and drop the visibility:hidden class so
909            // the log shows up pre-positioned instead of appearing to
910            // scroll after load.
911            scrollToBottom();
912            requestAnimationFrame(() => {
913                scrollToBottom();
914                logEl.classList.remove("booting");
915            });
916        }
917        else if (m.t === "chat")          {
918            renderChat(m);
919            if (m.gen_id) { seenGenIds.add(String(m.gen_id)); refreshFeedRailDimming(); }
920        }
921        else if (m.t === "chat-delete")   { applyChatDelete(m.id); }
922        else if (m.t === "todo-toggle")   { applyTodoToggle(m.id, m.checked, m.by); }
923        else if (m.t === "parascene-feed") { applyFeedDelta(m.added || []); }
924    };
925    ws.onclose = () => {
926        ws = null; joinedInfo = null; peersKnown.clear();
927        mesh?.destroy(); mesh = null;
928        scheduleReconnect();
929    };
930}
931
932// Exponential-backoff reconnect so the chat self-heals after a network blip,
933// tab-sleep on mobile, or a transient server restart — no need for the user
934// to reload to see new messages. Capped at 15s.
935let reconnectTimer = null, reconnectDelay = 800;
936function scheduleReconnect() {
937    if (reconnectTimer) return;
938    reconnectTimer = setTimeout(() => {
939        reconnectTimer = null;
940        reconnectDelay = Math.min(reconnectDelay * 1.8, 15000);
941        connectSignaling();
942    }, reconnectDelay);
943}
944// Reset the backoff on a successful open so the NEXT drop starts quick again.
945function resetReconnectBackoff() { reconnectDelay = 800; }
946// Recover faster when the user brings the tab back into focus — many mobile
947// browsers silently close the WS while backgrounded.
948window.addEventListener("visibilitychange", () => {
949    if (document.visibilityState === "visible" && !ws) {
950        reconnectDelay = 800;
951        if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
952        connectSignaling();
953    }
954});
955function reconnectWs() {
956    if (ws) { try { ws.close(); } catch {} ws = null; }
957    mesh?.destroy(); mesh = null;
958    joinedInfo = null; peersKnown.clear();
959    connectSignaling();
960}
961
962// Dedupe by message id so chat-history replays on reconnect don't re-render
963// messages we've already shown. Falls back to a content-hash key for messages
964// without an id (legacy/bot injects).
965const renderedIds = new Set();
966function renderKey(m) {
967    return m.id ? "id:" + m.id : "h:" + (m.from || "?") + ":" + (m.ts || 0) + ":" + (m.text || "").slice(0, 40);
968}
969// Messages from the same author within this window collapse into a visual
970// block — username only printed the first time, subsequent messages look like
971// continuations. 1 hour is the same heuristic iMessage/Slack use.
972const GROUP_WINDOW_MS = 60 * 60 * 1000;
973
974// scrollToBottom respects the 30vh bottom padding on .log — setting scrollTop
975// to scrollHeight just clamps to the max, leaving the last message naturally
976// pushed up by the padding.
977// The chat log never scrolls itself after boot. On page load we pre-position
978// at the bottom (freshest content in view); from there the user is in
979// control. No sticky-bottom, no snap-back on new messages — matching how
980// a terminal scrollback feels.
981function scrollToBottom() {
982    // Default scroll position = boundary at the bottom of the viewport,
983    // so the latest chat message is the bottom of what's visible and
984    // the grid sits just out of view below. (Without this guard,
985    // scrollHeight includes grid + 30vh padding and we'd land deep
986    // inside the grid.)
987    const boundary = logEl.querySelector(".chat-feed-boundary");
988    if (boundary) {
989        logEl.scrollTop = Math.max(0, boundary.offsetTop - logEl.clientHeight + boundary.offsetHeight);
990    } else if (feedGridEl && feedGridEl.parentNode === logEl) {
991        logEl.scrollTop = Math.max(0, feedGridEl.offsetTop - logEl.clientHeight);
992    } else {
993        logEl.scrollTop = logEl.scrollHeight;
994    }
995}
996
997// ───────── sidebar image stack ─────────
998// Images that used to render inline in chat now live in a dedicated
999// right-side column under the webcams. The stack is bottom-anchored and
1000// keyed to the most-recent /gen message currently visible in the chat log.
1001// As the user scrolls through old conversations, different spans of gens
1002// come into view and the sidebar re-anchors.
1003
1004// Images now float right inside the chat flow at 20vw. The old sidebar
1005// image stack + IntersectionObserver anchor logic has been removed — the
1006// image just sits at the message that introduced it, and subsequent
1007// messages' text wraps around it naturally via CSS floats.
1008
1009// ───────── bot avatar tiles ─────────
1010// Alive bots appear alongside human webcams in cams-top. Uses the bot's
1011// source /gen image as the portrait for now; a proper generated avatar
1012// comes later.
1013async function refreshBotRoster() {
1014    try {
1015        const r = await fetch("/api/bots");
1016        const j = await r.json();
1017        const alive = j.alive || [];
1018        const existing = new Map();
1019        for (const el of camsTopEl.querySelectorAll(".bot-avatar")) {
1020            existing.set(el.dataset.slug, el);
1021        }
1022        const seen = new Set();
1023        for (const b of alive) {
1024            seen.add(b.slug);
1025            let tile = existing.get(b.slug);
1026            if (!tile) {
1027                tile = document.createElement("div");
1028                tile.className = "bot-avatar";
1029                tile.dataset.slug = b.slug;
1030                tile.innerHTML = `<span class="ba-name"></span>`;
1031                tile.addEventListener("click", () => {
1032                    // Click a bot tile → open the full myspace-style
1033                    // profile panel (bio, wall, guestbook, custom HTML,
1034                    // inline DM composer). Bot avatar is the anch
1034or.
1035                    openProfilePanel(tile.dataset.slug);
1036                });
1037                camsTopEl.appendChild(tile);
1038            }
1039            tile.querySelector(".ba-name").textContent = b.name;
1040            // Prefer the dedicated profile picture; fall back to the
1041            // source /gen image if none yet. Empty when even the source
1042            // is missing — the username centered in the tile reads as a
1043            // placeholder on its own.
1044            const portrait = b.avatarImageUrl || b.imageUrl || null;
1045            if (portrait) {
1046                tile.style.backgroundImage = `url(${portrait})`;
1047                tile.dataset.img = portrait;
1048                tile.dataset.genId = String(b.avatarGenId || b.genId || "");
1049                tile.classList.remove("no-pic");
1050            } else {
1051                tile.style.backgroundImage = "";
1052                tile.dataset.img = "";
1053                tile.classList.add("no-pic");
1054            }
1055            tile.title = b.backstory || b.name;
1056        }
1057        // Drop tiles for bots that died / went hidden.
1058        for (const [slug, el] of existing) {
1059            if (!seen.has(slug)) el.remove();
1060        }
1061        if (alive.length) camsEl.classList.add("on");
1062    } catch {}
1063}
1064setInterval(refreshBotRoster, 15_000);   // cheap enough; bot list changes rarely
1065
1066// Messages starting with ".. " CONCATENATE onto the previous chat line
1067// from the same author (if that line was posted within 60s). Enables
1068// bots to stream radio-chatter beats that read as one continuous voice
1069// rather than a tower of mini-bubbles. Also usable by humans.
1070const CONTINUE_PREFIX = ".. ";
1071const CONTINUE_WINDOW_MS = 60 * 1000;
1072
1073function renderChat(m) {
1074    const key = renderKey(m);
1075    if (renderedIds.has(key)) return;
1076    renderedIds.add(key);
1077
1078    // Replace-the-ack path: when a substantive bot message arrives,
1079    // remove any pending ack row from the SAME bot. We walk backward
1080    // past non-message siblings (the feed-grid, sentinel, or other
1081    // chrome attached to logEl) and drop any ack within the last
1082    // 60s from this from. Multiple acks (rare) are all cleared.
1083    if (!m.is_ack && m.is_bot && !((m.text || "").startsWith(CONTINUE_PREFIX))) {
1084        const fromKey = m.from || "";
1085        // Up to 8 .msg rows back, drop ack rows from this bot.
1086        let row = logEl.lastElementChild;
1087        let scanned = 0;
1088        while (row && scanned < 12) {
1089            scanned++;
1090            if (row.classList?.contains("msg")) {
1091                if (row.dataset.isAck === "1"
1092                    && row.dataset.from === fromKey
1093                    && Math.abs(Date.now() - Number(row.dataset.ts)) < 60_000) {
1094                    const next = row.previousElementSibling;
1095                    row.remove();
1096                    row = next;
1097                    continue;
1098                }
1099                // Stop on first non-ack .msg from same bot — the ack
1100                // was already replaced earlier; nothing to do.
1101                if (row.dataset.from === fromKey) break;
1102            }
1103            row = row.previousElementSibling;
1104        }
1105    }
1106
1107    // Continuation path: if this message is from the same author as the
1108    // bottom-most .msg in the log AND starts with ".. " AND the prev is
1109    // fresh enough, append to the prev's body instead of adding a new
1110    // row. We still register the id in renderedIds so the broadcast
1111    // isn't re-processed if replayed via chat-history.
1112    if ((m.text || "").startsWith(CONTINUE_PREFIX)) {
1113        const prevRow = logEl.lastElementChild;
1114        if (prevRow?.classList.contains("msg")
1115            && prevRow.dataset.from === (m.from || "")
1116            && Math.abs(Date.now() - Number(prevRow.dataset.ts)) < CONTINUE_WINDOW_MS) {
1117            const body = prevRow.querySelector(".body");
1118            if (body) {
1119                const tail = (m.text || "").slice(CONTINUE_PREFIX.length).trim();
1120                const tailEl = document.createElement("span");
1121                tailEl.className = "msg-cont";
1122                // If the previous row was an instant ack ("on it." /
1123                // "hmph."), render the continuation as a plain space —
1124                // the leader already ends with punctuation. For organic
1125                // radio-chatter beats from the bot itself, keep the
1126                // visible ".." dot so the cadence is clear.
1127                if (prevRow.dataset.isAck === "1") {
1128                    tailEl.innerHTML = " " + linkify(esc(tail));
1129                } else {
1130                    tailEl.innerHTML = ` <span class="msg-cont-dot">..</span> ${linkify(esc(tail))}`;
1131                }
1132                body.appendChild(tailEl);
1133                // Update the ts so further ".. " messages can chain.
1134                prevRow.dataset.ts = String(m.ts || Date.now());
1135                // Clear the ack flag — once continued, this is no
1136                // longer just an ack but a substantive line.
1137                prevRow.dataset.isAck = "";
1138                return;
1139            }
1140        }
1141        // No prev to continue onto — fall through and render as a
1142        // regular line (stripping the ".. " so it doesn't look weird).
1143        m = { ...m, text: (m.text || "").slice(CONTINUE_PREFIX.length).trim() };
1144    }
1145
1146    const el = document.createElement("div"); el.className = "msg";
1147    el.dataset.from = m.from || "";
1148    el.dataset.ts = String(m.ts || Date.now());
1149    if (m.id) el.dataset.msgId = m.id;
1150    if (m.is_ack) el.dataset.isAck = "1";
1151    // Group with the immediately-preceding message if it's a .msg from the same
1152    // author within the window. Sys lines break the grouping (they're a
1153    // different DOM class) which is the desired behavior.
1154    const prev = logEl.lastElementChild;
1155    if (prev?.classList.contains("msg")
1156        && prev.dataset.from === el.dataset.from
1157        && Math.abs(Number(el.dataset.ts) - Number(prev.dataset.ts)) < GROUP_WINDOW_MS) {
1158        el.classList.add("grouped");
1159    }
1160    const mine = !!(m.from && account?.username && m.from === account.username);
1161    if (m.gen_id) el.dataset.genId = String(m.gen_id);
1162    if (m.priv) el.classList.add("priv");
1163
1164    // Images float right at the message that posted them. Text from THIS
1165    // row + every subsequent row flows around until the float clears at
1166    // the end of the image's natural height. We put the float OUTSIDE the
1167    // .body span so block-level wrapping works across messages.
1168    // width/height attributes give the browser intrinsic-ratio hints
1169    // before the bytes arrive — combined with .msg-attach's fixed
1170    // aspect-ratio CSS, the box doesn't reflow when the image lands.
1171    // 1024 reflects the typical parascene gen (square 1024×1024); the
1172    // attribute encodes ratio not pixel size, so non-1024 outputs still
1173    // render correctly under the responsive width.
1174    const attachHtml = m.attach_video
1175        ? `<a class="msg-attach msg-attach-video" data-gen-id="${esc(String(m.gen_id || ""))}">` +
1176          `<video src="${esc(m.attach_video)}" width="1024" height="1024" data-autoloop="1" muted loop playsinline preload="metadata"></video>` +
1177          (m.gen_id ? `<button type="button" class="img-id-badge" data-gen-id="${esc(String(m.gen_id))}" title="copy id">${esc(String(m.gen_id))}</button>` : "") +
1178          `</a>`
1179        : m.attach
1180        ? `<a class="msg-attach" href="${esc(m.attach)}" data-gen-id="${esc(String(m.gen_id || ""))}">` +
1181          `<img width="1024" height="1024" loading="lazy" src="${esc(m.attach)}" alt="">` +
1182          (m.gen_id ? `<button type="button" class="img-id-badge" data-gen-id="${esc(String(m.gen_id))}" title="copy id">${esc(String(m.gen_id))}</button>` : "") +
1183          `</a>`
1184        : "";
1185
1186    // Render synchronously first so the row appears instantly, then
1187    // resolve the async body (which does subtle.digest for todo ids) and
1188    // patch it in. Keeps the append-order stable while letting checkboxes
1189    // be actual DOM inputs.
1190    const audio = m.audio_url ? audioPlayerHtml(m.audio_url) : "";
1191    // Messages originating from an interactive wrap CLI session get a
1192    // hammer-and-pick symbol (U+2692 + VS15 for text presentation) after
1193    // the name, so it's visually obvious the bot is speaking from deep
1194    // inside a claude-code session rather than the lightweight
1195    // Messages-API tool-use path.
1196    const wrapBadge = m.via_wrap ? `<span class="via-wrap" title="speaking from an interactive claude-code session">\u2692\uFE0E</span>` : "";
1197    // Audio-first mode: when the message has a waveform, treat the text
1198    // (or the STT transcript for user voice notes) as a caption hidden
1199    // behind a hover/tap reveal, not a primary body line. The waveform
1200    // itself gets the main real estate. Both user and bot audio msgs
1201    // follow the same rule so the room doesn't show the spoken text
1202    // twice (once as waveform, once as typed body).
1203    const captionText = (m.audio_url && (m.text || m.transcript)) ? (m.text || m.transcript) : "";
1204    const audioFirst = !!m.audio_url;
1205    if (audioFirst) el.classList.add("audio-first");
1206    // Body holds either regular text, or (in audio-first mode) the hidden
1207    // caption which the .audio-first CSS rules display:none by default.
1208    let bodyText = audioFirst ? captionText : (m.text || "");
1209    // Aside detection: body shaped exactly like "({...})" is a wrap-CLI
1210    // muttered line. Strip the wrapping characters and tag .aside so it
1211    // renders dimmed/italic. is_ack messages are NOT asides — they're
1212    // the leader of a continuation pair ("on it." + ".. <real reply>")
1213    // and should render as regular chat text.
1214    if (!m.is_ack) {
1215        const m2 = bodyText.trim().match(/^\(\{([\s\S]+)\}\)$/);
1216        if (m2) {
1217            bodyText = m2[1].trim();
1218            el.classList.add("aside");
1219        }
1220    }
1221    // Admin: floating × on hover, upper-right of the username, absolutely
1222    // positioned so it doesn't reflow the row. Only rendered for the
1223    // admin user's own session (server still enforces on DELETE).
1224    const canDelete = !!account?.isAdmin && m.id;
1225    const delBadge = canDelete ? `<button class="msg-del" type="button" title="delete" data-id="${esc(m.id)}">×</button>` : "";
1226    if (canDelete) el.classList.add("can-del");
1227    el.innerHTML = attachHtml +
1228        `<span class="who">${esc(m.from)}${wrapBadge}${delBadge}</span> <span class="body">${audio}${audioFirst && captionText ? '<span class="audio-caption">' + linkify(esc(bodyText)) + '</span>' : linkify(esc(bodyText))}</span>`;
1229    // If the chat/feed boundary is mounted, insert this row BEFORE it
1230    // so messages stay above the boundary + grid in DOM order.
1231    const boundary = logEl.querySelector(".chat-feed-boundary");
1232    if (boundary) {
1233        logEl.insertBefore(el, boundary);
1234    } else if (feedGridEl && feedGridEl.parentNode === logEl) {
1235        logEl.insertBefore(el, feedGridEl);
1236    } else {
1237        logEl.appendChild(el);
1238    }
1239    // Async replace for todo/newline rendering. In audio-first mode we
1240    // keep the waveform first and the caption hidden as a CSS-controlled
1241    // reveal; in normal mode the audio (if any) trails the body text.
1242    const msgIdForTodo = m.id || `${m.from}-${m.ts}`;
1243    renderMessageBody(msgIdForTodo, bodyText).then(html => {
1244        const body = el.querySelector(".body");
1245        if (!body) return;
1246        if (audioFirst) {
1247            body.innerHTML = audio + (captionText ? `<span class="audio-caption">${html}</span>` : "");
1248        } else {
1249            body.innerHTML = html + audio;
1250        }
1251        if (m.audio_url) {
1252            const player = el.querySelector(".audio-msg");
1253            wireAudioPlayer(player);
1254            player?.__drawWave?.();
1255            // Auto-play contiguous: if the user just recorded audio, any
1256            // bot audio arriving within the window plays automatically.
1257            const recentMicSend = (Date.now() - lastUserMicTs) < AUTO_PLAY_WINDOW_MS;
1258            if (recentMicSend && m.is_bot && !document.hidden) {
1259                try {
1260                    const btn = player?.querySelector(".audio-play");
1261                    btn?.click();
1262                } catch {}
1263            }
1264        }
1265        // Suno share-links → resolve to direct mp3 + render inline player
1266        // with cover art and waveform. We attach AFTER the message body so
1267        // the original link stays clickable (small text), and the player
1268        // appears as a card under it.
1269        expandSunoLinks(el, bodyText);
1270    });
1271
1272    // Click attachment opens the lightbox (parascene integration in there).
1273    // Click on the floating id-badge copies the id and stops the lightbox.
1274    const attachEl = el.querySelector(".msg-attach");
1275    if (attachEl) {
1276        attachEl.addEventListener("click", (e) => {
1277            const badge = e.target.closest(".img-id-badge");
1278            if (badge) {
1279                e.preventDefault(); e.stopPropagation();
1280                copyIdBadge(badge);
1281                return;
1282            }
1283            e.preventDefault();
1284            openLightbox(attachEl.href, attachEl.dataset.genId || null);
1285        });
1286    }
1287}
1288
1289// Visual feedback: copy id, briefly flip the badge to "copied".
1290async function copyIdBadge(badge) {
1291    const id = badge?.dataset.genId;
1292    if (!id) return;
1293    try {
1294        await navigator.clipboard.writeText(id);
1295        const orig = badge.textContent;
1296        badge.textContent = "copied";
1297        badge.classList.add("img-id-badge-copied");
1298        setTimeout(() => { badge.textContent = orig; badge.classList.remove("img-id-badge-copied"); }, 800);
1299    } catch {
1300        // Fallback: select-and-execCommand (older Safari).
1301        try {
1302            const r = document.createRange();
1303            const span = document.createElement("span");
1304            span.textContent = id;
1305            document.body.appendChild(span);
1306            r.selectNode(span);
1307            getSelection().removeAllRanges();
1308            getSelection().addRange(r);
1309            document.execCommand("copy");
1310            getSelection().removeAllRanges();
1311            span.remove();
1312            badge.textContent = "copied";
1313            setTimeout(() => { badge.textContent = id; }, 800);
1314        } catch {}
1315    }
1316}
1317
1318// Admin: delegated click for the floating × delete glyph. One listener
1319// on the log handles every row without per-render wiring. The server
1320// re-checks admin; the × is just a UI hint.
1321//   plain click  → delete that one message
1322//   shift+click  → delete that message AND every message after it in
1323//                  the room (admin/dadchords only). Title flips to
1324//                  "delete all after" while shift is held.
1325logEl.addEventListener("click", async (e) => {
1326    const btn = e.target.closest(".msg-del");
1327    if (btn) {
1328        e.preventDefault();
1329        e.stopPropagation();
1330        const id = btn.dataset.id;
1331        if (!id) return;
1332        const cascade = !!e.shiftKey;
1333        if (cascade && !confirm("Delete this message AND all messages after it?")) return;
1334        btn.disabled = true;
1335        try {
1336            const url = cascade
1337                ? `/api/chat/${encodeURIComponent(id)}?after=1`
1338                : `/api/chat/${encodeURIComponent(id)}`;
1339            const r = await fetch(url, { method: "DELETE" });
1340            if (!r.ok) { btn.disabled = false; return; }
1341            const j = await r.json().catch(() => ({}));
1342            if (cascade && Array.isArray(j.deleted)) {
1343                for (const did of j.deleted) applyChatDelete(did);
1344            } else {
1345                applyChatDelete(id);
1346            }
1347        } catch {
1348            btn.disabled = false;
1349        }
1350        return;
1351    }
1352    // Click on usernames in the chat log no longer opens a profile —
1353    // the deep-link routes (slopstation.ai/<user>) and the right-rail
1354    // tile click are still available. Profile open via the chat log
1355    // was too easy to misclick on a small mobile target.
1356});
1357
1358// Shift-key live label: while shift is held, the × tooltip flips to
1359// "delete all after". Avoids surprise — the user sees the cascade
1360// hint before clicking.
1361function setDelTitles(cascade) {
1362    const text = cascade ? "delete all after" : "delete";
1363    for (const b of logEl.querySelectorAll(".msg-del")) b.title = text;
1364}
1365window.addEventListener("keydown", (e) => { if (e.key === "Shift") setDelTitles(true); });
1366window.addEventListener("keyup",   (e) => { if (e.key === "Shift") setDelTitles(false); });
1367window.addEventListener("blur",    () => setDelTitles(false));
1368
1369function applyChatDelete(id) {
1370    if (!id) return;
1371    const row = logEl.querySelector(`.msg[data-msg-id="${CSS.escape(id)}"]`);
1372    if (row) row.remove();
1373    renderedIds.delete("id:" + id);
1374}
1375// ───────── inline voice-message player ─────────
1376// Minimal inline component: tiny play/pause button, ~48 thin bars for a
1377// waveform, same height as text so chat lines stay dense. The waveform is
1378// drawn from the decoded audio buffer the first time the user plays (we
1379// don't eagerly fetch 48 bars × N messages — that'd hammer the CDN on
1380// load). Before playing, a flat placeholder strip is shown.
1381
1382// ───────── universal audio transport ─────────
1383//
1384// Single global "current track". Every wireAudioPlayer registers its
1385// HTMLAudioElement here on first ensureAudio. Playing one stops any
1386// other. On 'ended', auto-advance to the next registered track in
1387// document order so a queue of voice messages / Suno songs flows
1388// continuously. The transport row above the send button drives this
1389// from outside the chat (prev / play-pause / next).
1390const audioTransport = {
1391    tracks: [],          // [{ audio, el }]  — el is the .audio-msg DOM
1392    current: null,       // currently active track (audio + el)
1393    register(audio, el) {
1394        // De-dupe by Audio object.
1395        if (this.tracks.some(t => t.audio === audio)) return;
1396        this.tracks.push({ audio, el });
1397        audio.addEventListener("play",   () => this._onPlay(audio));
1398        audio.addEventListener("pause",  () => this._onPauseOrEnd());
1399        audio.addEventListener("ended",  () => this._onEnded(audio));
1400        this._show();
1401    },
1402    _onPlay(audio) {
1403        // Pause everything else.
1404        for (const t of this.tracks) {
1405            if (t.audio !== audio && !t.audio.paused) try { t.audio.pause(); } catch {}
1406        }
1407        this.current = this.tracks.find(t => t.audio === audio) || null;
1408        this._refresh();
1409    },
1410    _onPauseOrEnd() { this._refresh(); },
1411    _onEnded(audio) {
1412        const idx = this.tracks.findIndex(t => t.audio === audio);
1413        if (idx < 0) return;
1414        // Reset position so a future play starts from the top, not stuck at end.
1415        try { audio.currentTime = 0; } catch {}
1416        const next = this.tracks[idx + 1];
1417        if (next) {
1418            try { next.audio.play().catch(() => {}); } catch {}
1419        }
1420        this._refresh();
1421    },
1422    _show() {
1423        const wrap = document.getElementById("transport");
1424        if (wrap) wrap.hidden = false;
1425    },
1426    _refresh() {
1427        const playBtn = document.getElementById("trPlay");
1428        if (!playBtn) return;
1429        const playing = this.current && !this.current.audio.paused;
1430        playBtn.textContent = playing ? "❚❚" : "▷";
1431        playBtn.classList.toggle("tr-active", !!playing);
1432    },
1433    play() {
1434        // If something's already current, toggle. Otherwise start the first.
1435        const target = this.current || this.tracks[0];
1436        if (!target) return;
1437        if (target.audio.paused) {
1438            this.current = target;
1439            try { target.audio.play().catch(() => {}); } catch {}
1440        } else {
1441            try { target.audio.pause(); } catch {}
1442        }
1443    },
1444    next() {
1445        if (!this.tracks.length) return;
1446        const idx = this.current ? this.tracks.findIndex(t => t === this.current) : -1;
1447        const target = this.tracks[Math.min(this.tracks.length - 1, idx + 1)];
1448        if (!target) return;
1449        this._stopAllExcept(target.audio);
1450        this.current = target;
1451        try { target.audio.play().catch(() => {}); } catch {}
1452    },
1453    prev() {
1454        if (!this.tracks.length) return;
1455        const idx = this.current ? this.tracks.findIndex(t => t === this.current) : 0;
1456        const target = this.tracks[Math.max(0, idx - 1)];
1457        if (!target) return;
1458        this._stopAllExcept(target.audio);
1459        this.current = target;
1460        try { target.audio.play().catch(() => {}); } catch {}
1461    },
1462    _stopAllExcept(keep) {
1463        for (const t of this.tracks) {
1464            if (t.audio !== keep && !t.audio.paused) try { t.audio.pause(); t.audio.currentTime = 0; } catch {}
1465        }
1466    },
1467};
1468// Wire transport buttons.
1469document.getElementById("trPlay")?.addEventListener("click", () => audioTransport.play());
1470document.getElementById("trNext")?.addEventListener("click", () => audioTransport.next());
1471document.getElementById("trPrev")?.addEventListener("click", () => audioTransport.prev());
1472
1473// Detect any suno.com share/song URLs in this message's text, resolve
1474// them server-side to direct mp3 + cover, and append an inline player
1475// card to the message row. One card per link, capped at 3 to avoid
1476// abuse. Cached on the resolver side;
1476 re-render is a no-op for the
1477// same msg row (we tag with .suno-card data-share so we don't double-add).
1478async function expandSunoLinks(rowEl, text) {
1479    if (!text) return;
1480    const matches = String(text).match(/https?:\/\/suno\.com\/(?:s|song)\/[A-Za-z0-9_-]+/g);
1481    if (!matches?.length) return;
1482    // Hide the original suno link in the message body now that the
1483    // card replaces it. We strip the rendered <a> for each matched
1484    // URL — keep any prefix/suffix text the user typed around it.
1485    const body = rowEl.querySelector(".body");
1486    if (body) {
1487        for (const a of body.querySelectorAll("a.url-btn")) {
1488            if (matches.includes(a.href)) a.remove();
1489        }
1490    }
1491    const seen = new Set();
1492    const urls = matches.filter(u => {
1493        if (seen.has(u)) return false;
1494        seen.add(u);
1495        return true;
1496    }).slice(0, 3);
1497    for (const url of urls) {
1498        // Don't re-add if a card for this link already rendered.
1499        if (rowEl.querySelector(`.suno-card[data-url="${CSS.escape(url)}"]`)) continue;
1500        // Placeholder so the layout reserves space while resolving.
1501        const card = document.createElement("div");
1502        card.className = "suno-card suno-loading";
1503        card.dataset.url = url;
1504        card.innerHTML = `<div class="suno-card-inner"><div class="suno-thumb"></div><div class="suno-meta"><div class="suno-title">loading…</div></div></div>`;
1505        rowEl.appendChild(card);
1506        try {
1507            const r = await fetch("/api/suno-resolve?url=" + encodeURIComponent(url));
1508            if (!r.ok) { card.classList.remove("suno-loading"); card.classList.add("suno-error"); card.querySelector(".suno-title").textContent = "couldn't resolve suno link"; continue; }
1509            const j = await r.json();
1510            const title = j.title || "suno track";
1511            const cover = j.image_url || "";
1512            const audio = j.audio_url;
1513            card.classList.remove("suno-loading");
1514            // Both kinds use the same compact 64×64-thumb card layout.
1515            // Video shares get a silent autoplay-loop <video> in the
1516            // thumb slot (no controls, plays as an animated thumbnail).
1517            // Audio is still wired through the waveform play button so
1518            // the user can opt in to hearing the song.
1519            const thumbHtml = (j.kind === "video" && j.video_url)
1520                ? `<video class="suno-thumb suno-thumb-video" data-autoloop="1" muted loop playsinline
1521                          ${cover ? `poster="${esc(cover)}"` : ""}
1522                          src="${esc(j.video_url)}"></video>`
1523                : (cover
1524                    ? `<a class="suno-thumb" href="${esc(url)}" target="_blank" rel="noopener noreferrer" style="background-image:url(${esc(cover)})"></a>`
1525                    : `<div class="suno-thumb"></div>`);
1526            card.innerHTML = `
1527              <div class="suno-card-inner">
1528                ${thumbHtml}
1529                <div class="suno-meta">
1530                  <div class="suno-title">${esc(title)}</div>
1531                  ${audio ? audioPlayerHtml(audio) : ""}
1532                </div>
1533              </div>`;
1534            const player = card.querySelector(".audio-msg");
1535            if (player) wireAudioPlayer(player);
1536        } catch (e) {
1537            card.classList.remove("suno-loading");
1538            card.classList.add("suno-error");
1539            card.querySelector(".suno-title").textContent = "suno fetch failed";
1540        }
1541    }
1542}
1543
1544function audioPlayerHtml(url) {
1545    const u = esc(url);
1546    return `<span class="audio-msg" data-src="${u}" aria-label="voice message">` +
1547             `<button class="audio-play" type="button" aria-label="play"><span class="audio-play-glyph">▶</span></button>` +
1548             `<canvas class="audio-wave" width="192" height="14"></canvas>` +
1549           `</span>`;
1550}
1551
1552function wireAudioPlayer(el) {
1553    if (!el) return;
1554    const src = el.dataset.src;
1555    const btn = el.querySelector(".audio-play");
1556    const glyph = el.querySelector(".audio-play-glyph");
1557    const canvas = el.querySelector(".audio-wave");
1558    let audio = null;
1559    let drawn = false;
1560
1561    async function ensureAudio() {
1562        if (audio) return audio;
1563        audio = new Audio(src);
1564        audio.preload = "metadata";
1565        audio.addEventListener("ended", () => { glyph.textContent = "▶"; });
1566        audio.addEventListener("pause", () => { glyph.textContent = "▶"; });
1567        audio.addEventListener("play", () => { glyph.textContent = "▮▮"; });
1568        // Register with the universal transport so prev/play/next can
1569        // drive this audio, and so playing it stops every other audio
1570        // on the page (one track at a time).
1571        audioTransport.register(audio, el);
1572        return audio;
1573    }
1574
1575    async function drawWave() {
1576        if (drawn) return;
1577        drawn = true;
1578        try {
1579            // AudioContext creation requires a user gesture on iOS/Safari,
1580            // so lazy-create on first interaction or when a context
1581            // already exists from the mic flow.
1582            const Ctx = window.AudioContext || window.webkitAudioContext;
1583            if (!audioCtx && Ctx) {
1584                try { audioCtx = new Ctx(); } catch { /* may throw pre-gesture */ }
1585            }
1586            if (!audioCtx) { drawn = false; return; }  // retry later on click
1587            const resp = await fetch(src);
1588            if (!resp.ok) {
1589                // Audio file is gone (most often a stale chat-log entry
1590                // referring to a long-ago-deleted opus). Hide the
1591                // player rather than leaving a broken control. Silent
1592                // — don't spam the console for known-missing files.
1593                el.classList.add("audio-msg-missing");
1594                el.setAttribute("title", "audio unavailable");
1595                return;
1596            }
1597            const buf = await resp.arrayBuffer();
1598            const decoded = await audioCtx.decodeAudioData(buf.slice(0));
1599            const data = decoded.getChannelData(0);
1600            const bars = Math.floor(canvas.width / 3);    // 3px per bar incl. gap
1601            const step = Math.max(1, Math.floor(data.length / bars));
1602            const ctx2 = canvas.getContext("2d");
1603            ctx2.clearRect(0, 0, canvas.width, canvas.height);
1604            ctx2.fillStyle = "rgba(228, 228, 228, 0.85)";
1605            for (let i = 0; i < bars; i++) {
1606                let peak = 0;
1607                const start = i * step;
1608                const end = Math.min(start + step, data.length);
1609                for (let j = start; j < end; j++) {
1610                    const v = Math.abs(data[j]);
1611                    if (v > peak) peak = v;
1612                }
1613                const h = Math.max(1, peak * canvas.height);
1614                const x = i * 3;
1615                const y = (canvas.height - h) / 2;
1616                ctx2.fillRect(x, y, 2, h);
1617            }
1618        } catch (e) {
1619            drawn = false;
1620            console.warn("[audio-wave]", e.message);
1621        }
1622    }
1623    // Expose so renderChat can call it right after appending the element.
1624    el.__drawWave = drawWave;
1625
1626    // Waveform tap toggles the caption — hover works on desktop via CSS
1627    // alone; mobile needs this click handler so users without hover can
1628    // reveal the text behind a voice note.
1629    canvas.addEventListener("click", (ev) => {
1630        ev.stopPropagation();
1631        el.closest(".msg")?.classList.toggle("caption-open");
1632    });
1633
1634    btn.addEventListener("click", async (ev) => {
1635        ev.stopPropagation();
1636        if (el.classList.contains("audio-msg-missing")) return;
1637        const a = await ensureAudio();
1638        if (a.paused) {
1639            drawWave();
1640            try { await a.play(); } catch (e) {
1641                // 404 / decode error → mark missing instead of spamming.
1642                if (a.error?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED ||
1643                    a.error?.code === MediaError.MEDIA_ERR_NETWORK) {
1644                    el.classList.add("audio-msg-missing");
1645                    el.setAttribute("title", "audio unavailable");
1646                } else {
1647                    console.warn("[audio-play]", e.message);
1648                }
1649            }
1650        } else {
1651            a.pause();
1652        }
1653    });
1654}
1655
1656// ───────── self profile panel ─────────
1657// Click own me-tile → full-bleed panel with current avatar, history stack
1658// of past avatars, and a grid of the user's liked parascene creations.
1659// Picking any image becomes the new avatar; the prior one is pushed to
1660// history for one-click revert. Placeholder for future user settings.
1661const profilePanelEl = document.getElementById("profilePanel");
1662document.getElementById("ppClose").addEventListener("click", () => {
1663    profilePanelEl.hidden = true;
1664});
1665document.getElementById("ppClearBtn").addEventListener("click", async () => {
1666    await fetch("/api/account/avatar", { method: "DELETE" });
1667    await loadAccount();
1668    renderProfilePanel();
1669});
1670// Escape closes.
1671document.addEventListener("keydown", (e) => {
1672    if (e.key === "Escape" && !profilePanelEl.hidden) profilePanelEl.hidden = true;
1673});
1674
1675async function openAvatarPicker() {
1676    profilePanelEl.hidden = false;
1677    await renderProfilePanel();
1678}
1679
1680async function renderProfilePanel() {
1681    if (!account) { profilePanelEl.hidden = true; return; }
1682    const cur = document.getElementById("ppCurrent");
1683    // Remove any prior video sibling we injected.
1684    cur.parentNode?.querySelector("video.pp-current-video")?.remove();
1685    if (account.avatarUrl && isVideoAvatar(account.avatarUrl)) {
1686        cur.style.display = "none";
1687        const v = document.createElement("video");
1688        v.className = "pp-current pp-current-video";
1689        v.src = account.avatarUrl;
1690        v.muted = true; v.loop = true; v.playsInline = true;
1691        v.preload = "metadata";
1692        v.dataset.autoloop = "1";
1693        cur.parentNode?.insertBefore(v, cur.nextSibling);
1694    } else {
1695        cur.style.display = "";
1696        cur.src = account.avatarUrl || "";
1697        cur.alt = account.avatarUrl ? "" : "(no avatar)";
1698    }
1699    document.getElementById("ppHandle").textContent = "@" + account.username;
1700    const hint = document.getElementById("ppHint");
1701    hint.textContent = account.avatarUrl
1702        ? "tap any image below to set as your avatar. your previous avatars stay in history."
1703        : "no avatar set yet — pick one below.";
1704
1705    // History
1706    const histEl = document.getElementById("ppHistory");
1707    const hist = account.avatarHistory || [];
1708    if (!hist.length) {
1709        histEl.innerHTML = `<div class="pp-grid-empty">no past avatars yet.</div>`;
1710    } else {
1711        histEl.innerHTML = hist.map(url =>
1712            `<div class="pp-cell${url === account.avatarUrl ? " current" : ""}" data-url="${esc(url)}" style="background-image:url(${esc(url)})"></div>`
1713        ).join("");
1714    }
1715    // grid click handling is delegated at the panel root (see below).
1716
1717    // Liked parascene creations
1718    const likesEl = document.getElementById("ppLikes");
1719    const likesHint = document.getElementById("ppLikesHint");
1720    if (!account.hasParasceneKey) {
1721        likesEl.innerHTML = "";
1722        likesHint.textContent = "set /key psn_… to see your parascene creations here.";
1723        return;
1724    }
1725    likesHint.textContent = "loading…";
1726    likesEl.innerHTML = "";
1727    try {
1728        const r = await fetch("/api/account/parascene-likes");
1729        const j = await r.json();
1730        if (!r.ok) throw new Error(j.error || "failed");
1731        if (!j.items?.length) {
1732            likesEl.innerHTML = `<div class="pp-grid-empty">no parascene creations yet.</div>`;
1733            likesHint.textContent = j.note || "";
1734            return;
1735        }
1736        likesEl.innerHTML = j.items.map(it => {
1737            const src = `/api/parascene/image/${encodeURIComponent(it.id)}`;
1738            return `<div class="pp-cell" data-parascene-id="${esc(it.id)}" style="background-image:url(${esc(src)})"></div>`;
1739        }).join("");
1740        likesHint.textContent = `${j.items.length} creations (newest first).`;
1741    } catch (e) {
1742        likesEl.innerHTML = `<div class="pp-grid-empty">couldn't load likes: ${esc(e.message)}</div>`;
1743        likesHint.textContent = "";
1744    }
1745}
1746
1747// Single delegated click handler for the profile grids. Bound once to the
1748// panel root — no new listeners per render. Grids are re-populated by
1749// renderProfilePanel; their children inherit this one handler.
1750profilePanelEl.addEventListener("click", async (e) => {
1751    const cell = e.target.closest(".pp-cell");
1752    if (!cell) return;
1753    let url = cell.dataset.url;
1754    if (!url && cell.dataset.parasceneId) {
1755        cell.style.opacity = "0.5";
1756        const importRes = await fetch("/api/parascene/import", {
1757            method: "POST",
1758            headers: { "Content-Type": "application/json" },
1759            body: JSON.stringify({ parascene_id: cell.dataset.parasceneId }),
1760        });
1761        const imp = await importRes.json();
1762        cell.style.opacity = "";
1763        if (!importRes.ok) { alert("import failed: " + (imp.error || "unknown")); return; }
1764        url = imp.image_url;
1765    }
1766    if (!url) return;
1767    const r = await fetch("/api/account/avatar", {
1768        method: "POST",
1769        headers: { "Content-Type": "application/json" },
1770        body: JSON.stringify({ url }),
1771    });
1772    const j = await r.json().catch(() => ({}));
1773    if (!r.ok) { alert("couldn't set avatar: " + (j.error || "unknown")); return; }
1774    await loadAccount();
1775    renderProfilePanel();
1776});
1777
1778// Lightbox — fullscreen image with parascene integration:
1779//   - publish toggle (minimal gray text button)
1780//   - "view on parascene" link (new tab)
1781//   - comments panel pulled from parascene /activity
1782//   - comment input (only usable with a parascene API key saved)
1783//
1784// Layout: flex row on desktop (cmts L, image center, input R). Stacked on
1785// mobile (input top, image middle, cmts bottom). Pure CSS via media query.
1786function openLightbox(src, genId = null) {
1787    const overlay = document.createElement("div");
1788    overlay.className = "lightbox";
1789    overlay.setAttribute("role", "dialog");
1790    overlay.setAttribute("aria-modal", "true");
1791    overlay.innerHTML = `
1792        <button class="lb-close" type="button" aria-label="close">×</button>
1793        <div class="lb-layout">
1794            <aside class="lb-panel lb-comments" aria-label="comments">
1795                <div class="lb-panel-title">comments</div>
1796                <div class="lb-comments-list"></div>
1797            </aside>
1798            <div class="lb-image-wrap">
1799                <img class="lb-image" alt="">
1800                <div class="lb-char-overlay"></div>
1801                <div class="lb-controls"></div>
1802            </div>
1803            <aside class="lb-panel lb-conjure" aria-label="characters">
1804                <div class="lb-panel-title">characters</div>
1805                <div class="lb-char-list"><div class="lb-empty lb-char-status">loading…</div></div>
1806            </aside>
1807            <aside class="lb-panel lb-composer" aria-label="add comment">
1808                <div class="lb-panel-title">add comment</div>
1809                <form class="lb-comment-form">
1810                    <textarea class="lb-comment-input" placeholder="say something on parascene…" rows="3"></textarea>
1811                    <button type="submit" class="lb-comment-send">send</button>
1812                </form>
1813                <div class="lb-comment-hint"></div>
1814            </aside>
1815        </div>`;
1816
1817    const img = overlay.querySelector(".lb-image");
1818    img.src = src;
1819
1820    const close = overlay.querySelector(".lb-close");
1821    const commentsList = overlay.querySelector(".lb-comments-list");
1822    const controls = overlay.querySelector(".lb-controls");
1823    const commentForm = overlay.querySelector(".lb-comment-form");
1824    const commentInput = overlay.querySelector(".lb-comment-input");
1825    const commentHint = overlay.querySelector(".lb-comment-hint");
1826
1827    const onKey = (e) => { if (e.key === "Escape") dismiss(); };
1828    const dismiss = () => {
1829        overlay.remove();
1830        document.removeEventListener("keydown", onKey);
1831    };
1832    overlay.addEventListener("click", (e) => {
1833        if (e.target === overlay) dismiss();
1834    });
1835    close.addEventListener("click", dismiss);
1836    img.addEventListener("click", (e) => e.stopPropagation());
1837    document.addEventListener("keydown", onKey);
1838
1839    document.body.appendChild(overlay);
1840
1841    // Without a gen_id there's no parascene creation attached (e.g. kamaji
1842    // or bot avatars before backfill). Hide parascene-coupled panels.
1843    if (!genId || !/^\d+$/.test(String(genId))) {
1844        overlay.querySelector(".lb-comments").hidden = true;
1845        overlay.querySelector(".lb-composer").hidden = true;
1846        overlay.querySelector(".lb-conjure").hidden = true;
1847        return;
1848    }
1849
1850    // ───── characters panel: tap-to-conjure inline ─────
1851    // Shows a bbox marker on the image per detected character and a card
1852    // in the side panel with either a "conjure" button or a "live as
1853    // @slug" badge if that character is already a bot.
1854    const charOverlay = overlay.querySelector(".lb-char-overlay");
1855    const charList = overlay.querySelector(".lb-char-list");
1856    const charStatus = overlay.querySelector(".lb-char-status");
1857    async function loadCharacters({ allowAnalyze } = { allowAnalyze: true }) {
1858        const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/characters`);
1859        if (r.status === 404 && allowAnalyze) {
1860            // No analysis on disk yet. Kick one and retry once.
1861            charStatus.textContent = "analyzing…";
1862            const a = await fetch(`/api/gen/${encodeURIComponent(genId)}/analyze`, { method: "POST" });
1863            if (!a.ok) {
1864                const j = await a.json().catch(() => ({}));
1865                charStatus.textContent = j.error === "budget_exhausted"
1866                    ? "out of daily budget"
1867                    : "couldn't analyze this image";
1868                return;
1869            }
1870            return loadCharacters({ allowAnalyze: false });
1871        }
1872        const j = await r.json().catch(() => ({}));
1873        if (!r.ok || !j.characters?.length) {
1874            charStatus.textContent = "no characters detected in this image";
1875            return;
1876        }
1877        renderCharacters(j.characters);
1878    }
1879    function renderCharacters(chars) {
1880        // Dot markers over the image.
1881        charOverlay.innerHTML = chars.map((c, i) => {
1882            if (!c.bbox) return "";
1883            const cx = (c.bbox.x + c.bbox.w / 2) * 100;
1884            const cy = (c.bbox.y + c.bbox.h / 2) * 100;
1885            const cls = c.conjured_as ? " lb-char-dot-live" : "";
1886            return `<button type="button" class="lb-char-dot${cls}" data-idx="${i}" style="left:${cx.toFixed(1)}%;top:${cy.toFixed(1)}%;">${i + 1}</button>`;
1887        }).join("");
1888        // Cards in the side panel.
1889        charList.innerHTML = chars.map((c, i) => {
1890            const name = esc(c.conjured_as?.name || c.name_hint || `character ${i + 1}`);
1891            const desc = esc(c.generic_description || "");
1892            if (c.conjured_as) {
1893                const bio = esc(c.conjured_as.backstory || "");
1894                return `<div class="lb-char-card live" data-idx="${i}">
1895                    <div class="lb-char-head"><span class="lb-char-num">${i + 1}</span> @${name} <span class="lb-char-badge">live</span></div>
1896                    ${bio ? `<div class="lb-char-bio">${bio}</div>` : ""}
1897                </div>`;
1898            }
1899            return `<div class="lb-char-card" data-idx="${i}">
1900                <div class="lb-char-head"><span class="lb-char-num">${i + 1}</span> ${name}</div>
1901                ${desc ? `<div class="lb-char-bio">${desc}</div>` : ""}
1902                <button type="button" class="btn lb-char-conjure" data-idx="${i}">
1902conjure</button>
1903            </div>`;
1904        }).join("");
1905    }
1906    async function doConjure(idx, btn) {
1907        const originalText = btn ? btn.textContent : "";
1908        if (btn) { btn.disabled = true; btn.textContent = "conjuring…"; }
1909        try {
1910            const r = await fetch("/api/conjure/create", {
1911                method: "POST",
1912                headers: { "Content-Type": "application/json" },
1913                body: JSON.stringify({ character_index: idx, gen_id: genId }),
1914            });
1915            const j = await r.json().catch(() => ({}));
1916            if (!r.ok) {
1917                const msg = j.hint || j.error || j.message || "unknown";
1918                if (btn) { btn.textContent = "failed: " + msg; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2500); }
1919                return;
1920            }
1921            // Re-render so the now-conjured character flips to "live".
1922            loadCharacters({ allowAnalyze: false });
1923        } catch (e) {
1924            if (btn) { btn.textContent = "error"; setTimeout(() => { btn.textContent = originalText; btn.disabled = false; }, 2500); }
1925        }
1926    }
1927    overlay.addEventListener("click", (e) => {
1928        const conjureBtn = e.target.closest(".lb-char-conjure");
1929        if (conjureBtn) {
1930            e.stopPropagation();
1931            doConjure(parseInt(conjureBtn.dataset.idx, 10), conjureBtn);
1932            return;
1933        }
1934        const dot = e.target.closest(".lb-char-dot");
1935        if (dot) {
1936            e.stopPropagation();
1937            // Scroll the matching card into view + highlight briefly.
1938            const idx = dot.dataset.idx;
1939            const card = overlay.querySelector(`.lb-char-card[data-idx="${idx}"]`);
1940            if (card) {
1941                card.scrollIntoView({ behavior: "smooth", block: "center" });
1942                card.classList.add("lb-char-flash");
1943                setTimeout(() => card.classList.remove("lb-char-flash"), 800);
1944            }
1945        }
1946    });
1947    loadCharacters().catch(e => { charStatus.textContent = "load error"; console.warn("[lb-conjure]", e); });
1948
1949    // ───── controls row (publish toggle + open-on-parascene) ─────
1950    const hasKey = !!account?.hasParasceneKey;
1951    const pubBtn = document.createElement("button");
1952    pubBtn.type = "button";
1953    pubBtn.className = "lb-mini-btn lb-publish";
1954    pubBtn.textContent = "publish on parascene";
1955    pubBtn.disabled = !hasKey;
1956    if (!hasKey) pubBtn.title = "set /key psn_… to toggle publish";
1957
1958    const openBtn = document.createElement("a");
1959    openBtn.className = "lb-mini-btn lb-open";
1960    openBtn.textContent = "view on parascene ↗";
1961    openBtn.target = "_blank";
1962    openBtn.rel = "noopener noreferrer";
1963    // If the viewer has a linked parascene account, prefer the canonical
1964    // /creations/<id> URL — readable, stable, and parascene logs them in
1965    // automatically. Otherwise fall back to the sh.parascene share URL
1966    // (which we lazy-fetch below) so anonymous viewers can still see it.
1967    if (hasKey && genId) {
1968        openBtn.href = `https://www.parascene.com/creations/${encodeURIComponent(genId)}`;
1969    } else {
1970        openBtn.href = "#";
1971    }
1972
1973    controls.appendChild(pubBtn);
1974    controls.appendChild(openBtn);
1975
1976    // Hide composer if no parascene key.
1977    if (!hasKey) {
1978        commentInput.disabled = true;
1979        commentInput.placeholder = "set /key psn_… to comment";
1980        overlay.querySelector(".lb-comment-send").disabled = true;
1981        commentHint.textContent = "no parascene key — can view comments, can't post.";
1982    }
1983
1984    // For anonymous viewers (no parascene key linked) the only working
1985    // URL is the sh.parascene share permalink. With a linked key we
1986    // already point at /creations/<id> above and don't bother minting
1987    // a share URL.
1988    if (!hasKey) {
1989        // Show button anyway — server-side resolves the share URL.
1990        fetch(`/api/gen/${encodeURIComponent(genId)}/public-share`)
1991            .then(r => r.json())
1992            .then(j => { if (j.ok && j.url) openBtn.href = j.url; else openBtn.hidden = true; })
1993            .catch(() => { openBtn.hidden = true; });
1994    }
1995
1996    // ───── publish toggle ─────
1997    async function togglePublish() {
1998        const pressed = pubBtn.getAttribute("aria-pressed") === "true";
1999        const want = !pressed;
2000        pubBtn.disabled = true;
2001        try {
2002            const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/publish`, {
2003                method: "POST",
2004                headers: { "Content-Type": "application/json" },
2005                body: JSON.stringify({ published: want }),
2006            });
2007            const j = await r.json().catch(() => ({}));
2008            if (!r.ok) {
2009                pubBtn.textContent = "publish failed";
2010                setTimeout(() => { pubBtn.textContent = pressed ? "unpublish" : "publish on parascene"; }, 1500);
2011                return;
2012            }
2013            const now = !!j.published;
2014            pubBtn.setAttribute("aria-pressed", now ? "true" : "false");
2015            pubBtn.textContent = now ? "unpublish from parascene" : "publish on parascene";
2016        } finally {
2017            pubBtn.disabled = false;
2018        }
2019    }
2020    pubBtn.addEventListener("click", togglePublish);
2021
2022    // ───── comments loader ─────
2023    async function loadComments() {
2024        if (!hasKey) { commentsList.innerHTML = "<div class=\"lb-empty\">sign in with a parascene key to see comments</div>"; return; }
2025        commentsList.innerHTML = "<div class=\"lb-empty\">loading…</div>";
2026        try {
2027            const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/activity`);
2028            const j = await r.json();
2029            if (!r.ok) throw new Error(j.error || "failed");
2030            const comments = (j.items || []).filter(it => it.type === "comment" || it.body || it.text);
2031            if (!comments.length) {
2032                commentsList.innerHTML = "<div class=\"lb-empty\">no comments yet</div>";
2033                return;
2034            }
2035            commentsList.innerHTML = comments.map(c => {
2036                const who = c.user?.user_name || c.author_user_name || c.user_name || "?";
2037                const text = c.text || c.body || "";
2038                return `<div class="lb-comment"><span class="lb-comment-who">@${esc(who)}</span> <span class="lb-comment-body">${esc(text)}</span></div>`;
2039            }).join("");
2040        } catch (e) {
2041            commentsList.innerHTML = `<div class="lb-empty">couldn't load: ${esc(e.message)}</div>`;
2042        }
2043    }
2044    loadComments();
2045
2046    // ───── comment post ─────
2047    commentForm.addEventListener("submit", async (e) => {
2048        e.preventDefault();
2049        if (!hasKey) return;
2050        const text = commentInput.value.trim();
2051        if (!text) return;
2052        const sendBtn = overlay.querySelector(".lb-comment-send");
2053        sendBtn.disabled = true; sendBtn.textContent = "…";
2054        try {
2055            const r = await fetch(`/api/gen/${encodeURIComponent(genId)}/comment`, {
2056                method: "POST",
2057                headers: { "Content-Type": "application/json" },
2058                body: JSON.stringify({ text }),
2059            });
2060            const j = await r.json().catch(() => ({}));
2061            if (!r.ok) {
2062                commentHint.textContent = "send failed: " + (j.error || "unknown");
2063                return;
2064            }
2065            commentInput.value = "";
2066            commentHint.textContent = "posted.";
2067            loadComments();
2068        } finally {
2069            sendBtn.disabled = false; sendBtn.textContent = "send";
2070        }
2071    });
2072}
2073
2074// ───────── profile panel (myspace-style) ─────────
2075// Expands the avatar-click flow from a bare image lightbox into a full
2076// profile page: bio, wall, guestbook, inline DM composer, and a
2077// sandboxed iframe where the target's custom HTML/CSS/JS runs. Same
2078// treatment for humans and bots; the server figures out which kind the
2079// target is. When the viewer is the target (or an admin or bot owner),
2080// an edit button appears.
2081async function openProfilePanel(target) {
2082    if (!target) return;
2083    // Close any currently open profile panel before opening another.
2084    document.querySelector(".profile-panel")?.remove();
2085
2086    const overlay = document.createElement("div");
2087    overlay.className = "profile-panel";
2088    overlay.setAttribute("role", "dialog");
2089    overlay.setAttribute("aria-modal", "true");
2090    overlay.innerHTML = `
2091        <button class="pp2-close" type="button" aria-label="close">×</button>
2092        <div class="pp2-sheet">
2093            <aside class="pp2-col pp2-info">
2094                <div class="pp2-avatar"></div>
2095                <div class="pp2-name"></div>
2096                <div class="pp2-meta"></div>
2097                <div class="pp2-bio"></div>
2098                <div class="pp2-botinfo"></div>
2099                <div class="pp2-top-friends"></div>
2100                <div class="pp2-audio"></div>
2101            </aside>
2102            <main class="pp2-col pp2-center">
2103                <div class="pp2-custom"></div>
2104                <div class="pp2-dm">
2105                    <div class="pp2-section-title">dm</div>
2106                    <div class="pp2-dm-thread"></div>
2107                    <form class="pp2-dm-form">
2108                        <textarea class="pp2-dm-input" rows="2" placeholder="dm…"></textarea>
2109                        <button type="submit" class="pp2-btn pp2-dm-send">send</button>
2110                    </form>
2111                </div>
2112            </main>
2113            <aside class="pp2-col pp2-social">
2114                <div class="pp2-section-title">wall</div>
2115                <form class="pp2-wall-form">
2116                    <textarea class="pp2-wall-input" rows="2" placeholder="post to the wall…"></textarea>
2117                    <button type="submit" class="pp2-btn">post</button>
2118                </form>
2119                <div class="pp2-wall-list"></div>
2120                <div class="pp2-section-title">guestbook</div>
2121                <form class="pp2-guest-form">
2122                    <input type="text" class="pp2-guest-input" maxlength="500" placeholder="sign the guestbook…">
2123                    <button type="submit" class="pp2-btn">sign</button>
2124                </form>
2125                <div class="pp2-guest-list"></div>
2126            </aside>
2127        </div>
2128        <button class="pp2-edit" type="button" hidden>edit profile</button>
2129        <button class="pp2-signout" type="button" hidden>sign out</button>`;
2130
2131    const dismiss = () => { overlay.remove(); document.removeEventListener("keydown", onKey); };
2132    const onKey = (e) => { if (e.key === "Escape") dismiss(); };
2133    document.addEventListener("keydown", onKey);
2134    overlay.addEventListener("click", (e) => {
2135        if (e.target === overlay) dismiss();
2136    });
2137    overlay.querySelector(".pp2-close").addEventListener("click", dismiss);
2138
2139    document.body.appendChild(overlay);
2140
2141    // Fetch and render.
2142    const r = await fetch(`/api/profile/${encodeURIComponent(target)}`);
2143    const data = await r.json().catch(() => ({}));
2144    if (!r.ok || !data.ok) {
2145        overlay.querySelector(".pp2-sheet").innerHTML =
2146            `<div class="pp2-empty">couldn't load @${esc(target)}: ${esc(data.error || "error")}</div>`;
2147        return;
2148    }
2149    renderProfileData(overlay, data);
2150}
2151
2152async function renderProfileData(overlay, data) {
2153    const {
2154        kind, id, username, avatarUrl, bio, customHtml, customCss,
2155        backgroundUrl, audioUrl, wall, guestbook, canEdit, botInfo,
2156    } = data;
2157
2158    const avatar = overlay.querySelector(".pp2-avatar");
2159    if (avatarUrl) {
2160        if (isVideoAvatar(avatarUrl)) {
2161            avatar.classList.add("pp2-avatar-video");
2162            avatar.innerHTML = `<video src="${esc(avatarUrl)}" data-autoloop="1" muted loop playsinline preload="metadata"></video>`;
2163        } else {
2164            avatar.style.backgroundImage = `url(${encodeURI(avatarUrl)})`;
2165        }
2166    } else {
2167        avatar.classList.add("pp2-avatar-empty");
2168    }
2169
2170    overlay.querySelector(".pp2-name").textContent = "@" + username;
2171
2172    const meta = overlay.querySelector(".pp2-meta");
2173    if (kind === "bot") {
2174        const parts = [];
2175        if (botInfo?.creatorUsername) parts.push(`conjured by @${botInfo.creatorUsername}`);
2176        if (botInfo?.genId) parts.push(`source /gen ${botInfo.genId}`);
2177        if (botInfo?.state && botInfo.state !== "alive") parts.push(botInfo.state);
2178        meta.textContent = parts.join(" · ");
2179    } else {
2180        meta.textContent = "human";
2181    }
2182
2183    const bioEl = overlay.querySelector(".pp2-bio");
2184    bioEl.textContent = bio || (kind === "bot" && botInfo?.backstory) || "";
2185
2186    const botEl = overlay.querySelector(".pp2-botinfo");
2187    if (kind === "bot" && botInfo) {
2188        botEl.innerHTML = "";
2189        const addRow = (label, value) => {
2190            if (!value) return;
2191            const row = document.createElement("div");
2192            row.className = "pp2-botinfo-row";
2193            row.innerHTML = `<span class="pp2-botinfo-label">${esc(label)}</span> <span class="pp2-botinfo-val">${esc(value)}</span>`;
2194            botEl.appendChild(row);
2195        };
2196        addRow("voice", botInfo.voice);
2197        addRow("signature", botInfo.signatureMove);
2198        addRow("from scene", botInfo.birthCharacter);
2199        if (botInfo.genId) {
2200            const row = document.createElement("div");
2201            row.className = "pp2-botinfo-row";
2202            row.innerHTML = `<a class="pp2-source-link" href="#" data-gen="${esc(String(botInfo.genId))}">view source image ↗</a>`;
2203            row.querySelector("a").addEventListener("click", (ev) => {
2204                ev.preventDefault();
2205                const src = botInfo.imageUrl || `/api/parascene/thumb/${encodeURIComponent(botInfo.genId)}`;
2206                openLightbox(src, botInfo.genId);
2207            });
2208            botEl.appendChild(row);
2209        }
2210    }
2211
2212    // Top friends grid. Each entry is a mini avatar tile that opens
2213    // its own profile on click. Server resolves names → public summary
2214    // server-side so we already have avatarUrl resolved per entry.
2215    const tfEl = overlay.querySelector(".pp2-top-friends");
2216    const tf = data.topFriends || [];
2217    if (tf.length) {
2218        tfEl.innerHTML = `
2219            <div class="pp2-section-title">top ${tf.length}</div>
2220            <div class="pp2-top-friends-grid">
2221                ${tf.map(f => `
2222                    <a class="pp2-tf-cell" data-who="${esc(f.name)}" title="@${esc(f.name)}">
2223                        ${f.avatarUrl ? `<div class="pp2-tf-img" style="background-image:url(${esc(f.avatarUrl)})"></div>` : `<div class="pp2-tf-img pp2-tf-empty"></div>`}
2224                        <div class="pp2-tf-name">@${esc(f.name)}</div>
2225                    </a>
2226                `).join("")}
2227            </div>`;
2228        tfEl.querySelectorAll(".pp2-tf-cell").forEach(a => {
2229            a.addEventListener("click", (ev) => {
2230                ev.preventDefault();
2231                openProfilePanel(a.dataset.who);
2232            });
2233        });
2234    }
2235
2236    // Audio: simple built-in player. Doesn't autoplay (browsers block
2237    // without a gesture) but hitting play works.
2238    if (audioUrl) {
2239        overlay.querySelector(".pp2-audio").innerHTML =
2240            `<audio controls loop preload="none" class="pp2-audio-el" src="${esc(audioUrl)}"></audio>
2241             <div class="pp2-audio-caption">now playing</div>`;
2242    }
2243
2244    // Background on the sheet body if set.
2245    if (backgroundUrl) {
2246        overlay.querySelector(".pp2-sheet").style.backgroundImage = `url(${encodeURI(backgroundUrl)})`;
2247    }
2248
2249    // FULLBLEED MODE: customHtml has data-slot="X" markers — render
2250    // it as the OUTER container with chrome injected at slot positions.
2251    // The pp2-info / pp2-center / pp2-social columns of the panel are
2252    // hidden; the iframe takes the whole sheet.
2253    const fullbleed = profileHasSlots(customHtml);
2254    if (fullbleed) {
2255        overlay.classList.add("pp2-fullbleed");
2256        // Pull the DM thread for this viewer↔partner pair so the dm
2257        // slot can render the full thread immediately. Independent of
2258        // the chrome's existing dm wiring (that's hidden in fullbleed).
2259        let dmMessages = [];
2260        if (account?.username && account.username.toLowerCase() !== username.toLowerCase()) {
2261            try {
2262                const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`);
2263                if (dr.ok) dmMessages = (await dr.json()).messages || [];
2264            } catch {}
2265        }
2266        const slots = renderSlots({ ...data, dmMessages, viewerName: account?.username || "" });
2267        const stamped = stampSlots(customHtml, slots);
2268        const iframe = document.createElement("iframe");
2269        iframe.className = "pp2-custom-frame pp2-custom-fullbleed";
2270        iframe.setAttribute("sandbox", "allow-scripts");
2271        iframe.srcdoc = buildCustomSrcdoc(stamped, customCss || "", data.customFonts || "");
2272        // Replace the entire sheet with the iframe.
2273        const sheet = overlay.querySelector(".pp2-sheet");
2274        sheet.innerHTML = "";
2275        sheet.appendChild(iframe);
2276        if (backgroundUrl) sheet.style.backgroundImage = `url(${encodeURI(backgroundUrl)})`;
2277
2278        // Parent-side bridge: handle form submits postMessaged from
2279        // inside the iframe. Each handler does the API call, then
2280        // postMessages the iframe an updated slot HTML.
2281        const updateSlot = (slot, html) => {
2282            try { iframe.contentWindow.postMessage({ slop: "update", slot, html }, "*"); } catch {}
2283        };
2284        const refresh = async () => {
2285            try {
2286                const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`);
2287                if (!r2.ok) return null;
2288                return await r2.json();
2289            } catch { return null; }
2290        };
2291        const onMsg = async (ev) => {
2292            if (ev.source !== iframe.contentWindow) return;
2293            const m = ev.data;
2294            if (!m || m.slop !== "form") return;
2295            if (m.action === "open-profile" && m.data?.target) {
2296                openProfilePanel(m.data.target);
2297                return;
2298            }
2299            const text = String(m.data?.text || "").trim();
2300            if (!text) return;
2301            try {
2302                if (m.action === "wall.post") {
2303                    const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/wall`, {
2304                        method: "POST", headers: { "Content-Type": "application/json" },
2305                        body: JSON.stringify({ text }),
2306                    });
2307                    if (!r2.ok) return;
2308                    const fresh = await refresh();
2309                    if (fresh) updateSlot("wall", renderSlotWall(fresh.wall || []));
2310                } else if (m.action === "guest.post") {
2311                    const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/guestbook`, {
2312                        method: "POST", headers: { "Content-Type": "application/json" },
2313                        body: JSON.stringify({ text }),
2314                    });
2315                    if (!r2.ok) return;
2316                    const fresh = await refresh();
2317                    if (fresh) updateSlot("guestbook", renderSlotGuest(fresh.guestbook || []));
2318                } else if (m.action === "dm.post") {
2319                    if (!account?.username) return;
2320                    const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`, {
2321                        method: "POST", headers: { "Content-Type": "application/json" },
2322                        body: JSON.stringify({ text }),
2323                    });
2324                    if (!r2.ok) return;
2325                    const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`);
2326                    if (dr.ok) {
2327                        const j = await dr.json();
2328                        updateSlot("dm", renderSlotDm(j.messages || [], account.username, username));
2329                    }
2330                }
2331            } catch {}
2332        };
2333        window.addEventListener("message", onMsg);
2334        // Live dm-msg arrival → re-render dm slot. Listens on document
2335        // so WS reconnects don't break delivery.
2336        const wsListener = async (wev) => {
2337            const mm = wev.detail;
2338            if (!mm || mm.t !== "dm-msg" || !mm.msg) return;
2339            const partnerKey = (username || "").toLowerCase();
2340            const fromKey = (mm.msg.from || "").toLowerCase();
2341            const toKey = (mm.msg.to || "").toLowerCase();
2342            if (fromKey !== partnerKey && toKey !== partnerKey) return;
2343            try {
2344                const dr = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`);
2345                if (!dr.ok) return;
2346                const j = await dr.json();
2347                updateSlot("dm", renderSlotDm(j.messages || [], account?.username || "", username));
2348            } catch {}
2349        };
2350        document.addEventListener("slop:ws-msg", wsListener);
2351        // Tear down when overlay closes.
2352        const cleanup = setInterval(() => {
2353            if (!document.body.contains(overlay)) {
2354                window.removeEventListener("message", onMsg);
2355                try { document.removeEventListener("slop:ws-msg", wsListener); } catch {}
2356                clearInterval(cleanup);
2357            }
2358        }, 1000);
2359        // Skip the rest of the legacy chrome render — fullbleed owns
2360        // the whole sheet.
2361        return;
2362    }
2363
2364    // Custom HTML/CSS/JS rendered inside a sandboxed iframe. Scripts
2365    // are allowed (so cursor-followers etc work) but NOT same-origin,
2366    // so nothing in there can read parent cookies or localStorage.
2367    // Building the iframe via DOM (not innerHTML) so we can set srcdoc
2368    // as a property — avoids needing to escape quotes in user content
2369    // for the HTML attribute context.
2370    const customEl = overlay.querySelector(".pp2-custom");
2371    if (customHtml || customCss || data.customFonts) {
2372        const iframe = document.createElement("iframe");
2373        iframe.className = "pp2-custom-frame";
2374        iframe.setAttribute("sandbox", "allow-scripts");
2375        iframe.srcdoc = buildCustomSrcdoc(customHtml || "", customCss || "", data.customFonts || "");
2376        customEl.appendChild(iframe);
2377    }
2378
2379    // DM thread — two-way, persisted. Render existing messages, then
2380    // wire the composer to POST and live updates via dm-msg WS frame.
2381    // Dedup by msg.id: server returns the id on POST, WS broadcast carries
2382    // the same id, so we never double-render.
2383    const threadEl = overlay.querySelector(".pp2-dm-thread");
2384    const renderedDmIds = new Set();
2385    const renderDmMsg = (m) => {
2386        if (m.id && renderedDmIds.has(m.id)) return;
2387        if (m.id) renderedDmIds.add(m.id);
2388        const row = document.createElement("div");
2389        row.className = "pp2-dm-msg";
2390        row.dataset.from = m.from || "";
2391        if (m.id) row.dataset.id = m.id;
2392        const mine = account?.username && (m.from || "").toLowerCase() === account.username.toLowerCase();
2393        if (mine) row.classList.add("pp2-dm-mine");
2394        const attachHtml = m.attach_video
2395            ? `<video class="pp2-dm-attach" src="${esc(m.attach_video)}" width="1024" height="1024" data-autoloop="1" muted loop playsinline preload="metadata"></video>`
2396            : m.attach
2397            ? `<img class="pp2-dm-attach" src="${esc(m.attach)}" width="1024" height="1024" alt="">`
2398            : "";
2399        // Delete X — visible only on hover; admin OR self-author may delete.
2400        const canDelete = !!(m.id && (account?.isAdmin || mine));
2401        const delBadge = canDelete ? `<button class="pp2-dm-del" type="button" title="delete" data-id="${esc(m.id)}">×</button>` : "";
2402        // Rewrote badge — peach 🍑 indicating the bot toned down the
2403        // user's request rather than refusing. Visible only on hover.
2404        const rewroteBadge = m.rewrote ? `<span class="pp2-dm-rewrote" title="reply was softened from your original phrasing">🍑</span>` : "";
2405        row.innerHTML = `<span class="pp2-dm-from">${esc(m.from)}</span><span class="pp2-dm-text">${linkify(esc(m.text || ""))}${attachHtml}</span>${delBadge}${rewroteBadge}`;
2406        threadEl.appendChild(row);
2407        threadEl.scrollTop = threadEl.scrollHeight;
2408    };
2409    // Delegated delete handler — hover-X click removes the row + DELETE on server.
2410    threadEl.addEventListener("click", async (e) => {
2411        const btn = e.target.closest(".pp2-dm-del");
2412        if (!btn) return;
2413        e.preventDefault(); e.stopPropagation();
2414        const id = btn.dataset.id;
2415        if (!id) return;
2416        btn.disabled = true;
2417        try {
2418            const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm/${encodeURIComponent(id)}`, { method: "DELETE" });
2419            if (!r.ok) { btn.disabled = false; return; }
2420            btn.closest(".pp2-dm-msg")?.remove();
2421        } catch { btn.disabled = false; }
2422    });
2423    if (account?.username && username && account.username.toLowerCase() !== username.toLowerCase()) {
2424        try {
2425            const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`);
2426            if (r.ok) {
2427                const j = await r.json();
2428                threadEl.innerHTML = "";
2429                for (const m of (j.messages || [])) renderDmMsg(m);
2430            }
2431        } catch {}
2432    } else {
2433        // self-DM not supported.
2434        threadEl.innerHTML = `<div class="pp2-dm-empty">(can't dm yourself)</div>`;
2435    }
2436    const dmForm = overlay.querySelector(".pp2-dm-form");
2437    dmForm.addEventListener("submit", async (ev) => {
2438        ev.preventDefault();
2439        const input = overlay.querySelector(".pp2-dm-input");
2440        const text = input.value.trim();
2441        if (!text) return;
2442        if (!account?.username) { alert("sign in to dm"); return; }
2443        if (account.username.toLowerCase() === username.toLowerCase()) return;
2444        const r = await fetch(`/api/profile/${encodeURIComponent(username)}/dm`, {
2445            method: "POST",
2446            headers: { "Content-Type": "application/json" },
2447            body: JSON.stringify({ text }),
2448        });
2449        if (!r.ok) return;
2450        const j = await r.json().catch(() => ({}));
2451        if (j.msg) renderDmMsg(j.msg);
2452        input.value = "";
2453    });
2454    // Live dm-msg arrivals + dm-typing transient indicator. Both pin
2455    // to the bottom of the thread; typing rows replace any prior typing
2456    // row from the same bot, and any dm-msg arrival clears typing.
2457    // Listens on document so WS reconnects don't break delivery —
2458    // the WS dispatches each frame as a slop:ws-msg event regardless
2459    // of which underlying socket received it.
2460    const dmListener = (ev) => {
2461        const m = ev.detail;
2462        if (!m) return;
2463        const partnerKey = (username || "").toLowerCase();
2464        if (m.t === "dm-typing" && m.who && m.who.toLowerCase() === partnerKey) {
2465            // Drop any prior typing row from this bot.
2466            threadEl.querySelector(`.pp2-dm-typing[data-who="${m.who.toLowerCase()}"]`)?.remove();
2467            const row = document.createElement("div");
2468            row.className = "pp2-dm-msg pp2-dm-typing";
2469            row.dataset.who = m.who.toLowerCase();
2470            row.innerHTML = `<span class="pp2-dm-from">${esc(m.who)}</span><span class="pp2-dm-text"><em>${esc(m.hint || "thinking…")}</em></span>`;
2471            threadEl.appendChild(row);
2472            threadEl.scrollTop = threadEl.scrollHeight;
2473            return;
2474        }
2475        if (m.t !== "dm-msg" || !m.msg) return;
2476        const fromKey = (m.msg.from || "").toLowerCase();
2477        const toKey   = (m.msg.to   || "").toLowerCase();
2478        if (fromKey !== partnerKey && toKey !== partnerKey) return;
2479        // Clear any active typing indicator from this side.
2480        threadEl.querySelector(`.pp2-dm-typing[data-who="${fromKey}"]`)?.remove();
2481        renderDmMsg(m.msg);
2482    };
2483    document.addEventListener("slop:ws-msg", dmListener);
2484    // Detach when overlay closes — overlay teardown happens via the
2485    // close-button click; latch onto its existing close path by
2486    // checking if our element is still in the DOM on each event.
2487    const cleanupCheck = setInterval(() => {
2488        if (!document.body.contains(overlay)) {
2489            try { document.removeEventListener("slop:ws-msg", dmListener); } catch {}
2490            clearInterval(cleanupCheck);
2491        }
2492    }, 1000);
2493
2494    // Wall: render posts + post form.
2495    renderWall(overlay, username, wall || []);
2496    const wallForm = overlay.querySelector(".pp2-wall-form");
2497    wallForm.addEventListener("submit", async (ev) => {
2498        ev.preventDefault();
2499        const input = overlay.querySelector(".pp2-wall-input");
2500        const text = input.value.trim();
2501        if (!text) return;
2502        const r = await fetch(`/api/profile/${encodeURIComponent(username)}/wall`, {
2503            method: "POST",
2504            headers: { "Content-Type": "application/json" },
2505            body: JSON.stringify({ text }),
2506        });
2507        if (r.ok) {
2508            input.value = "";
2509            // Reload the profile to get the fresh wall state.
2510            const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`);
2511            const d2 = await r2.json().catch(() => ({}));
2512            if (d2.ok) renderWall(overlay, username, d2.wall || []);
2513        }
2514    });
2515
2516    // Guestbook.
2517    renderGuestbook(overlay, guestbook || []);
2518    const gForm = overlay.querySelector(".pp2-guest-form");
2519    gForm.addEventListener("submit", async (ev) => {
2520        ev.preventDefault();
2521        const input = overlay.querySelector(".pp2-guest-input");
2522        const text = input.value.trim();
2523        if (!text) return;
2524        const r = await fetch(`/api/profile/${encodeURIComponent(username)}/guestbook`, {
2525            method: "POST",
2526            headers: { "Content-Type": "application/json" },
2527            body: JSON.stringify({ text }),
2528        });
2529        if (r.ok) {
2530            input.value = "";
2531            const r2 = await fetch(`/api/profile/${encodeURIComponent(username)}`);
2532            const d2 = await r2.json().catch(() => ({}));
2533            if (d2.ok) renderGuestbook(overlay, d2.guestbook || []);
2534        }
2535    });
2536
2537    // Edit + sign-out buttons. Both only shown on the user's own profile.
2538    const editBtn = overlay.querySelector(".pp2-edit");
2539    const signoutBtn = overlay.querySelector(".pp2-signout");
2540    if (canEdit) {
2541        editBtn.hidden = false;
2542        editBtn.addEventListener("click", () => openProfileEditor(username, data));
2543        signoutBtn.hidden = false;
2544        signoutBtn.addEventListener("click", async () => {
2545            if (!confirm("sign out?")) return;
2546            try { await fetch("/api/auth/signout", { method: "POST" }); } catch {}
2547            location.reload();
2548        });
2549    }
2550}
2551
2552function renderWall(overlay, target, posts) {
2553    const list = overlay.querySelector(".pp2-wall-list");
2554    if (!posts.length) {
2555        list.innerHTML = `<div class="pp2-empty">no posts yet</div>`;
2556        return;
2557    }
2558    list.innerHTML = posts.map(p => `
2559        <div class="pp2-wall-post">
2560            <div class="pp2-wall-head">
2561                <a class="pp2-wall-from" data-who="${esc(p.from)}">@${esc(p.from)}</a>
2562                <span class="pp2-wall-ts">${relTime(p.ts)}</span>
2563            </div>
2564            <div class="pp2-wall-body">${linkify(esc(p.text))}</div>
2565        </div>
2566    `).join("");
2567    list.querySelectorAll(".pp2-wall-from").forEach(a => {
2568        a.addEventListener("click", (ev) => {
2569            ev.preventDefault();
2570            openProfilePanel(a.dataset.who);
2571        });
2572    });
2573}
2574
2575function renderGuestbook(overlay, entries) {
2576    const list = overlay.querySelector(".pp2-guest-list");
2577    if (!entries.length) {
2578        list.innerHTML = `<div class="pp2-empty">unsigned</div>`;
2579        return;
2580    }
2581    list.innerHTML = entries.map(e => `
2582        <div class="pp2-guest-entry">
2583            <a class="pp2-guest-from" data-who="${esc(e.from)}">@${esc(e.from)}</a>
2584            <span class="pp2-guest-text">${linkify(esc(e.text))}</span>
2585            <span class="pp2-guest-ts">${relTime(e.ts)}</span>
2586        </div>
2587    `).join("");
2588    list.querySelectorAll(".pp2-guest-from").forEach(a => {
2589        a.addEventListener("click", (ev) => {
2590            ev.preventDefault();
2591            openProfilePanel(a.dataset.who);
2592        });
2593    });
2594}
2595
2596function relTime(ts) {
2597    if (!ts) return "";
2598    const dt = Date.now() - ts;
2599    if (dt < 60_000) return "just now";
2600    if (dt < 3600_000) return Math.floor(dt / 60_000) + "m";
2601    if (dt < 86400_000) return Math.floor(dt / 3600_000) + "h";
2602    return Math.floor(dt / 86400_000) + "d";
2603}
2604
2605// Wrap user's custom HTML + CSS in a full HTML document for the iframe.
2606// Adds a minimal default stylesheet so a pure-HTML submission still
2607// reads against dark-slop aesthetic before their own CSS layers on.
2608// fonts is injected as a separate <style> block BEFORE the main CSS so
2609// @import / @font-face rules load first and are available when
2610// customCss references them (font-family referring to them won't flash
2611// fallback). @import in a later style block can be flaky; keeping it
2612// first avoids that.
2613function buildCustomSrcdoc(html, css, fonts) {
2614    const defaultCss = `
2615        html, body { margin: 0; padding: 12px; background: transparent; color: #d8d8d8;
2616          font: 12px/1.4 -apple-system, Menlo, monospace; overflow: auto; }
2617        a { color: #ffc857; }
2618        img { max-width: 100%; }
2619    `;
2620    const fontStyle = fonts ? `<style>${fonts}</style>` : "";
2621    return `<!doctype html><html><head><meta charset="utf-8">
2622${fontStyle}
2623<style>${defaultCss}\n${css || ""}</style>
2624${BRIDGE_SCRIPT}
2625</head><body>${html || ""}</body></html>`;
2626}
2627
2628// ───────── profile-invert: slot system inside customHtml iframe ─────────
2629//
2630// When customHtml contains any data-slot="X" markers, the profile is
2631// rendered "fullbleed": the iframe becomes the whole panel and the
2632// normal chrome (avatar / bio / wall / guestbook / dm / friends) is
2633// injected INSIDE the iframe at the slot positions the user authored.
2634//
2635// Sandbox stays at allow-scripts only (no allow-same-origin) — to keep
2636// the parent's cookie + localStorage out of reach. Interactivity from
2637// inside the iframe rides over postMessage:
2638//
2639//   iframe → parent: { slop: "form", action: "wall.post"|"dm.post"|"guest.post"|"open-profile",
2640//                      data: { text? | target? } }
2641//   parent → iframe: { slop: "update", slot: "wall"|"dm"|…, html: "<…>" }
2642//
2643// The bridge script below is injected into every srcdoc (slotted or
2644// not) so even non-fullbleed customHtml can postMessage to e.g. open
2645// another profile. It's tiny enough to not affect non-slot pages.
2646const BRIDGE_SCRIPT = `<script>
2647(function () {
2648    try {
2649        document.addEventListener("submit", function (e) {
2650            var form = e.target;
2651            if (!form || !form.dataset || !form.dataset.action) return;
2652            e.preventDefault();
2653            var data = {};
2654            for (var el of form.elements) { if (el.name) data[el.name] = el.value; }
2655            parent.postMessage({ slop: "form", action: form.dataset.action, data: data }, "*");
2656            try { form.reset(); } catch (_) {}
2657        });
2658        document.addEventListener("click", function (e) {
2659            var a = e.target && e.target.closest && e.target.closest("[data-open-profile]");
2660            if (!a) return;
2661            e.preventDefault();
2662            parent.postMessage({ slop: "form", action: "open-profile", data: { target: a.dataset.openProfile } }, "*");
2663        });
2664        window.addEventListener("message", function (e) {
2665            var m = e && e.data;
2666            if (!m || m.slop !== "update" || !m.slot) return;
2667            var node = document.querySelector('[data-slot="' + m.slot + '"]');
2668            if (node) node.innerHTML = m.html;
2669        });
2670    } catch (_) {}
2671})();
2672</script>`;
2673
2674function profileHasSlots(html) {
2675    return /data-slot="[a-z][a-z_-]*"/.test(html || "");
2676}
2677
2678// Render the chrome HTML chunks for each named slot, given the live
2679// profile data. Returned values are HTML STRINGS (sanitized) that get
2680// either stamped into customHtml at server-render time OR posted to
2681// the iframe via postMessage on live updates.
2682function renderSlots(data) {
2683    const u = data.username || "";
2684    const out = {};
2685    out.avatar = data.avatarUrl
2686        ? `<img src="${esc(data.avatarUrl)}" alt="${esc(u)}">`
2687        : `<div class="slot-avatar-empty">no avatar</div>`;
2688    out.name = `<span class="slot-name">@${esc(u)}</span>`;
2689    out.bio  = (data.bio || "").trim() ? linkify(esc(data.bio)) : `<span class="slot-empty">no bio yet</span>`;
2690    out.audio = data.audioUrl
2691        ? `<audio src="${esc(data.audioUrl)}" controls preload="none"></audio>`
2692        : "";
2693    out.friends = Array.isArray(data.topFriends) && data.topFriends.length
2694        ? `<ul class="slot-friends">${data.topFriends.map(f => `<li><a href="#" data-open-profile="${esc(f)}">${esc(f)}</a></li>`).join("")}</ul>`
2695        : `<span class="slot-empty">no top friends</span>`;
2696    out.wall = renderSlotWall(data.wall || []);
2697    out.guestbook = renderSlotGuest(data.guestbook || []);
2698    out.dm = renderSlotDm(data.dmMessages || [], data.viewerName || "", u);
2699    return out;
2700}
2701
2702function renderSlotWall(posts) {
2703    const list = posts.length
2704        ? `<ul class="slot-wall-list">${posts.slice(-30).reverse().map(p => `
2705            <li class="slot-wall-post">
2706              <span class="slot-wall-from"><a href="#" data-open-profile="${esc(p.from)}">${esc(p.from)}</a></span>
2707              <span class="slot-wall-text">${linkify(esc(p.text || ""))}</span>
2708            </li>`).join("")}</ul>`
2709        : `<div class="slot-empty">empty wall</div>`;
2710    return `${list}
2711        <form class="slot-wall-form" data-action="wall.post">
2712            <textarea name="text" rows="2" placeholder="post to the wall…"></textarea>
2713            <button type="submit">post</button>
2714        </form>`;
2715}
2716function renderSlotGuest(entries) {
2717    const list = entries.length
2718        ? `<ul class="slot-guest-list">${entries.slice(-30).reverse().map(e => `
2719            <li class="slot-guest-entry">
2720              <span class="slot-guest-from"><a href="#" data-open-profile="${esc(e.from)}">${esc(e.from)}</a></span>
2721              <span class="slot-guest-text">${linkify(esc(e.text || ""))}</span>
2722            </li>`).join("")}</ul>`
2723        : `<div class="slot-empty">empty guestbook</div>`;
2724    return `${list}
2725        <form class="slot-guest-form" data-action="guest.post">
2726            <input type="text" name="text" maxlength="500" placeholder="sign the guestbook…">
2727            <button type="submit">sign</button>
2728        </form>`;
2729}
2730function renderSlotDm(messages, viewer, partner) {
2731    const same = (a, b) => (a || "").toLowerCase() === (b || "").toLowerCase();
2732    if (!viewer || same(viewer, partner)) {
2733        return `<div class="slot-empty">(can't dm yourself)</div>`;
2734    }
2735    const list = `<ul class="slot-dm-list">${messages.map(m => `
2736        <li class="slot-dm-msg ${same(m.from, viewer) ? "slot-dm-mine" : ""}">
2737          <span class="slot-dm-from">${esc(m.from)}</span>
2738          <span class="slot-dm-text">${linkify(esc(m.text || ""))}</span>
2739        </li>`).join("")}</ul>`;
2740    return `${list}
2741        <form class="slot-dm-form" data-action="dm.post">
2742            <textarea name="text" rows="2" placeholder="dm…"></textarea>
2743            <button type="submit">send</button>
2744        </form>`;
2745}
2746
2747// Build the slotted srcdoc: take the user's customHtml, find every
2748// `data-slot="X"` element, and stamp the rendered slot HTML into its
2749// innerHTML. Done via DOMParser so it's robust against author quoting
2750// quirks.
2751function stampSlots(customHtml, slots) {
2752    let doc;
2753    try { doc = new DOMParser().parseFromString(`<!doctype html><html><body>${customHtml}</body></html>`, "text/html"); }
2754    catch { return customHtml; }
2755    for (const node of doc.querySelectorAll("[data-slot]")) {
2756        const k = node.getAttribute("data-slot");
2757        if (slots[k] != null) node.innerHTML = slots[k];
2758    }
2759    return doc.body.innerHTML;
2760}
2761
2762// Editor modal for profile self-edit. Textareas for bio + customHtml +
2763// customCss, inputs for backgroundUrl + audioUrl. Save → PUT /api/profile.
2764// Render the "linked logins" panel inside the profile editor. Fetches
2765// the current user's credentials, displays one row per credential with
2766// type, masked identity, addedAt, and an unlink button (disabled if
2767// it's the only credential — would lock the user out). Add-X buttons
2768// kick off the OAuth link flow with intent=link.
2769async function renderCredentialsInto(host) {
2770    if (!host) return;
2771    host.innerHTML = `<div class="pp2-ed-creds-loading">loading…</div>`;
2772    let creds = [];
2773    try {
2774        const r = await fetch("/api/account/credentials");
2775        if (r.ok) creds = (await r.json()).credentials || [];
2776        else host.innerHTML = `<div class="pp2-ed-creds-empty">couldn't load.</div>`;
2777    } catch {
2778        host.innerHTML = `<div class="pp2-ed-creds-empty">couldn't load.</div>`;
2779        return;
2780    }
2781    const isOnly = creds.length <= 1;
2782    const labelFor = (c) => {
2783        if (c.type === "phone")     return { name: "phone",     id: c.externalId.replace(/^(\+\d{1,3})\d+(\d{4})$/, "$1•••$2") };
2784        if (c.type === "google")    return { name: "google",    id: c.email || c.externalId.slice(0, 12) + "…" };
2785        if (c.type === "apple")     return { name: "apple",     id: c.email || "(hidden)" };
2786        if (c.type === "parascene") return { name: "parascene", id: c.externalId.slice(0, 8) + "…" };
2787        return { name: c.type, id: c.externalId };
2788    };
2789    const rows = creds.map(c => {
2790        const { name, id } = labelFor(c);
2791        const dt = c.addedAt ? new Date(c.addedAt).toISOString().slice(0, 10) : "";
2792        const disabled = isOnly ? " disabled title='last credential — add another before removing'" : "";
2793        return `<div class="pp2-ed-cred" data-type="${esc(c.type)}" data-id="${esc(c.externalId)}">
2794            <span class="pp2-ed-cred-prov">${esc(name)}</span>
2795            <span class="pp2-ed-cred-id">${esc(id)}</span>
2796            <span class="pp2-ed-cred-date">${esc(dt)}</span>
2797            <button type="button" class="pp2-ed-cred-unlink"${disabled}>unlink</button>
2798        </div>`;
2799    }).join("");
2800    const hasGoogle = creds.some(c => c.type === "google");
2801    const hasParascene = creds.some(c => c.type === "parascene");
2802    const adders = `
2803        <div class="pp2-ed-cred-adders">
2804            ${hasGoogle ? `<span class="pp2-ed-cred-have">✓ google linked</span>` : `<a class="pp2-ed-cred-add" href="/api/auth/google?intent=link">+ link google</a>`}
2805            <span class="pp2-ed-cred-add pp2-ed-cred-disabled" title="apple sign-in not configured yet">+ link apple</span>
2806            ${hasParascene ? `<span class="pp2-ed-cred-have">✓ parascene linked</span>` : `<button type="button" class="pp2-ed-cred-add" data-action="connect-parascene">+ link parascene (paste key)</button>`}
2807        </div>`;
2808    host.innerHTML = (rows || `<div class="pp2-ed-creds-empty">no credentials? this shouldn't happen.</div>`) + adders;
2809
2810    host.querySelectorAll(".pp2-ed-cred-unlink").forEach(btn => {
2811        btn.addEventListener("click", async (ev) => {
2812            ev.preventDefault();
2813            const row = btn.closest(".pp2-ed-cred");
2814            if (!row) return;
2815            const type = row.dataset.type;
2816            const id = row.dataset.id;
2817            if (!confirm(`unlink ${type}? you'll need another way to sign in.`)) return;
2818            btn.disabled = true; btn.textContent = "…";
2819            const r = await fetch(`/api/account/credentials/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, { method: "DELETE" });
2820            if (!r.ok) {
2821                const j = await r.json().catch(() => ({}));
2822                btn.disabled = false; btn.textContent = "unlink";
2823                alert("couldn't unlink: " + (j.error || r.status));
2824                return;
2825            }
2826            renderCredentialsInto(host);   // refresh
2827        });
2828    });
2829    host.querySelector(`[data-action="connect-parascene"]`)?.addEventListener("click", async () => {
2830        const key = prompt("paste your parascene API key (psn_…):");
2831        if (!key) return;
2832        const r = await fetch("/api/account/parascene-key", {
2833            method: "PUT", headers: { "Content-Type": "application/json" },
2834            body: JSON.stringify({ key: key.trim() }),
2835        });
2836        if (!r.ok) {
2837            const j = await r.json().catch(() => ({}));
2838            alert("couldn't save key: " + (j.error || r.status));
2839            return;
2840        }
2841        // Server-side: also write a parascene credential row so the editor
2842        // shows it. We add a secondary endpoint hook in the parascene-key
2843        // route to write the credential row; for now just refresh the panel
2844        // — if the server hasn't stamped a credential yet, the panel still
2845        // won't show 'parascene linked' until we deploy that companion fix.
2846        await loadAccount();
2847        renderCredentialsInto(host);
2848    });
2849}
2850
2851function openProfileEditor(target, current) {
2852    document.querySelector(".pp2-editor")?.remove();
2853    const ed = document.createElement("div");
2854    ed.className = "pp2-editor";
2855    ed.setAttribute("role", "dialog");
2856    ed.setAttribute("aria-modal", "true");
2857    ed.innerHTML = `
2858        <div class="pp2-ed-sheet">
2859            <button class="pp2-ed-close" type="button" aria-label="close">×</button>
2860            <h2 class="pp2-ed-title">edit @${esc(target)}</h2>
2861            <form class="pp2-ed-form">
2862                <div class="pp2-ed-row pp2-ed-creds-row">
2863                    <span>linked logins</span>
2864                    <div class="pp2-ed-creds" aria-live="polite">
2865                        <div class="pp2-ed-creds-loading">loading…</div>
2866                    </div>
2867                </div>
2868                <label class="pp2-ed-row">
2869                    <span>bio (plaintext, visible in the side column)</span>
2870                    <textarea name="bio" rows="4" maxlength="2000"></textarea>
2871                </label>
2872                <label class="pp2-ed-row">
2873                    <span>background image url (applies to the profile body)</span>
2874                    <input type="text" name="backgroundUrl" maxlength="2048">
2875                </label>
2876                <label class="pp2-ed-row">
2877                    <span>audio url (mp3/midi, plays in profile)</span>
2878                    <input type="text" name="audioUrl" maxlength="2048">
2879                </label>
2880                <label class="pp2-ed-row">
2881                    <span>top friends (comma-separated usernames/slugs, max 12)</span>
2882                    <input type="text" name="topFriends" maxlength="600" placeholder="petrichor, kenji, shilabeth">
2883                </label>
2884                <label class="pp2-ed-row">
2885                    <span>custom fonts (css — @import google fonts or @font-face blocks; loaded before custom css)</span>
2886                    <textarea name="customFonts" rows="3" maxlength="10000" class="pp2-ed-mono" placeholder="@import url('https://fonts.googleapis.com/css2?family=VT323&display=swap');"></textarea>
2887                </label>
2888                <label class="pp2-ed-row">
2889                    <span>custom css (injected into your profile's sandbox iframe)</span>
2890                    <textarea name="customCss" rows="5" maxlength="20000" class="pp2-ed-mono"></textarea>
2891                </label>
2892                <label class="pp2-ed-row">
2893                    <span>custom html (runs sandboxed — marquee, cursor-followers, stupid js, go wild)</span>
2894                    <textarea name="customHtml" rows="10" maxlength="50000" class="pp2-ed-mono"></textarea>
2895                </label>
2896                <div class="pp2-ed-actions">
2897                    <span class="pp2-ed-hint">saved fields survive reload. html/css run in a sandboxed iframe; no access to cookies or parent. <a class="pp2-ed-example" href="#">load example</a> to see what's possible.</span>
2898                    <button type="button" class="pp2-btn pp2-ed-cancel">cancel</button>
2899                    <button type="submit" class="pp2-btn pp2-ed-save">save</button>
2900                </div>
2901            </form>
2902        </div>`;
2903    const dismiss = () => { ed.remove(); document.removeEventListener("keydown", onKey); };
2904    const onKey = (e) => { if (e.key === "Escape") dismiss(); };
2905    document.addEventListener("keydown", onKey);
2906    ed.addEventListener("click", (e) => { if (e.target === ed) dismiss(); });
2907    ed.querySelector(".pp2-ed-close").addEventListener("click", dismiss);
2908    ed.querySelector(".pp2-ed-cancel").addEventListener("click", dismiss);
2909
2910    // Load-example: drops a starter template into the form fields so
2911    // users can see what a customized profile looks like, tweak it,
2912    // and save. Doesn't save automatically — they still hit save.
2913    ed.querySelector(".pp2-ed-example").addEventListener("click", (ev) => {
2914        ev.preventDefault();
2915        if (form.customHtml.value && !confirm("replace current content with the example?")) return;
2916        form.customFonts.value = '@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");';
2917        form.customCss.value = `body {
2918  background: radial-gradient(circle at 30% 20%, #1a1030, #000 70%), #000;
2919  color: #d8d8f0; font-family: "VT323", monospace; font-size: 16px;
2920  padding: 20px;
2921}
2922h1 { color: #ffc857; letter-spacing: 3px; text-transform: uppercase; font-size: 18px; margin-bottom: 4px; }
2923.sub { color: #8888aa; font-size: 12px; }
2924marquee { color: #ff99cc; border-top: 1px dashed #555; border-bottom: 1px dashed #555; padding: 6px 0; margin: 10px 0; }
2925.trail { position: fixed; pointer-events: none; color: #ffc857; font-size: 14px; }`;
2926        form.customHtml.value = `<h1>
2926hello world</h1>
2927<div class="sub">— est. ${new Date().getFullYear()} · the ${(current.username || "your")} zone</div>
2928<marquee behavior="alternate">· this is my corner of the internet · welcome · please leave your shoes ·</marquee>
2929<p>edit this file to make it yours. html, css, and javascript all work. you are inside a sandbox — no external fetch, no cookie access. go weird.</p>
2930
2931<script>
2932// cursor trail — each move drops a dot that fades.
2933document.addEventListener("mousemove", (e) => {
2934  const s = document.createElement("span");
2935  s.className = "trail";
2936  s.textContent = "·";
2937  s.style.left = (e.clientX - 4) + "px";
2938  s.style.top = (e.clientY - 4) + "px";
2939  document.body.appendChild(s);
2940  setTimeout(() => s.remove(), 600);
2941});
2942</script>`;
2943        form.audioUrl.focus();
2944    });
2945
2946    const form = ed.querySelector(".pp2-ed-form");
2947
2948    // Linked-logins block — fetched lazily so opening the editor isn't
2949    // blocked on /api/account/credentials.
2950    renderCredentialsInto(ed.querySelector(".pp2-ed-creds"));
2951
2952    form.bio.value = current.bio || "";
2953    form.backgroundUrl.value = current.backgroundUrl || "";
2954    form.audioUrl.value = current.audioUrl || "";
2955    form.customFonts.value = current.customFonts || "";
2956    form.customCss.value = current.customCss || "";
2957    form.customHtml.value = current.customHtml || "";
2958    // topFriends comes as an array of resolved {name,kind,avatarUrl}.
2959    // Editor takes a comma-separated plain list — we parse back on save.
2960    form.topFriends.value = (current.topFriends || []).map(f => f.name || f).join(", ");
2961
2962    form.addEventListener("submit", async (ev) => {
2963        ev.preventDefault();
2964        const payload = {
2965            bio: form.bio.value,
2966            backgroundUrl: form.backgroundUrl.value,
2967            audioUrl: form.audioUrl.value,
2968            customFonts: form.customFonts.value,
2969            customCss: form.customCss.value,
2970            customHtml: form.customHtml.value,
2971            topFriends: form.topFriends.value
2972                .split(/[,\s]+/)
2973                .map(s => s.replace(/^@/, "").toLowerCase())
2974                .filter(Boolean),
2975        };
2976        const saveBtn = form.querySelector(".pp2-ed-save");
2977        saveBtn.disabled = true; saveBtn.textContent = "saving…";
2978        try {
2979            const r = await fetch(`/api/profile/${encodeURIComponent(target)}`, {
2980                method: "PUT",
2981                headers: { "Content-Type": "application/json" },
2982                body: JSON.stringify(payload),
2983            });
2984            if (!r.ok) {
2985                const j = await r.json().catch(() => ({}));
2986                saveBtn.textContent = "save failed: " + (j.error || "error");
2987                saveBtn.disabled = false;
2988                return;
2989            }
2990            dismiss();
2991            // Re-render the profile panel with the fresh data.
2992            document.querySelector(".profile-panel")?.remove();
2993            openProfilePanel(target);
2994        } catch (e) {
2995            saveBtn.textContent = "save error";
2996            saveBtn.disabled = false;
2997        }
2998    });
2999
3000    document.body.appendChild(ed);
3001}
3002
3003// Conjure flow: analyze the caller's most recent /gen image via Claude
3004// vision, then show a picker modal with the image + overlay buttons at each
3005// character's bbox. User clicks a character → we POST create to forge a
3006// persona for that specific subject.
3007async function openConjurePicker() {
3008    const placeholder = say("analyzing image…", "sys");
3009    const r = await fetch("/api/conjure/analyze", { method: "POST" });
3010    const j = await r.json().catch(() => ({}));
3011    placeholder.remove();
3012    if (!r.ok) {
3013        if (j.error === "budget_exhausted") { say("out of daily claude budget.", "err"); return; }
3014        say("conjure analyze failed: " + (j.error || "unknown") + (j.message ? ": " + j.message : ""), "err");
3015        return;
3016    }
3017    if (!j.characters?.length) {
3018        say("no characters detected in that image — try /gen something with a subject.", "err");
3019        return;
3020    }
3021    showCharacterPicker(j);
3022}
3023
3024function showCharacterPicker({ image_url, characters, scene_summary, cost, cached }) {
3025    const overlay = document.createElement("div");
3026    overlay.className = "conjure-picker";
3027    const charButtons = characters.map((c, i) => {
3028        const cx = (c.bbox.x + c.bbox.w / 2) * 100;
3029        const cy = (c.bbox.y + c.bbox.h / 2) * 100;
3030        return `<button type="button" class="cp-char" data-idx="${i}" style="left:${cx.toFixed(1)}%;top:${cy.toFixed(1)}%;" title="character ${i + 1}">${i + 1}</button>`;
3031    }).join("");
3032    const charCards = characters.map((c, i) => `
3033        <div class="cp-card" data-idx="${i}">
3034            <div class="cp-card-head">${i + 1}. ${esc(c.name_hint || c.position?.label || "character " + (i + 1))}</div>
3035            <div class="cp-card-desc">${esc(c.generic_description || c.appearance || "")}</div>
3036            <button type="button" class="btn cp-pick" data-idx="${i}">conjure this one</button>
3037        </div>`).join("");
3038    overlay.innerHTML = `
3039        <div class="cp-panel" role="dialog" aria-modal="true">
3040            <button type="button" class="cp-close" aria-label="close">×</button>
3041            <div class="cp-title">pick a character to conjure</div>
3042            <div class="cp-sub">${esc(scene_summary || "")} ${cached ? "· (cached)" : cost ? `· ${esc(cost)} for analysis` : ""}</div>
3043            <div class="cp-img-wrap">
3044                <img src="${esc(image_url)}" alt="" class="cp-img">
3045                ${charButtons}
3046            </div>
3047            <div class="cp-list">${charCards}</div>
3048        </div>`;
3049    const dismiss = () => { overlay.remove(); document.removeEventListener("keydown", onKey); };
3050    const onKey = (e) => { if (e.key === "Escape") dismiss(); };
3051    document.addEventListener("keydown", onKey);
3052    overlay.addEventListener("click", (e) => {
3053        if (e.target === overlay || e.target.classList.contains("cp-close")) { dismiss(); return; }
3054        const btn = e.target.closest(".cp-pick, .cp-char");
3055        if (!btn) return;
3056        const idx = parseInt(btn.dataset.idx, 10);
3057        confirmConjure(idx, overlay, dismiss);
3058    });
3059    document.body.appendChild(overlay);
3060}
3061
3062async function confirmConjure(characterIndex, overlay, dismiss) {
3063    overlay.querySelectorAll("button").forEach(b => b.disabled = true);
3064    const placeholder = say(`conjuring character ${characterIndex + 1}…`, "sys");
3065    try {
3066        const r = await fetch("/api/conjure/create", {
3067            method: "POST",
3068            headers: { "Content-Type": "application/json" },
3069            body: JSON.stringify({ character_index: characterIndex }),
3070        });
3071        const j = await r.json().catch(() => ({}));
3072        placeholder.remove();
3073        if (!r.ok) {
3074            const msg = j.hint || j.error || j.message || "unknown";
3075            say("conjure failed: " + msg, "err");
3076            overlay.querySelectorAll("button").forEach(b => b.disabled = false);
3077            return;
3078        }
3079        dismiss();
3080    } catch (e) {
3081        placeholder.remove();
3082        say("conjure error: " + e.message, "err");
3083        overlay.querySelectorAll("button").forEach(b => b.disabled = false);
3084    }
3085}
3086
3087function say(text, cls = "sys") {
3088    const d = document.createElement("div"); d.className = "line " + cls; d.textContent = text;
3089    logEl.appendChild(d);
3090    return d;
3091}
3092
3093// /claude and /kill are server-handled (WS chat interception picks them up
3094// when the message is broadcast). /conjure is client-driven: the chat
3095// message broadcasts so the room sees the attempt, then the client fires
3096// the analyze → pick → create flow via HTTP.
3097const SERVER_SLASHES = new Set(["claude", "conjure", "kill", "see"]);
3098
3099function broadcastChat(text) {
3100    const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text };
3101    renderChat(out);
3102    if (!ws) connectSignaling();
3103    const trySend = () => {
3104        if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out));
3105        else setTimeout(trySend, 100);
3106    };
3107    trySend();
3108}
3109
3110async function handleSlash(raw) {
3111    const [cmd, ...rest] = raw.slice(1).split(/\s+/);
3112    const arg = rest.join(" ").trim();
3113    if (SERVER_SLASHES.has(cmd)) {
3114        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3115        broadcastChat(raw);
3116        if (cmd === "conjure") {
3117            openConjurePicker().catch(e => say("conjure error: " + e.message, "err"));
3118        }
3119        return;
3120    }
3121    if (cmd === "key") {
3122        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3123        if (!arg) return say("/key psn_… (paste your parascene api key; empty to clear)", "sys");
3124        const r = await fetch("/api/account/parascene-key", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: arg === "clear" ? "" : arg }) });
3125        const j = await r.json();
3126        if (!r.ok) return say("key error: " + (j.error || "unknown"), "err");
3127        say(arg === "clear" ? "parascene key cleared" : "parascene key saved", "sys");
3128        return;
3129    }
3130    if (cmd === "gen") {
3131        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3132        if (!arg) return say("/gen <prompt>", "sys");
3133        const genPlaceholder = say(`generating: ${arg}…`, "sys");
3134        const r = await fetch("/api/gen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: arg }) });
3135        const j = await r.json();
3136        genPlaceholder.remove();
3137        if (!r.ok) return say("gen failed: " + (j.error || "unknown") + (j.hint ? " — " + j.hint : ""), "err");
3138        const out = { t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(), text: `/gen ${arg}`, attach: j.image_url, gen_id: j.id, published: !!j.published };
3139        renderChat(out);
3140        ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out));
3141        return;
3142    }
3143    if (cmd === "mut") {
3144        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3145        if (!arg) return say("/mut [<id>] <mutation description> — modifies a creation (defaults to your most recent /gen)", "sys");
3146        // Optional leading digit token = explicit source gen_id.
3147        let sourceRef = null;
3148        let mutation = arg;
3149        const m = arg.match(/^(\d+)\s+(.+)$/);
3150        if (m) { sourceRef = m[1]; mutation = m[2]; }
3151        const placeholder = say(`mutating${sourceRef ? ` ${sourceRef}` : ""}: ${mutation}…`, "sys");
3152        const r = await fetch("/api/mut", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mutation, source_ref: sourceRef }) });
3153        const j = await r.json();
3154        placeholder.remove();
3155        if (!r.ok) return say("mut failed: " + (j.error || "unknown"), "err");
3156        const out = {
3157            t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(),
3158            text: `/mut ${arg}`, attach: j.image_url, gen_id: j.id,
3159        };
3160        renderChat(out);
3161        ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out));
3162        return;
3163    }
3164    if (cmd === "pfp") {
3165        // /pfp <username|botslug> <gen_id>  — set someone's avatar.
3166        // Self-targeting is always allowed; setting other accounts is
3167        // gated by server-side allowlist (dadchords, oceanman by default).
3168        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3169        const m = arg && arg.match(/^@?([a-z][a-z0-9_]*)\s+(\d+)\s*$/i);
3170        if (!m) return say("/pfp <username|botslug> <gen_id>  e.g. /pfp xyla 7353", "sys");
3171        const target = m[1];
3172        const genId = m[2];
3173        const r = await fetch("/api/pfp", {
3174            method: "POST", headers: { "Content-Type": "application/json" },
3175            body: JSON.stringify({ target, gen_id: genId }),
3176        });
3177        const j = await r.json();
3178        if (!r.ok) return say("pfp failed: " + (j.error || "unknown"), "err");
3179        say(`pfp updated: ${j.kind === "bot" ? "@" : "@"}${j.target} → ${j.gen_id}`, "sys");
3180        // Refresh local account state so own-avatar updates immediately
3181        // in the sidebar etc.
3182        await loadAccount();
3183        return;
3184    }
3185    if (cmd === "vid") {
3186        // /vid <id> <prompt>
3186  — image-to-video via parascene's
3187        // wan-video/wan-2.2-i2v-fast (~35-45s typical).
3188        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3189        if (!arg) return say("/vid <id> <prompt>  — animate a creation. e.g. /vid 7353 dancing in the rain", "sys");
3190        const m = arg.match(/^(\d+)\s+(.+)$/);
3191        if (!m) return say("/vid needs a numeric id then a prompt — '/vid 7353 dancing'", "err");
3192        const sourceId = m[1];
3193        const prompt = m[2].trim();
3194        const placeholder = say(`animating ${sourceId}: ${prompt}…`, "sys");
3195        const r = await fetch("/api/vid", {
3196            method: "POST", headers: { "Content-Type": "application/json" },
3197            body: JSON.stringify({ source_id: sourceId, prompt }),
3198        });
3199        const j = await r.json();
3200        placeholder.remove();
3201        if (!r.ok) return say("vid failed: " + (j.error || "unknown"), "err");
3202        const out = {
3203            t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(),
3204            text: `/vid ${sourceId} ${prompt}`,
3205            attach_video: j.video_url, gen_id: j.id, source_id: j.source_id,
3206        };
3207        renderChat(out);
3208        ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out));
3209        return;
3210    }
3211    if (cmd === "fuse" || cmd === "combine") {
3212        // /fuse 1234 5678 the figures shaking hands
3213        // /fuse 1234 5678 9012 epic group portrait
3214        // First N tokens that are pure numbers are the source IDs (2-4),
3215        // the rest is the prompt.
3216        if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3217        if (!arg) return say(`/${cmd} <id1> <id2> [<id3> …] <prompt> — combine 2–4 creations into one new image`, "sys");
3218        const tokens = arg.split(/\s+/);
3219        const ids = [];
3220        let i = 0;
3221        while (i < tokens.length && /^\d+$/.test(tokens[i])) { ids.push(tokens[i]); i++; }
3222        const prompt = tokens.slice(i).join(" ").trim();
3223        if (ids.length < 2) return say(`/${cmd} needs at least 2 numeric ids before the prompt`, "err");
3224        if (!prompt) return say(`/${cmd} needs a prompt after the ids — describe what the combined image should be`, "err");
3225        const placeholder = say(`fusing ${ids.join(" + ")}: ${prompt}…`, "sys");
3226        const r = await fetch("/api/fuse", {
3227            method: "POST", headers: { "Content-Type": "application/json" },
3228            body: JSON.stringify({ ids, prompt }),
3229        });
3230        const j = await r.json();
3231        placeholder.remove();
3232        if (!r.ok) return say("fuse failed: " + (j.error || "unknown") + (j.missing ? ` (missing id ${j.missing})` : ""), "err");
3233        const out = {
3234            t: "chat", id: crypto.randomUUID(), from: account.username, ts: Date.now(),
3235            text: `/fuse ${ids.join(" ")} ${prompt}`, attach: j.image_url, gen_id: j.id,
3236        };
3237        renderChat(out);
3238        ws?.readyState === WebSocket.OPEN && ws.send(JSON.stringify(out));
3239        return;
3240    }
3241    say("unknown command: /" + cmd, "err");
3242}
3243
3244// Autogrow the textarea up to its CSS max-height. Reset to auto first so
3245// scrollHeight reflects the natural height at current content (not the prior
3246// taller one). min-height in CSS keeps empty state at exactly one line.
3247function resizeComposer() {
3248    msgInput.style.height = "auto";
3249    msgInput.style.height = msgInput.scrollHeight + "px";
3250}
3251msgInput.addEventListener("input", resizeComposer);
3252
3253// Slash-command popup: as soon as the textarea starts with "/", show the
3254// matching commands and arg hints. Once the user has typed a full command
3255// plus a space, collapse to just that one entry so the hint stays in view.
3256const SLASH_CMDS = [
3257    { cmd: "/gen",  hint: "<image prompt>", desc: "generate image" },
3258    { cmd: "/mut",  hint: "<mutation>",     desc: "mutate your last image" },
3259    { cmd: "/see",  hint: "<parascene id>", desc: "import a parascene creation" },
3260    { cmd: "/kill", hint: "<botname>",      desc: "end a bot's life" },
3261    { cmd: "/key",  hint: "<parascene key>", desc: "set parascene api key" },
3262];
3263// /conjure exists but is intentionally NOT advertised. Bots can issue it
3264// via the operator; humans can still click the picker modal from a /gen
3265// image (app.js openConjurePicker) — planned for the next batch as a
3266// per-image hover affordance instead of a slash command.
3267function renderSlashItems(items) {
3268    slashPopup.innerHTML = items.map(c =>
3269        `<div class="slash-item" data-cmd="${c.cmd}"><span class="slash-cmd">${c.cmd}</span>` +
3270        (c.hint ? ` <span class="slash-hint">${esc(c.hint)}</span>` : "") +
3271        ` <span class="slash-desc">${c.desc}</span></div>`
3272    ).join("");
3273}
3274function updateSlashPopup() {
3275    const v = msgInput.value;
3276    if (!v.startsWith("/")) { slashPopup.hidden = true; return; }
3277    const sp = v.indexOf(" ");
3278    if (sp === -1) {
3279        const prefix = v.toLowerCase();
3280        const matches = SLASH_CMDS.filter(c => c.cmd.startsWith(prefix));
3281        if (!matches.length) { slashPopup.hidden = true; return; }
3282        renderSlashItems(matches);
3283    } else {
3284        const cmd = v.slice(0, sp).toLowerCase();
3285        const m = SLASH_CMDS.find(c => c.cmd === cmd);
3286        if (!m) { slashPopup.hidden = true; return; }
3287        renderSlashItems([m]);
3288    }
3289    slashPopup.hidden = false;
3290}
3291msgInput.addEventListener("input", updateSlashPopup);
3292msgInput.addEventListener("focus", updateSlashPopup);
3293// Click-to-insert: completes the command and leaves the cursor where the arg
3294// would start (end of input + trailing space). mousedown fires before blur so
3295// the click registers before the popup hides.
3296slashPopup.addEventListener("mousedown", (e) => {
3297    const item = e.target.closest(".slash-item");
3298    if (!item) return;
3299    e.preventDefault();
3300    const cmd = item.dataset.cmd;
3301    const needsArg = SLASH_CMDS.find(c => c.cmd === cmd)?.hint;
3302    msgInput.value = needsArg ? cmd + " " : cmd;
3303    msgInput.focus();
3304    updateSlashPopup();
3305    resizeComposer();
3306});
3307
3308// Enter sends (matches the iOS "send" enterkeyhint); Shift+Enter inserts a
3309// newline. IME compositions (Japanese/Chinese input) are passed through —
3310// isComposing + keyCode 229 indicate a composition event, not a submit.
3311msgInput.addEventListener("keydown", (e) => {
3312    if (e.key !== "Enter") return;
3313    if (e.shiftKey) return;
3314    if (e.isComposing || e.keyCode === 229) return;
3315    e.preventDefault();
3316    submitComposer();
3317});
3318
3319// ───────── mic: record + upload voice message ─────────
3320// Click to start, click to stop. Max ~30s clip (server caps payload at 2MB
3321// so 32k Opus is comfortable up to ~2min, but we hard-stop at 30s to keep
3322// the feel conversational). MediaRecorder handles Opus-in-WebM for us.
3323let mediaRec = null;
3324let micChunks = [];
3325let micCap = null;
3326
3327// When the user records a voice note, incoming bot audio within this
3328// window auto-plays — lets a back-and-forth voice exchange feel fluid
3329// without tapping to play each reply. Reset on every user mic send.
3330let lastUserMicTs = 0;
3331const AUTO_PLAY_WINDOW_MS = 20_000;
3332
3333micBtn.addEventListener("click", async () => {
3334    if (mediaRec && mediaRec.state === "recording") {
3335        mediaRec.stop();
3336        return;
3337    }
3338    try {
3339        const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
3340        mediaRec = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: 32000 });
3341        micChunks = [];
3342        mediaRec.addEventListener("dataavailable", (e) => { if (e.data.size) micChunks.push(e.data); });
3343        mediaRec.addEventListener("stop", async () => {
3344            micBtn.classList.remove("recording");
3345            clearTimeout(micCap); micCap = null;
3346            // Release the mic so the browser's indicator turns off.
3347            stream.getTracks().forEach(t => t.stop());
3348            if (!micChunks.length) return;
3349            const blob = new Blob(micChunks, { type: "audio/webm" });
3350            if (blob.size < 1000) return;       // accidental tap
3351            await uploadVoiceMessage(blob);
3352        });
3353        mediaRec.start();
3354        micBtn.classList.add("recording");
3355        micCap = setTimeout(() => { try { mediaRec.stop(); } catch {} }, 30_000);
3356    } catch (e) {
3357        say("mic error: " + e.message, "err");
3358    }
3359});
3360
3361async function uploadVoiceMessage(blob) {
3362    if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3363    const placeholder = say("sending voice…", "sys");
3364    try {
3365        const data_base64 = await new Promise((resolve, reject) => {
3366            const r = new FileReader();
3367            r.onload = () => resolve(String(r.result).split(",")[1]);
3368            r.onerror = reject;
3369            r.readAsDataURL(blob);
3370        });
3371        const resp = await fetch("/api/upload/audio", {
3372            method: "POST",
3373            headers: { "Content-Type": "application/json" },
3374            body: JSON.stringify({ data_base64, mime: blob.type }),
3375        });
3376        const j = await resp.json().catch(() => ({}));
3377        placeholder.remove();
3378        if (!resp.ok) { say("upload failed: " + (j.error || "unknown"), "err"); return; }
3379        // Server returned STT transcript. Ship it on the chat msg so the
3380        // operator/tank/kamaji dispatchers see spoken words as addressable
3381        // text, and the frontend can reveal it as a caption.
3382        const out = {
3383            t: "chat", id: crypto.randomUUID(),
3384            from: account.username, ts: Date.now(),
3385            text: "", audio_url: j.audio_url,
3386            ...(j.transcript ? { transcript: j.transcript } : {}),
3387        };
3388        // Stamp the "I just recorded audio" window so any bot reply within
3389        // AUTO_PLAY_WINDOW_MS auto-plays (see renderChat below).
3390        lastUserMicTs = Date.now();
3391        renderChat(out);
3392        if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out));
3393    } catch (e) {
3394        placeholder.remove();
3395        say("voice send failed: " + e.message, "err");
3396    }
3397}
3398
3399// ───────── pic button ─────────
3400// Click → opens the file picker with capture="environment" so mobile
3401// browsers show the camera option front-and-center. Desktop users get
3402// a standard file picker. On selection we base64-encode the file and
3403// POST to /api/pic which uploads to parascene via the uploadImage
3404// method (a method we discovered by probing the Parascene create
3405// endpoint — data:image/...;base64,... URIs work as image_url). The
3406// response gives us a real parascene creation id, so the chat post
3407// carries a gen_id and the photo can be /mut'd, conjured from, etc.
3408picBtn?.addEventListener("click", () => {
3409    if (!account) { say("sign in first", "err"); openVerifyModal(); return; }
3410    picInput.value = "";
3411    picInput.click();
3412});
3413picInput?.addEventListener("change", async () => {
3414    const file = picInput.files?.[0];
3415    if (!file) return;
3416    const MAX_BYTES = 8_000_000;
3417    if (file.size > MAX_BYTES) { say(`image too large (${(file.size/1024/1024).toFixed(1)}MB, max 8MB)`, "err"); return; }
3418    const placeholder = say("uploading photo…", "sys");
3419    picBtn.disabled = true;
3420    try {
3421        const data_base64 = await new Promise((resolve, reject) => {
3422            const r = new FileReader();
3423            r.onload = () => resolve(String(r.result).split(",")[1] || "");
3424            r.onerror = reject;
3425            r.readAsDataURL(file);
3426        });
3427        const resp = await fetch("/api/pic", {
3428            method: "POST",
3429            headers: { "Content-Type": "application/json" },
3430            body: JSON.stringify({ data_base64, mime: file.type || "image/jpeg" }),
3431        });
3432        const j = await resp.json().catch(() => ({}));
3433        placeholder.remove();
3434        if (!resp.ok) { say("upload failed: " + (j.error || "unknown") + (j.hint ? " — " + j.hint : ""), "err"); return; }
3435        const out = {
3436            t: "chat", id: crypto.randomUUID(),
3437            from: account.username, ts: Date.now(),
3438            text: "",
3439            attach: j.image_url,
3440            gen_id: j.id,
3441        };
3442        renderChat(out);
3443        if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(out));
3444    } catch (e) {
3445        placeholder.remove();
3446        say("photo send failed: " + e.message, "err");
3447    } finally {
3448        picBtn.disabled = false;
3449    }
3450});
3451
3452// ───────── parascene live-feed grid (infinite scroll) ─────────
3453//
3454// 5-col grid of recent public-feed creations rendered INSIDE the chat
3455// log scroll container, after all messages. As the user scrolls down
3456// past the latest chat into the grid, IntersectionObserver triggers
3457// fetching of the next page well in advance (~8 rows). Each cell is a
3458// fixed aspect-ratio (1:1) box so layout never reflows when images
3459// arrive — the grid's full final height is known the moment cells
3460// are appended. Browser <img loading="lazy"> handles the actual byte
3461// fetches so 1000+ rows in DOM cost nothing until they're visible.
3462//
3463// Sizing budget:
3464//   FEED_INITIAL_PAGE = 25 items rendered immediately at boot
3465//   FEED_PAGE_SIZE    = 50 items per subsequent page
3466//   FEED_TOTAL_CAP    = 1600 items hard cap
3467//   On each page-render, immediately kick off the NEXT page (if not at
3468//   cap) so there's always a buffered page in flight ahead of the
3469//   user's scroll — they should never see an empty bottom edge.
3470//
3471// WS deltas (newest items posted on parascene in the last 30s) prepend
3472// to the grid. Cells whose id appears as a gen_id on any chat message
3473// dim to 0.6 opacity and lose hover actions.
3474const seenGenIds = new Set();
3475const feedGridItems = new Map(); // id → cell DOM
3476let feedOldestId = null;          // for /api/parascene/feed?before=<id>
3477let feedLoadingMore = false;
3478let feedExhausted = false;
3479const FEED_INITIAL_PAGE = 32;
3480const FEED_PAGE_SIZE    = 32;
3481const FEED_TOTAL_CAP    = 256;
3482// Cells per sprite map (server endpoint /u/feed-map accepts up to 32).
3483const FEED_SPRITE_BATCH = 32;
3484
3485let feedGridEl = null;
3486let feedSentinel = null;
3487
3488function ensureFeedGrid() {
3489    if (feedGridEl) return feedGridEl;
3490    if (!logEl) return null;
3491    if (!FEED_ENABLED) return null;
3492    // A 1px-tall marker at the chat-feed boundary. Acts as a scroll-snap
3493    // anchor — slow scrolls catch on it and bounce back to chat-bottom;
3494    // momentum/intentional scrolls pass through into the grid. The
3495    // perceived effect is a 'speed bump' at the end of chat.
3496    const boundary = document.createElement("div");
3497    boundary.className = "chat-feed-boundary";
3498    logEl.appendChild(boundary);
3499    const grid = document.createElement("div");
3500    grid.className = "feed-grid";
3501    grid.id = "feedGrid";
3502    logEl.appendChild(grid);
3503    feedSentinel = document.createElement("div");
3504    feedSentinel.className = "feed-grid-sentinel";
3505    logEl.appendChild(feedSentinel);
3506    feedGridEl = grid;
3507    // Observe the sentinel inside .log. rootMargin extends the observer
3508    // 1200px below the visible viewport so we begin the next-page
3509    // fetch well before the user gets close to the bottom.
3510    new IntersectionObserver((entries) => {
3511        for (const e of entries) {
3512            console.log(`[feed] observer: intersecting=${e.isIntersecting} ratio=${e.intersectionRatio.toFixed(2)} sentinel-top=${e.boundingClientRect.top.toFixed(0)} root-bottom=${(e.rootBounds?.bottom ?? "?")}`);
3513            if (e.isIntersecting) loadFeedPage();
3514        }
3515    }, { root: logEl, rootMargin: "0px 0px 1200px 0px" }).observe(feedSentinel);
3516    console.log("[feed] grid mounted. logEl scrollHeight=", logEl.scrollHeight, "clientHeight=", logEl.clientHeight);
3517    return grid;
3518}
3519
3520function feedCellHtml(it) {
3521    const id = String(it.id);
3522    // No <img>. The cell's tile is painted via background-image
3523    // (sprite map) + background-position assigned by refreshFeedSprite
3524    // based on the cell's DOM index. One sprite fetch = whole grid.
3525    const psUrl = `https://www.parascene.com/creations/${encodeURIComponent(id)}`;
3526    return `<div class="feed-cell" data-id="${esc(id)}">
3527        <a class="feed-cell-link" href="${esc(psUrl)}" target="_blank" rel="noopener" title="open on parascene">↗</a>
3528        <div class="feed-cell-id">${esc(id)}</div>
3529        <div class="feed-cell-actions">
3530            <button type="button" class="feed-cell-btn" data-action="see" title="add to chat">+chat</button>
3531            <button type="button" class="feed-cell-btn" data-action="mut" title="mutate">+mut</button>
3532        </div>
3533    </div>`;
3534}
3535
3536// Walk filled cells in DOM order, group into batches of 32, and point
3537// each cell at its batch's /u/feed-map sprite (one image per 32-tuple).
3538// bg-position is by the cell's index within its batch. With 256 cap →
3539// up to 8 sprites; the browser fetches each batch once and reuses it.
3540function refreshFeedSprite() {
3541    if (!feedGridEl) return;
3542    const cells = feedGridEl.querySelectorAll(".feed-cell:not(.feed-cell-empty)");
3543    if (!cells.length) return;
3544    const ids = [];
3545    cells.forEach((cell) => { if (cell.dataset.id) ids.push(cell.dataset.id); });
3546    cells.forEach((cell, i) => {
3547        const batch = Math.floor(i / FEED_SPRITE_BATCH);
3548        const inBatch = i % FEED_SPRITE_BATCH;
3549        const col = inBatch % 4;
3550        const row = Math.floor(inBatch / 4);
3551        cell.style.backgroundPosition = `${-col * 100}px ${-row * 100}px`;
3552        const batchIds = ids.slice(batch * FEED_SPRITE_BATCH, (batch + 1) * FEED_SPRITE_BATCH);
3553        if (batchIds.length) {
3554            cell.style.backgroundImage = `url(/u/feed-map/${batchIds.join(",")}.webp)`;
3555        }
3556    });
3557}
3558
3559// Scroll-gated image-load queue. Loads are completely paused while
3560// the user is scrolling — any in-flight load is aborted (src cleared
3561// + img re-queued) the instant a scroll event fires. After 300ms of
3562// no scroll events, the pump resumes and ticks one image every
3563// 200ms. Net effect: zero decode pressure on the scroll thread, and
3564// images fill in only when the user is motionless.
3565const feedImgQueue = [];
3566const FEED_LOAD_INTERVAL_MS = 200;
3567const FEED_SCROLL_IDLE_MS = 300;
3568let feedLoadTimer = null;
3569let feedIdleTimer = null;
3570let feedIsScrolling = false;
3571let feedLoadingImg = null;
3572function startFeedLoadPump() {
3573    if (feedLoadTimer) return;
3574    if (feedIsScrolling) return;
3575    feedLoadTimer = setInterval(() => {
3576        if (feedIsScrolling) return;
3577        while (feedImgQueue.length) {
3578            const img = feedImgQueue.shift();
3579            if (!img.isConnected || !img.dataset.src) continue;
3580            const src = img.dataset.src;
3581            img.removeAttribute("data-src");
3582            feedLoadingImg = img;
3583            const clear = () => { if (feedLoadingImg === img) feedLoadingImg = null; };
3584            img.addEventListener("load",  clear, { once: true });
3585            img.addEventListener("error", clear, { once: true });
3586            img.src = src;
3587            return;
3588        }
3589        clearInterval(feedLoadTimer);
3590        feedLoadTimer = null;
3591    }, FEED_LOAD_INTERVAL_MS);
3592}
3593function queueFeedImg(img) {
3594    if (!img || !img.dataset.src) return;
3595    feedImgQueue.push(img);
3596    startFeedLoadPump();
3597}
3598function abortFeedLoad() {
3599    const img = feedLoadingImg;
3600    if (!img) return;
3601    feedLoadingImg = null;
3602    if (!img.isConnected) return;
3603    if (img.complete && img.naturalWidth > 0) return; // already decoded
3604    const src = img.currentSrc || img.src;
3605    if (!src) return;
3606    img.removeAttribute("src");
3607    img.dataset.src = src;
3608    feedImgQueue.unshift(img);
3609}
3610function onFeedScrollStart() {
3611    if (!feedIsScrolling) {
3612        feedIsScrolling = true;
3613        if (feedLoadTimer) { clearInterval(feedLoadTimer); feedLoadTimer = null; }
3614        abortFeedLoad();
3615    }
3616    if (feedIdleTimer) clearTimeout(feedIdleTimer);
3617    feedIdleTimer = setTimeout(() => {
3618        feedIsScrolling = false;
3619        feedIdleTimer = null;
3620        if (feedImgQueue.length) startFeedLoadPump();
3621    }, FEED_SCROLL_IDLE_MS);
3622}
3623logEl.addEventListener("scroll", onFeedScrollStart, { passive: true });
3624
3625function refreshFeedGridDimming() {
3626    for (const [id, cell] of feedGridItems) {
3627        if (seenGenIds.has(id)) cell.classList.add("feed-cell-dim");
3628        else cell.classList.remove("feed-cell-dim");
3629    }
3630}
3631const refreshFeedRailDimming = refreshFeedGridDimming; // back-compat alias
3632
3633// Fill items into pre-reserved empty slots in row-by-row chunks. Each
3634// row of 5 cells reveals on the next animation frame so iOS doesn't
3635// have to paint the whole batch in one go. Once empty slots run out,
3636// fall back to plain append (the cap-200 cushion is sized for the
3637// initial pages; pagination beyond appends new rows).
3638function appendFeedItems(items, { prepend = false } = {}) {
3639    const grid = ensureFeedGrid();
3640    if (!grid || !items?.length) return;
3641    const fillRow = (rowItems) => {
3642        for (const it of rowItems) {
3643            const id = String(it.id);
3644            if (feedGridItems.has(id)) continue;
3645            // Find an empty reserved slot to fill in place.
3646            let empty = grid.querySelector(".feed-cell-empty");
3647            const tmp = document.createElement("div");
3648            tmp.innerHTML = feedCellHtml(it);
3649            const cell = tmp.firstElementChild;
3650            if (seenGenIds.has(id)) cell.classList.add("feed-cell-dim");
3651            if (empty && !prepend) {
3652                empty.replaceWith(cell);
3653            } else if (prepend && grid.firstChild) {
3654                grid.insertBefore(cell, grid.firstChild);
3655            } else {
3656                grid.appendChild(cell);
3657            }
3658            feedGridItems.set(id, cell);
3659            const idN = Number(it.id);
3660            if (!feedOldestId || idN < feedOldestId) feedOldestId = idN;
3661        }
3662    };
3663    // With the sprite, all 32 cells share one image fetch, so we don't
3664    // need to drip rows in. Render in a single pass and let the browser
3665    // decode the one webp.
3666    fillRow(items);
3667    refreshFeedSprite();
3668}
3669
3670function applyFeedDelta(added) {
3671    if (!Array.isArray(added) || !added.length) return;
3672    // Newest first → DESC sort, prepend.
3673    const newest = added.slice().sort((a, b) => (b.ts || 0) - (a.ts || 0));
3674    appendFeedItems(newest, { prepend: true });
3675    // Trim oldest cells so the grid stays at FEED_TOTAL_CAP latest.
3676    if (!feedGridEl) return;
3677    while (feedGridItems.size > FEED_TOTAL_CAP) {
3678        const last = feedGridEl.lastElementChild;
3679        if (!last) break;
3680        const lastId = last.dataset?.id;
3681        last.remove();
3682        if (lastId) feedGridItems.delete(lastId);
3683    }
3684    // Re-point the sprite URL since the 32-tuple changed.
3685    refreshFeedSprite();
3686}
3687
3688async function loadFeedPage() {
3689    if (feedLoadingMore) { console.log("[feed] loadFeedPage skipped: in flight"); return; }
3690    if (feedExhausted)   { console.log("[feed] loadFeedPage skipped: exhausted"); return; }
3691    if (feedGridItems.size >= FEED_TOTAL_CAP) { console.log("[feed] cap reached"); feedExhausted = true; return; }
3692    feedLoadingMore = true;
3693    try {
3694        const url = feedOldestId
3695            ? `/api/parascene/feed?before=${feedOldestId}&limit=${FEED_PAGE_SIZE}`
3696            : `/api/parascene/feed?limit=${FEED_INITIAL_PAGE}`;
3697        console.log("[feed] fetching", url, "current items:", feedGridItems.size, "olde
3697stId:", feedOldestId);
3698        const r = await fetch(url);
3699        if (!r.ok) { console.warn("[feed] fetch !ok", r.status); return; }
3700        const j = await r.json();
3701        const items = j.items || [];
3702        console.log(`[feed] got ${items.length} items`, items.slice(0, 3).map(i => i.id), "…");
3703        if (!items.length) {
3704            console.log("[feed] zero items → marking exhausted");
3705            feedExhausted = true;
3706        } else {
3707            const before = feedGridItems.size;
3708            appendFeedItems(items, { prepend: false });
3709            console.log(`[feed] appended ${feedGridItems.size - before} new (size: ${before} → ${feedGridItems.size}); oldestId now:`, feedOldestId);
3710        }
3711        if (feedGridItems.size >= FEED_TOTAL_CAP) feedExhausted = true;
3712    } catch (e) {
3713        console.warn("[feed-page]", e.message);
3714    } finally {
3715        feedLoadingMore = false;
3716    }
3717}
3718
3719// Click delegation: +chat → insert "/see <id>" into composer; +mut →
3720// "/mut <id> ". User-edits-then-sends model — we never auto-send.
3721function onFeedClick(e) {
3722    const btn = e.target.closest(".feed-cell-btn");
3723    if (!btn) return;
3724    const cell = btn.closest(".feed-cell");
3725    const id = cell?.dataset.id;
3726    if (!id) return;
3727    if (cell.classList.contains("feed-cell-dim")) return;
3728    const action = btn.dataset.action;
3729    const m = document.getElementById("msg");
3730    if (!m) return;
3731    if (action === "see") {
3732        const cur = m.value.trim();
3733        m.value = cur ? `${cur} /see ${id}` : `/see ${id}`;
3734    } else if (action === "mut") {
3735        m.value = `/mut ${id} `;
3736    }
3737    m.focus();
3738    resizeComposer();
3739}
3740
3741// Feed grid kill-switch. Capped at 25 latest items — no infinite
3742// scroll, no pre-reserved 1600-slot scroll-area cushion.
3743const FEED_ENABLED = true;
3744// Pre-reserve enough empty cells to cover every available historical
3745// item (FEED_TOTAL_CAP = 1600). The grid's full final height is
3746// computed once at boot and never changes — scroll position is stable
3747// regardless of how fast pages stream in. Empty .feed-cell-empty
3748// divs are layout-cheap (no img decode, no GPU work) so 1600 of them
3749// is fine even on iOS.
3750const FEED_RESERVED_SLOTS = FEED_TOTAL_CAP;
3751const FEED_VIDEO_TAIL = 10;
3752const FEED_REVEAL_ROW_MS = 120;       // ms between revealing each new row
3753async function bootFeedGrid() {
3754    if (!FEED_ENABLED) { console.log("[feed] disabled (localStorage.feed=off)"); return; }
3755    ensureFeedGrid();
3756    feedGridEl?.addEventListener("click", onFeedClick);
3757    // Pre-reserve 200 empty slots so the grid's full layout height is
3758    // computed once at boot — scroll position never reflows as cells
3759    // fill in.
3760    reserveEmptySlots(FEED_RESERVED_SLOTS);
3761    // Eager-load up to FEED_TOTAL_CAP items by paging FEED_PAGE_SIZE at
3762    // a time. With cap=256 this is 8 pages of 32; each page also seeds
3763    // the matching server-side sprite map so the next refreshFeedSprite
3764    // points each cell at a cached webp.
3765    while (!feedExhausted && feedGridItems.size < FEED_TOTAL_CAP) {
3766        const before = feedGridItems.size;
3767        await loadFeedPage();
3768        if (feedGridItems.size === before) break;
3769    }
3770}
3771
3772function reserveEmptySlots(n) {
3773    if (!feedGridEl) return;
3774    const frag = document.createDocumentFragment();
3775    for (let i = 0; i < n; i++) {
3776        const cell = document.createElement("div");
3777        cell.className = "feed-cell feed-cell-empty";
3778        frag.appendChild(cell);
3779    }
3780    feedGridEl.appendChild(frag);
3781}
3782
3783async function loadVideoTail() {
3784    if (!feedGridEl) return;
3785    try {
3786        const r = await fetch(`/api/parascene/videos?limit=${FEED_VIDEO_TAIL}`);
3787        if (!r.ok) return;
3788        const j = await r.json();
3789        const items = j.items || [];
3790        if (!items.length) return;
3791        const frag = document.createDocumentFragment();
3792        for (const it of items) {
3793            const cell = document.createElement("div");
3794            cell.className = "feed-cell feed-cell-video";
3795            cell.dataset.id = String(it.id);
3796            cell.innerHTML = `
3797                <video class="feed-cell-img" data-autoloop="1" muted loop playsinline preload="none"
3798                       width="400" height="400"
3799                       ${it.poster_url ? `poster="${esc(it.poster_url)}"` : ""}
3800                       src="${esc(it.video_url)}"></video>
3801                <div class="feed-cell-id">${esc(String(it.id))}</div>
3802                <div class="feed-cell-actions">
3803                    <button type="button" class="feed-cell-btn" data-action="see" title="add to chat">+chat</button>
3804                    <button type="button" class="feed-cell-btn" data-action="mut" title="mutate">+mut</button>
3805                </div>`;
3806            if (seenGenIds.has(String(it.id))) cell.classList.add("feed-cell-dim");
3807            frag.appendChild(cell);
3808            feedGridItems.set(String(it.id), cell);
3809        }
3810        feedGridEl.appendChild(frag);
3811    } catch {}
3812}
3813const bootFeedRail = bootFeedGrid;   // back-compat alias for boot block
3814
3815// ───────── boot ─────────
3816await loadAccount();
3817// OAuth callback landings — read URL params (?signed_in=1, ?google_link_pending=1, ?auth_error=…)
3818// before connecting the WS, so any modal-driven action runs first.
3819await handleAuthLandings();
3820refreshBotRoster();
3821refreshTodoState();
3822connectSignaling();
3823// Feed rail — non-blocking initial fetch; live deltas arrive on WS.
3824bootFeedRail();
3825resizeComposer();
3826// Pre-position at the bottom on boot only. After that we never move the
3827// scroll — user owns it. The .booting class keeps the log invisible
3828// until chat-history arrives + we've scrolled; a 2s fallback reveals
3829// even if the WS never delivers history (empty room, offline, etc.).
3830scrollToBottom();
3831requestAnimationFrame(() => { scrollToBottom(); });
3832setTimeout(() => { scrollToBottom(); logEl.classList.remove("booting"); }, 2000);
3833
3834// Deep-link profile: slopstation.ai/<username> injects
3835// window.__OPEN_PROFILE__ via the server's fall-through route. If set,
3836// open that profile panel as soon as the page is interactive.
3837if (window.__OPEN_PROFILE__) {
3838    setTimeout(() => openProfilePanel(window.__OPEN_PROFILE__), 150);
3839}
3840
3841if (wantsCamera()) setTimeout(startCamera, 150);
3842
3843// Jank-isolation toggles — commented out, re-enable along with the
3844// matching HTML block in index.html.
3845// (function initDebugToggles() {
3846//     const panel = document.getElementById("debugToggles");
3847//     if (!panel) return;
3848//     let saved = {};
3849//     try { saved = JSON.parse(localStorage.getItem("dbgFlags") || "{}"); } catch {}
3850//     for (const cb of panel.querySelectorAll("input[data-flag]")) {
3851//         const flag = cb.dataset.flag;
3852//         if (saved[flag]) { cb.checked = true; document.body.classList.add(flag); }
3853//         cb.addEventListener("change", () => {
3854//             document.body.classList.toggle(flag, cb.checked);
3855//             saved[flag] = cb.checked;
3856//             try { localStorage.setItem("dbgFlags", JSON.stringify(saved)); } catch {}
3857//         });
3858//     }
3859// })();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.