<!-- SOLUV · part 473 -->
width = W; cv.height = H;
    const ctx = cv.getContext('2d');
    if (t.header) drawHeader(ctx, t, ppe);
    else { ctx.translate(-t.c * SEG_W * ppe, 0); const W = Math.ceil(TEXT_W * ppe);   // this lane's slice of the one long column
    for (let r = t.r0; r < t.r1; r++) {
      const x = cell(0, r); if (!x) continue;
      const top = (r - t.r0) * L.LINE * ppe;
      if (x.k === 'live') { if (x.ln.newspk && r > baseRow()) { ctx.fillStyle = COL.rule; ctx.fillRect(0, top, x.ln.ext * ppe, Math.max(1, 0.06 * ppe)); } drawRuns(ctx, x.ln, top, ppe, W); }
      else if (x.k === 'more') {
        ctx.fillStyle = 'rgba(231,181,58,0.18)'; ctx.fillRect(0, top + 0.05 * ppe, Math.min(W, 80 * ppe), (L.LINE - 0.1) * ppe);
        ctx.font = FONT(true, ppe); ctx.fillStyle = COL.gold;
        ctx.fillText('\u25b2 ' + S.archivedMsgs + ' older message' + (S.archivedMsgs === 1 ? '' : 's') + ' did not fit \u00b7 tap to open the full chat (' + archiveUrl.replace(/^https?:\/\/[^/]+/, '') + ')', 0.3 * ppe, top + 0.99 * ppe);
      }
      else if (x.k === 'edge') {
        const live = S.status.state === 'live';
        ctx.fillStyle = live ? 'rgba(127,227,168,0.6)' : 'rgba(143,182,201,0.5)'; ctx.fillRect(0, top + 0.62 * ppe, W, Math.max(1, 0.08 * ppe));
        ctx.font = FONT(true, 0.95 * ppe); const label = (live ? '\u25bc newest \u00b7 LIVE ' : '\u25cb ') + (source && source.name || route) + ' \u00b7 new lines arrive here';
        const lw = ctx.measureText(label).width + 0.6 * ppe; ctx.fillStyle = '#04121c'; ctx.fillRect(0.2 * ppe, top, Math.min(lw, W), L.LINE * ppe);
        ctx.fillStyle = live ? '#7fe3a8' : S.status.state === 'error' ? COL.bad : COL.dim; ctx.fillText(label, 0.5 * ppe, top + 0.98 * ppe, W - 0.8 * ppe);
      }
      else if (x.k === 'notice') {
        const st = S.status, big = st.state === 'error' ? st.text : st.state === 'none' ? 'No room connected to ' + route + ' yet' : st.state === 'empty' ? 'No messages yet' : 'Opening ' + route + '\u2026';
        ctx.font = FONT(true, 1.5 * ppe); ctx.fillStyle = st.state === 'error' ? COL.bad : COL.cyan; ctx.fillText(big, 0.15 * ppe, top + 1.3 * ppe, W - 0.3 * ppe);
        ctx.font = FONT(false, 0.9 * ppe); ctx.fillStyle = COL.dim;
        ctx.fillText(st.state === 'none' ? 'Ready: give this wall a room API and it fills live.' : st.state === 'login' ? 'Log in on epistles.xyz to read this room.' : 'Be first: write below. It appears here live.', 0.15 * ppe, top + 2.6 * ppe, W - 0.3 * ppe);
      }
    } }
    const mat = t.mesh.material;
    if (mat.map) mat.map.dispose();
    S.texBytes -= t.bytes || 0;
    mat.map = makeTexture(cv); mat.needsUpdate = true;
    t.bytes = Math.round(W * H * 4 * 1.33); S.texBytes += t.bytes;
    t.tier = tier; t.stale = false; t.mesh.visible = true; S.renders++;
  }
  function drawHeader(ctx, t, ppe) {
    const W = t.w * ppe, H = t.h * ppe, st = S.status;
    ctx.textBaseline = 'alphabetic';
    ctx.font = FONT(true, 30 * ppe); ctx.fillStyle = '#aef8ff'; ctx.shadowColor = 'rgba(95,244,255,0.75)'; ctx.shadowBlur = 3 * ppe;
    ctx.fillText('SOLUV', 0, 27 * ppe); const sw = ctx.measureText('SOLUV').width; ctx.shadowBlur = 0;
    ctx.font = FONT(true, 14 * ppe); ctx.fillStyle = COL.gold; ctx.fillText(String(room), sw + 3 * ppe, 27 * ppe);
    ctx.font = FONT(false, 7 * ppe); ctx.fillStyle = '#d7f6ff'; ctx.fillText(route + '  \u00b7  ' + title, 0, 38 * ppe);
    const stateCol = st.state === 'live' ? '#7fe3a8' : st.state === 'error' ? COL.bad : st.state === 'reconnecting' ? '#ffbf5a' : COL.dim;
    ctx.font = FONT(true, 5 * ppe); ctx.fillStyle = stateCol;
    ctx.fillText((st.state === 'live' ? '\u25cf LIVE  ' : st.state === 'error' ? '\u25cf ' : '\u25cb ') + st.text + (testLabel ? '  \u00b7  ' + testLabel : '') + (fontProblem ? '  \u00b7  ' + fontProblem : ''), 0, H - 4.2 * ppe, W * 0.62);
    ctx.font = FONT(false, 2.6 * ppe); ctx.fillStyle = COL.dim;
    if (S.archivedMsgs > 0 && archiveUrl) {   // the archive link, pinned at the top of the glass
      ctx.fillStyle = 'rgba(231,181,58,0.16)'; ctx.fillRect(0, H - 3.6 * ppe, W * 0.62, 3.6 * ppe);
      ctx.font = FONT(true, 2.6 * ppe); ctx.fillStyle = COL.gold;
      ctx.fillText('\ud83d\udcdc ARCHIVE: ' + S.archivedMsgs + ' older message' + (S.archivedMsgs === 1 ? '' : 's') + ' \u00b7 tap here to open the full chat (' + archiveUrl.replace(/^https?:\/\/[^/]+/, '') + ')', 0.6 * ppe, H - 0.9 * ppe, W * 0.6);
    } else ctx.fillText((source && source.type === 'lobby' ? 'The site lobby itself: one chat, top to bottom \u00b7 posts here or on epistles.xyz/chat/lobby are the same messages' : 'One chat, read top to bottom \u00b7 newest at the bottom'), 0, H - 0.6 * ppe, W * 0.62);
    // right block: access / mode / cams placeholders + the camera tile band (maxCams slots)
    ctx.textAlign = 'right';
    ctx.font = FONT(true, 3.6 * ppe); ctx.fillStyle = '#9fd8ff';
    ctx.fillText('ACCESS ' + meta.access.toUpperCase() + '  \u00b7  MODE ' + meta.mode.toUpperCase(