<!-- SOLUV · part 453 -->
eraView(name, pg = currentPage()) {
    viewIdx = VIEWS.indexOf(name); viewBtn.textContent = name; viewBtn.setAttribute('aria-label', `Camera angle: ${name}. Tap for next`);
    if (pg && !pg.msgs) requestTile(pg.tile, true);
    stopMotion();
    const v = viewState(name, pg), d = v.drift; delete v.drift;
    await flyTo(v, 1900);
    if (d && viewIdx === VIEWS.indexOf(name) && !pointers.size) drift = { ...d, t0: performance.now() };
  }
  function cycleView() { return setCameraView(VIEWS[(viewIdx + 1) % VIEWS.length]); }
  function stepDrift(dt) {
    const d = drift; if (!d) return;
    if (d.type === 'orbit') { st.yaw += dt * 0.22; applyCamera(); return; }
    // glide: ~2.5 lines per second straight down the pillar's single column, easing in over the first second
    const pg = d.pg, bottom = 6, ease = Math.min(1, (performance.now() - d.t0) / 1000);
    st.tz -= 3.2 * ease * dt;
    if (st.tz < bottom) { st.tz = bottom; drift = null; }
    applyCamera();
  }
  function followPagePlane() {}

  /* ---------------- labels ---------------- */
  // A label = anchor + runs of text. Layouts are cached (LRU) as glyph arrays in em units relative to the anchor.
  const layoutCache = new Map(); const LRU = 4000;
  let highlight = null; // { pid, mi, s, e }
  function runsFor(kind, obj) {
    if (kind === 4) return [{ text: obj.title, scale: 1, color: C.paper, bold: 1, wrap: 18, lh: 1.1 },
      obj.liveN ? { text: `● ${obj.liveN} live now`, scale: 0.75, color: C.liveKw, wrap: 26, lh: 1.25 } : { text: obj.kw.slice(0, 3).join(' · '), scale: 0.75, color: C.bookKw, wrap: 26, lh: 1.25 }];
    if (kind === 3) {
      const live = obj.act > 0.25;
      const runs = [];
      const icon = { video: '▣', audio: '◉', text: '≡' }[obj.mode] || '≡';
      const badgeTxt = (obj.locked ? '⚜ SEALED · ' : '') + `open: ${obj.access} · ${icon} ${obj.mode}${obj.mode === 'video' ? ` ${obj.cams}/${obj.maxCams}` : ''}`;
      runs.push({ text: badgeTxt, scale: 0.86, color: obj.locked ? C.goldTag : (C['acc_' + obj.access] || C.srcCol), bold: 1, wrap: 30, lh: 1.3, glow: obj.locked ? 1 : 0 });
      runs.push({ text: obj.hl, scale: 1, color: live ? C.live : obj.locked ? C.goldHead : C.paper, bold: 1, wrap: 20, lh: 1.12 },
        { text: live ? (obj.newLines ? `● live · ${obj.newLines} new` : '● live') : obj.kw.slice(0, 3).join(' · '), scale: 0.86, color: live ? C.liveKw : C.headKw, wrap: 30, lh: 1.3 },
        { text: obj.src, scale: 0.86, color: obj.locked ? C.goldTag : C.srcCol, wrap: 30, lh: 1.25 });
      return runs;
    }
    if (kind === 2) return [{ text: obj.page.sub[obj.k] || '', scale: 1, color: C.deck, wrap: 30, lh: 1.2 }];
    const m = obj, p = pages[(m.id / 100000) | 0];
    if (kind === 1) {
      // as many opening words as fit the message's own block (1-5 lines at mid size), never overlapping the next message
      const maxLines = clamp(Math.floor((m.n * L.lineH + 0.7) / (L.midEm * 1.15)), 1, 5);
      const words = m.text.slice(m.sl).replace(/\n/g, ' ').split(' '); let s = '';
      for (const w of words) { if (s && wrapText(s + ' ' + w, 13).length > maxLines) { s += '…'; break; } s += (s ? ' ' : '') + w; }
      return [{ text: s, scale: 1, color: m.g ? C.gold : C.mid, wrap: 13, lh: 1.15, glow: m.g ? 1 : 0, maxLines }];
    }
    const ranges = [], cam = m.camId && m.sl ? 1 : 0, sc = speakerColor(m.spk, GOLD);
    if (cam) ranges.push([0, 1, m.live ? sc : C.still, m.live ? 0.6 : 0, false]); // live: speaker colour; ended: grey still with replay mark
    if (m.sl) ranges.push([cam, cam + m.sl, sc, m.spk === GOLD ? 1 : 0, true]);
    if (highlight && highlight.pid === p.id && highlight.mi === m.i) ranges.push([highlight.s + cam, highlight.e + cam, C.hit, 2, false]);
    if (cam && m.leave) { for (const r of ranges) { r[0] += 1; r[1] += 1; } ranges.unshift([0, 1, C.still, 0, false]); ranges[1] = [1, 2, C.rec, 0.6, false]; }
    return [{ text: cam ? (m.live ? CAM : m.leave ? CAM_STILL + REC_LINK : CAM_STILL) + m.text : m.text, pre: true, scale: 1, color: m.g ? C.gold : C.text, glow: m.g ? 0.6 : 0, lh: L.lineH, ranges }];
  }
  function layout(key, kind, obj) {
    let lay = layoutCache.get(key);
    if (lay) { layoutCache.delete(key); layoutCache.set(key, lay); return lay; }
    const runs = runsFor(kind, obj), glyphs = []; let Y = 0, maxW = 0; const minScale = Math.min(...runs.map(r => r.scale));
    const center = kind >= 2;
    for (const r of runs) {
      let lines = r.pre ? r.text.split('\n') : wrapText(r.text, r.wrap);
      if (r.maxLines && lines.length > r.maxLines) { lines = lines.slice(0, r.maxLines); lines[lines.length - 1] += '…'; }
      let off = 0;
      for (const line of lines) {
        const base = Y - 0.93 * r.scale; let x = 0; const start = glyphs.length;
        let ci = off;
        for (const ch of line) {
          let col = r.color, glow = r.glow || 0, bold = !!r.bold;
          if (r.ranges) for (const rg of r.ranges) if (ci >= rg[0] && ci < rg[1]) { col = rg[2]; g