<!-- SOLUV · part 441 -->
const FADE_HI = 12.8;
const FONT_FAMILY = 'WordSkyCond';
const FONT_STACK = `${FONT_FAMILY}, "Roboto Condensed", "Arial Narrow", "DejaVu Sans", sans-serif`;
const OLD_FAMILY = 'WordSkyOld'; // Old Standard TT: big old-style newspaper headlines
const OLD_STACK = `${OLD_FAMILY}, "Old Standard TT", Georgia, "DejaVu Serif", serif`;
const smooth = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
const hex = (h) => { const n = parseInt(h.slice(1), 16); return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255]; };
const C = {
  book: hex('#ffffff'), bookKw: hex('#8fd3ff'), bookMeta: hex('#9aa4c7'),
  live: hex('#ffbf5a'), liveKw: hex('#ff8a3d'), head: hex('#f6f1e4'), headGold: hex('#ffd98a'), headKw: hex('#7fd0ff'), sub: hex('#c9d3f5'),
  text: hex('#e6f3ff'), mid: hex('#bfe3ff'), gold: hex('#fff3cf'), goldTag: hex('#e7b53a'), hit: hex('#5ff4ff'), narr: hex('#a89a86'),
  paper: hex('#eaf8ff'), deck: hex('#9fd8ff'), goldHead: hex('#ffd98a'), srcCol: hex('#a89a86'), acc_watch: hex('#9aa4c7'), still: hex('#8a8f9e'), rec: hex('#5ff4ff'), acc_text: hex('#efe7d6'), acc_audio: hex('#5fe0d0'), acc_video: hex('#ff9f80'),
};
const SPEAKER_COLORS = ['#7fe3a8', '#ff9fb2', '#9fc3ff', '#ffb46b', '#c7a6ff', '#6fe0e8', '#f5e17a', '#ff8f6b'].map(hex);
const tagCache = new Map();
function speakerColor(name, goldName) { // same rule as the dense /soluv room: hsl(hash % 360, 75%, 70%)
  if (name === goldName || name.startsWith('\u265b')) return C.goldTag;
  if (name === 'Narrator') return C.narr;
  if (tagCache.has(name)) return tagCache.get(name);
  const key = 'guest:' + name; let x = 2166136261; for (let i = 0; i < key.length; i++) { x ^= key.charCodeAt(i); x = Math.imul(x, 16777619); }
  const c = new THREE.Color().setHSL(((x >>> 0) % 360) / 360, 1.0, 0.66); const v = [c.r, c.g, c.b]; tagCache.set(name, v); return v;
}
function wrapText(s, width) { // identical to tools/build_data.py wrap(): only spaces become breaks
  const out = []; let line = '';
  for (const w of s.split(' ')) {
    if (!line) line = w; else if (line.length + 1 + w.length <= width) line += ' ' + w; else { out.push(line); line = w; }
  }
  if (line) out.push(line);
  return out;
}

/* ---------------- SDF glyph atlas (Felzenszwalb EDT, in the spirit of mapbox/tiny-sdf) ---------------- */
const CAM = '\uE000', CAM_STILL = '\uE001', REC_LINK = '\uE002'; // private-use code points: inline camera slot (live) / frozen last frame with replay mark
const ATLAS_CELL_EM = (Math.ceil(40 * 1.22) + 2 * 8) / 40; // glyph quad height in em (cell incl. SDF buffer)
class SDFAtlas {
  constructor(size = 2048, fontSize = 40, buffer = 8, radius = 10) {
    Object.assign(this, { size, fontSize, buffer, radius, cutoff: 0.25 });
    this.cellW = Math.ceil(fontSize * 1.15) + 2 * buffer;
    this.cellH = Math.ceil(fontSize * 1.22) + 2 * buffer;
    this.baseY = buffer + Math.round(fontSize * 0.95);
    this.cols = Math.floor(size / this.cellW); this.rows = Math.floor(size / this.cellH);
    this.data = new Uint8Array(size * size);
    this.tex = new THREE.DataTexture(this.data, size, size, THREE.RedFormat, THREE.UnsignedByteType);
    this.tex.minFilter = THREE.LinearFilter; this.tex.magFilter = THREE.LinearFilter; this.tex.generateMipmaps = false;
    this.tex.needsUpdate = true;
    this.canvas = document.createElement('canvas'); this.canvas.width = this.cellW; this.canvas.height = this.cellH;
    this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
    this.map = new Map(); this.next = 0; this.dirty = false;
    const n = Math.max(this.cellW, this.cellH);
    this.f = new Float64Array(n); this.z = new Float64Array(n + 1); this.v = new Uint16Array(n);
    this.go = new Float64Array(this.cellW * this.cellH); this.gi = new Float64Array(this.cellW * this.cellH);
  }
  get(ch, bold) { // bold: 0/false regular, 1/true bold, 2 old-style serif headline
    const key = bold === 2 ? '\u0002' + ch : bold ? '\u0001' + ch : ch;
    let g = this.map.get(key);
    if (g) return g;
    if (this.next >= this.cols * this.rows) return this.map.get('?');
    g = this.render(ch, bold, this.next++); this.map.set(key, g); return g;
  }
  render(ch, bold, slot) {
    const { ctx, cellW: w, cellH: h, buffer, fontSize: fs, size } = this;
    ctx.clearRect(0, 0, w, h);
    ctx.font = bold === 2 ? `700 ${fs}px ${OLD_STACK}` : `${bold ? 700 : 400} ${fs}px ${FONT_STACK}`;
    ctx.textBaseline = 'alphabetic'; ctx.fillStyle = '#000';
    let adv;
    if (ch === REC_LINK) { // 'watch their full part' link: filmstrip frame with sprocket holes and a play triangle
      const x0 = buffer + fs * 0.06, x1 = buffer + fs * 1.06, yT = this.baseY - fs * 0.8, yB = this.baseY + fs * 0.1;
      ctx.fillRect(x0, yT, x1 - x0, yB - yT); ctx.globalCompositeOperation = 'destination-out';
      for (let k = 0; k < 4; k++) { const hx = x0 + fs * 0.08 + k * fs * 0.25; c