<!-- SOLID CITY · part 1635 -->
.remove();
      if (o.s === 401) { me = null; logLine('sys', 'Log in to talk to SABLE.'); $('t-login').hidden = false; return; }
      if (o.s === 429) { logLine('sys', 'SABLE: \u201cI\u2019m getting a lot of messages at once. Give me a minute and ask again.\u201d'); return; }
      var reply = o.j && (o.j.sable || o.j.reply);
      if (!reply) { logLine('sys', 'SABLE didn\u2019t answer (' + ((o.j && o.j.error) || o.s) + ').'); return; }
      talkHist.push({ role: 'user', text: text }, { role: 'sable', text: reply });
      if (window.AudioDrive) AudioDrive.receive({ from: 'sable', text: reply, render: function () { logLine('her', reply); } }); else { logLine('her', reply); speak(reply); }
    })
    .catch(function () { wait.remove(); logLine('sys', 'Connection lost.'); })
    .then(function () { talkBusy = false; $('t-send').disabled = false; });
}

/* ---------------- SOUL PRINT talk (Soul Print Tower door; login-gated + rate-limited server-side) ----------------
   Uses /api/city/soulprint (proxies to the existing /api/soulprint/chat relay). Sends the CSRF token,
   renders every reply with textContent (no HTML), keeps only this visit's short history. */
var soulHist = [], soulBusy = false;
function spLine(cls, text) { var d = document.createElement('div'); d.className = cls; d.textContent = text; $('sp-log').appendChild(d); $('sp-log').scrollTop = 1e9; }
function openSoul() {
  $('sc-talk').hidden = true; $('sp-talk').hidden = false;
  getMe().then(function (j) {
    var ok = j && j.loggedIn; $('sp-login').hidden = ok; $('sp-form').style.display = ok ? '' : 'none';
    if (wall.tab === 'wall') spTab('wall');
    if (!$('sp-log').children.length) spLine('sys', ok ? 'The wine-red door glows. Soul Print looks up from the light, ' + (j.name || 'traveler') + '.' : 'The door stays sealed. Soul Print speaks only with citizens of epistles.');
    if (ok && !IS_TOUCH) $('sp-msg').focus();
  });
}
$('sp-close').addEventListener('click', function () { $('sp-talk').hidden = true; try { speechSynthesis.cancel(); } catch (e) {} });
$('sp-form').addEventListener('submit', function (e) { e.preventDefault(); sendSoul($('sp-msg').value); });
function sendSoul(text) {
  text = String(text || '').trim().slice(0, 300); if (!text || soulBusy) return;
  $('sp-msg').value = ''; spLine('me', text); soulBusy = true; $('sp-send').disabled = true;
  var wait = document.createElement('div'); wait.className = 'sys'; wait.textContent = 'Soul Print is listening…'; $('sp-log').appendChild(wait);
  getMe().then(function (m) {
    function call() { return fetch('/api/city/soulprint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (m && m.csrf) || '' }, body: JSON.stringify({ message: text, history: soulHist.slice(-6) }) }); }
    // one quiet retry on 502/503 (e.g. the few seconds of a server restart) before showing an error
    return call().then(function (r) { return r.status === 502 || r.status === 503 ? new Promise(function (ok) { setTimeout(ok, 1500); }).then(call) : r; });
  }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { s: r.status, j: j }; }); })
    .then(function (o) {
      wait.remove();
      if (o.s === 401) { me = null; spLine('sys', 'Log in to talk with Soul Print.'); $('sp-login').hidden = false; $('sp-form').style.display = 'none'; return; }
      if (o.s === 403) { if (o.j && o.j.error === 'csrf') { me = null; spLine('sys', 'Your session check failed. Reload the page and try again.'); } else spLine('sys', 'The door won\u2019t open right now (' + ((o.j && o.j.error) || 'forbidden') + ').'); return; }
      if (o.s === 429) { spLine('sys', 'Soul Print needs a quiet moment. Too many messages; try again in a little while.'); return; }
      var reply = o.j && typeof o.j.reply === 'string' ? o.j.reply : '';
      if (!reply) { spLine('sys', 'Soul Print didn\u2019t answer (' + ((o.j && o.j.error) || o.s) + ').'); return; }
      soulHist.push({ role: 'user', text: text }, { role: 'her', text: reply.slice(0, 300) });
      spLine('her', reply); speak(reply, 1.08);
    })
    .catch(function () { wait.remove(); spLine('sys', 'Connection lost.'); })
    .then(function () { soulBusy = false; $('sp-send').disabled = false; });
}
/* ---------------- CHECK-INS tab: the SHARED Soluv room (= /soluv wall 2, /api/soluv-room) ----------------
   Not the private AI talk: everyone logged in sees the same feed (oldest at top, newest at the bottom).
   Posts go only to /api/soluv-room (name from the login, CSRF header); nothing here calls an AI. Polls every 4 s while open. */
var wall = { tab: 'talk', last: 0, timer: 0, busy: false, n: 0 };
function spTab(t) {
  wall.tab = t; var w = t === 'wall';
  $('sp-tab-talk').classList.toggle('on', !w); $('sp-tab-wall').classList.toggle('on', w);
  $('sp-tab-talk').setAttribute('aria-selected', String(!w)); $('sp-tab-wall').setAttribute('aria-selecte