<!-- SOLUV · part 452 -->
 1];
    statusEl.textContent = `${books[p.b].title} · ${p.hl}`;
    return flyTo(nearViewFor(m, p), 2400);
  }
  function goMostActive() { const list = mostActive(); if (!list.length) { statusEl.textContent = 'Nothing is live right now'; return; } liveIdx = liveIdx % list.length; return joinPage(list[liveIdx++]); }
  function tapAt(sx, sy) { // tap a glowing page (or any page) to glide into it
    let best = null, bd = Infinity;
    for (const p of sel) {
      const g = wp(p, p.w / 2, p.hdr * 0.45, 2.5);
      if (p.act < 0.15 || !project(g.x, g.y, g.z)) continue;
      const r = clamp(Math.max(18, p.w * 2.6) * (0.55 + 0.9 * p.act) * focal / P.w, 3 + 42 * p.act, 70 + 190 * p.act) / 2 + 14;
      const d = Math.hypot(P.sx - sx, P.sy - sy); if (d < r && d / r < bd) { bd = d / r; best = p; }
    }

    if (best) joinPage(best);
  }
  // simulated live activity (TEST): a few pages receive replayed lines from their own conversation on a timer,
  // and their headline/subheads are rewritten through setHeadline() exactly as a live headline writer would.
  const sim = { pages: [], timer: null, n: 0 };
  function simulateTick() {
    if (!sim.pages.length) return null;
    const p = sim.pages[(Math.random() * sim.pages.length) | 0];
    bumpActivity(p); p.newLines = (p.newLines || 0) + 1; sim.n++;
    if (!p.msgs) { requestTile(p.tile, true); return p; }
    const src = p.msgs[(Math.random() * Math.min(p.msgs.length, p.baseCount || p.msgs.length)) | 0];
    p.baseCount = p.baseCount || p.msgs.length;
    const body = src.text.slice(src.sl).replace(/\n/g, ' ');
    addMessage(p.id, src.spk, '(replay) ' + body, true);
    if (Math.random() < 0.3) { const v = sim.pages.filter(q => q.mode === 'video' && q.msgs); const q = v[(Math.random() * v.length) | 0]; const lm = q && [...q.msgs].reverse().find(x => x.live); if (lm) { const ss = q.sessions && q.sessions.get(lm.spk); if (ss && ss.last === lm) leaveRoom(q, ss, { msg: lm.i, t: Date.now() }); else endTransmission(lm); q.labelV++; } } // a speaker's transmission cuts / they leave
    const words = body.split(' ');
    setHeadline(p.id, p.baseHl, [`Now: ${words.slice(0, 7).join(' ')}${words.length > 7 ? '…' : ''}`, `${p.newLines} new line${p.newLines > 1 ? 's' : ''} · latest from ${src.spk}`]);
    return p;
  }
  if (opts.simulateActivity !== false) {
    // live = the unlocked chat-room pillars plus a few archive pillars (locked pillars are read-only and never change)
    const chatLive = sel.filter(p => books[p.b].kind === 'chat' && !p.locked), arch = sel.filter(p => books[p.b].kind !== 'chat' && !p.locked);
    sim.pages = opts.simulatePages ? opts.simulatePages.map(i => pages[i]).filter(p => p && p.on && !p.locked)
      : [...chatLive.slice(0, 6), ...arch.filter((_, i) => i % Math.max(1, Math.floor(arch.length / 8)) === 3).slice(0, 8)];
    for (const p of sim.pages) { p.act = 0.35 + 0.6 * Math.random(); books[p.b].liveDirty = true; }
    sim.timer = setInterval(simulateTick, opts.simulateEveryMs || 2600);
  }

  /* ---------------- camera angles: Wide / Glide / Orbit / Front ---------------- */
  const VIEWS = ['Wide', 'Ground', 'Glide', 'Orbit', 'Front']; let viewIdx = -1;
  const viewBtn = wrap.querySelector('.ws-view');
  const yawFor = p => Math.atan2(p.nx, -p.ny);
  function currentPage() { // pillar closest to the pivot
    let best = null, bd = Infinity;
    for (const q of sel) { const d = Math.hypot(q.bx + q.nx * 4 - st.tx, q.by + q.ny * 4 - st.ty, clamp(st.tz, 0, q.h) - st.tz); if (d < bd) { bd = d; best = q; } }
    return best;
  }
  function capViewFor(p) { const t = wp(p, p.w / 2, p.hdr * 0.6, 0), px = Math.max(12.5 / p.subEm, 4); return { tx: t.x, ty: t.y, tz: t.z, dist: focal / px, pitch: Math.PI / 2, yaw: yawFor(p) }; }
  function viewState(name, pg) {
    const yaw = yawFor(pg);
    if (name === 'Wide') { // high over the court, looking across many pillar caps
      return { tx: pg.bx + pg.nx * 140, ty: pg.by + pg.ny * 140, tz: 20, dist: 760, pitch: 1.0, yaw };
    }
    if (name === 'Ground') { // standing in the court among the pillars, eye height ~30
      return { tx: pg.bx + pg.nx * 4, ty: pg.by + pg.ny * 4, tz: 30, dist: 170, pitch: 1.72, yaw: yaw + 0.45 };
    }
    if (name === 'Glide') { // in front of the face, gliding down its single tall column
      const t = wp(pg, pg.w / 2, pg.hdr + 6, 0);
      return { tx: t.x, ty: t.y, tz: t.z, dist: focal / 16, pitch: 1.4, yaw, drift: { type: 'glide', pg } };
    }
    if (name === 'Orbit') { // circling the pillar's cap from the side
      return { tx: pg.bx, ty: pg.by, tz: Math.max(10, pg.h - pg.hdr - 20), dist: focal / 5.2, pitch: 1.25, yaw, drift: { type: 'orbit', pg } };
    }
    // Front: face-on reading of the message nearest the pivot
    let m = null; if (pg.msgs) { let bd = Infinity; for (const q of pg.msgs) { const d = Math.abs(q.z - st.tz); if (d < bd) { bd = d; m = q; } } }
    return m ? nearViewFor(m, pg) : capViewFor(pg);
  }
  async function setCam