a73x

web/mux.js

Ref:   Size: 82.0 KiB   History

// `mux web` shell (M-web Task 8). Thin by construction: every decision that
// can live in Zig lives in mux_core.wasm; this file is glue — WebSocket
// pump, canvas painter, event translation.
//
// THE ONE DISCIPLINE: never cache a view of wasm memory. Any call can
// grow linear memory, and growth detaches every ArrayBuffer view. Every
// access below re-reads exports.memory.buffer. (Spike-measured: caching
// gives silent garbage or a TypeError, and not immediately.)
'use strict';

// Wire bytes (protocol.zig MsgType / webhub.zig envelope).
const MSG = {
  attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05,
  selection_req: 0x0b,
  snapshot: 0x95, exit_status: 0x82, scrollback_chunk: 0x97, delta: 0x96,
  pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90,
};
const CLIENT_ACTION = {
  ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4,
};
const ENV_FRAME = 0x00, ENV_CONTROL = 0x01;
// OSC 52's Pc for the system clipboard, the only target this page can honour.
const CLIPBOARD_TARGET_C = 0x63; // 'c'

// input.Key by @intFromEnum (wasm_core.zig pins the table).
const KEY = {
  char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4,
  ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8,
  Home: 9, End: 10, Insert: 11, Delete: 12, PageUp: 13, PageDown: 14,
  F1: 15, F2: 16, F3: 17, F4: 18, F5: 19, F6: 20, F7: 21, F8: 22,
  F9: 23, F10: 24, F11: 25, F12: 26,
};

// Pastes chunk at 32 KiB so no browser→hub message approaches the hub's
// 64 KiB inbound bound (webhub.zig ws_buffer_len).
const PASTE_CHUNK = 32 * 1024;
// A missing history reply must not permanently lock wheel/drag movement.
// Two seconds tolerates an ordinary remote round trip without letting the
// 120ms drag interval flood retries while one request is still outstanding.
const SCROLL_REQUEST_TIMEOUT_MS = 2000;
// Extraction may format substantially more data than a viewport fetch. Five
// seconds bounds old-daemon/no-reply behavior without treating normal remote
// extraction of a large selection as unavailable too eagerly.
const SELECTION_REQUEST_TIMEOUT_MS = 5000;

// The reconnect schedule, and it is NOT a new one: this is client.zig's
// nextBackoffMs (src/client/client.zig — "The reconnect pacing, M7's numbers")
// spelled in JS because the page has no Zig. Iteration zero waits not at
// all, then 200ms doubling to a 2s cap, and no retry cap ever. The hub
// paces its daemon leg by that function; the browser leg pacing itself
// differently would make one flapping link behave two ways depending on
// which half broke. Change both or neither.
const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000));

// The states a tile reaches ON ITS OWN and does not leave on link news
// alone (see setStatus). Everything else in the badge vocabulary —
// connecting, up, reconnecting, gone — is narration ABOUT the link,
// from this socket's own lifecycle or from the hub's control messages.
const TERMINAL = new Set(['stuck', 'exited', 'refused']);

// How many straight failures to OPEN before a tile reads 'gone' rather
// than 'reconnecting'. Four is where the schedule hits its cap
// (0+200+400+800 = 1.4s of trying), which is long enough that restarting
// the hub under a live page never flashes 'gone'.
const GONE_AFTER_FAILURES = 4;

// --- settings: the page's theme and font ---
//
// Both belong to the browser alone. Colour never reaches either VT engine:
// a cell carries a palette INDEX and colorOf invents the hex at paint
// time, so a theme is repainted, never sent. Font is the exception that
// talks back — it decides how many cells fit the zoom box, which is a
// resize like any other, and only a zoomed tile is entitled to make it.
const SETTINGS_KEY = 'mux.settings.v1';
const DEFAULT_FONT_FAMILY = 'ui-monospace, monospace';
const DEFAULT_FONT_SIZE = 14;
// A cell must stay measurable: at size 0 the zoom box divides by a
// zero-width cell, zoomCols is Infinity, and setUint16 hands the daemon a
// resize to no columns at all.
const FONT_SIZE_MIN = 6, FONT_SIZE_MAX = 72;
// A theme is a couple of dozen short lines. The cap is what stops a
// dropped PNG or a log file from being read into memory to find that out.
const THEME_BYTES_MAX = 1 << 16;

const ANSI_DEFAULT = [
  '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
  '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
];
const BUILTIN_FG = '#c8ccd4', BUILTIN_BG = '#000000', BUILTIN_SELECTION = '#6ab0e0';

// The 240 colours above the ANSI 16 are an xterm formula, not a theme's to
// set: a theme that redefined one would leave the cube incoherent with the
// sixteen it is derived alongside. Themes name 0-15; we compute the rest.
function buildPalette(base16) {
  const p = base16.slice();
  const lv = [0, 95, 135, 175, 215, 255];
  for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++)
    p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`);
  for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); }
  return p;
}

let FONT = `${DEFAULT_FONT_SIZE}px ${DEFAULT_FONT_FAMILY}`;
let DEFAULT_FG = BUILTIN_FG, DEFAULT_BG = BUILTIN_BG;
let PALETTE = buildPalette(ANSI_DEFAULT);
// The overlays stay translucent so the glyph underneath survives them:
// they are a theme colour plus an alpha byte, not colours of their own.
let CURSOR_FILL = `${BUILTIN_FG}88`, SELECTION_FILL = `${BUILTIN_SELECTION}55`;

function colorOf(packed, dflt) {
  const kind = packed >>> 24;
  if (kind === 1) return PALETTE[packed & 0xff];
  if (kind === 2) return `rgb(${(packed >> 16) & 0xff},${(packed >> 8) & 0xff},${packed & 0xff})`;
  return dflt;
}

// The shared Zig core has already authenticated OSC 52's target, size,
// and base64 alphabet. The browser adds its one platform constraint here:
// the decoded clipboard must be valid UTF-8 text.
function clipboardText(base64Bytes) {
  try {
    let ascii = '';
    for (const byte of base64Bytes) ascii += String.fromCharCode(byte);
    const binary = atob(ascii);
    const decoded = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
    return new TextDecoder('utf-8', { fatal: true }).decode(decoded);
  } catch (_) {
    return null;
  }
}

// ghostty accepts #rrggbb, #rgb, and bare rrggbb. Anything else — a CSS
// colour name, a truncated hex, a line of a PNG — is not a colour here:
// refusing it is what lets "0 colours found" mean "not a theme file".
function normalizeHex(raw) {
  const s = raw.trim().replace(/^#/, '').toLowerCase();
  if (/^[0-9a-f]{3}$/.test(s)) return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`;
  if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`;
  return null;
}

const THEME_KEYS = {
  background: 'bg',
  foreground: 'fg',
  'cursor-color': 'cursor',
  'selection-background': 'selection',
};

// A ghostty theme file is `key = value` lines with `#` comments. The one
// trap is that a palette line's VALUE contains an `=` of its own
// (`palette = 0=#45475a`), so every split here takes the FIRST one only —
// which is also what stops `keybind = ctrl+a=new_tab` in a whole config
// from being read as anything at all.
//
// Returns null when nothing was recognized: a file that is not a theme
// must be refused, never applied as an empty one.
function parseGhosttyTheme(text) {
  const theme = { palette: new Array(16).fill(null), fg: null, bg: null, cursor: null, selection: null };
  let applied = 0, ignored = 0;
  for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eq = trimmed.indexOf('=');
    if (eq < 0) { ignored++; continue; }
    const key = trimmed.slice(0, eq).trim();
    const value = trimmed.slice(eq + 1).trim();
    if (key === 'palette') {
      const inner = value.indexOf('=');
      const index = inner < 0 ? NaN : Number(value.slice(0, inner).trim());
      const hex = inner < 0 ? null : normalizeHex(value.slice(inner + 1));
      if (!Number.isInteger(index) || index < 0 || index > 15 || !hex) { ignored++; continue; }
      theme.palette[index] = hex; // duplicates: last wins, as ghostty reads them
      applied++;
      continue;
    }
    const slot = THEME_KEYS[key];
    const hex = slot ? normalizeHex(value) : null;
    // cursor-text and selection-foreground land here: a translucent
    // overlay cannot honour a text colour, so naming one is not applying
    // it and the count must say so.
    if (!slot || !hex) { ignored++; continue; }
    theme[slot] = hex;
    applied++;
  }
  return applied === 0 ? null : { theme, applied, ignored };
}

function clampFontSize(size) {
  const n = Number(size);
  if (!Number.isFinite(n) || n <= 0) return DEFAULT_FONT_SIZE;
  return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(n)));
}

// One cell's font metrics, measured against the real font. Re-measured
// whenever the font changes, because every geometry below — the grid a
// zoomed tile claims, the canvas size, the cell under the pointer —
// divides by this.
function measureMetrics(font) {
  const c = document.createElement('canvas').getContext('2d');
  c.font = font;
  const m = c.measureText('M');
  // Fallbacks for browsers without fontBoundingBox*: proportions of the
  // em, since the 11/3 they replace only ever matched 14px.
  const px = parseFloat(font) || DEFAULT_FONT_SIZE;
  const ascent = Math.ceil(m.fontBoundingBoxAscent || px * 0.79);
  const descent = Math.ceil(m.fontBoundingBoxDescent || px * 0.21);
  // Floored at one pixel: a zero-width cell is how a resize to zero
  // columns reaches the daemon.
  return { w: Math.max(1, Math.ceil(m.width)), h: Math.max(1, ascent + descent), ascent };
}

let METRICS = measureMetrics(FONT);

// Chrome with cookies blocked throws on ACCESSING localStorage, not just
// on write, and verify.js's shell has none at all — so every touch goes
// through here and a browser that says no costs us the setting, not boot.
function settingsStorage() {
  try { return window.localStorage || null; } catch (_) { return null; }
}

function defaultSettings() {
  return { theme: null, fontFamily: null, fontSize: DEFAULT_FONT_SIZE };
}

function loadSettings() {
  // No try around the read: settingsStorage is the one place that knows
  // storage can refuse, and a second catch here would make its guard
  // untestable — both would have to be removed before anything failed.
  const raw = settingsStorage()?.getItem(SETTINGS_KEY) ?? null;
  if (!raw) return defaultSettings();
  try {
    const stored = JSON.parse(raw);
    // Version-gated rather than merged: a shape from a build that spelled
    // these differently is not worth guessing at.
    if (!stored || stored.v !== 1) return defaultSettings();
    return { ...defaultSettings(), ...stored };
  } catch (_) {
    return defaultSettings();
  }
}

function saveSettings(settings) {
  try {
    settingsStorage()?.setItem(SETTINGS_KEY, JSON.stringify({ ...settings, v: 1 }));
  } catch (_) {
    // Quota, private mode, or a browser that refuses: the setting is
    // still live on this page, it just will not outlive the tab.
  }
}

// The one place the render globals move. Every tile repaints from what
// its core already holds — a theme sends nothing — and the zoomed tile
// re-claims the grid, because the cell it fits its box with just changed.
function applySettings(settings) {
  const theme = settings.theme;
  FONT = `${clampFontSize(settings.fontSize)}px ${settings.fontFamily || DEFAULT_FONT_FAMILY}`;
  METRICS = measureMetrics(FONT);
  PALETTE = buildPalette(ANSI_DEFAULT.map((builtin, i) => theme?.palette[i] || builtin));
  DEFAULT_FG = theme?.fg || BUILTIN_FG;
  DEFAULT_BG = theme?.bg || BUILTIN_BG;
  CURSOR_FILL = `${theme?.cursor || DEFAULT_FG}88`;
  SELECTION_FILL = `${theme?.selection || BUILTIN_SELECTION}55`;
  // The canvas element's own background is terminal ground: it is what
  // shows before the first frame lands and through the sub-pixel edge
  // sizeCanvas's rounding leaves. Hardcoded black flashed through a light
  // theme, so the CSS reads it from here.
  document.documentElement?.style?.setProperty('--term-bg', DEFAULT_BG);
  for (const tile of tilesById.values()) tile.repaintForSettings();
  zoomedTile?.sendResizeIfDiffers();
}

// The page's live settings. Boot replaces this with what was persisted;
// the panel edits it through updateSettings so no caller can apply a
// change without saving it, or save one without applying it.
let settings = defaultSettings();

function updateSettings(patch) {
  settings = { ...settings, ...patch };
  applySettings(settings);
  saveSettings(settings);
}

let compiledCore = null; // one compile, one instance per tile

class Tile {
  constructor(id, label, wallEl, session) {
    // The hub's tile id, stable for the run and NOT a position: the wall is
    // reorderable, so an index would name a different tile after one drag.
    this.id = id;
    this.label = label;
    this.dead = false;        // shutdown ran: this tile reconnects no more
    this.reconnectTimer = null;
    // The daemon session this tile attaches to, '' for the default. It is
    // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug
    // back out of the label — see sendAttach for what happens to it.
    this.session = session || '';
    this.zoomed = false;
    this.scrollPages = 0;
    this.gotState = false; // JS mirror of the CLI's state_since_attach
    this.wsBackoffMs = 0;  // browser-leg reconnect, client.zig's schedule
    this.wsFailures = 0;   // straight failures to OPEN, for the badge
    this.wsOpened = false; // did THIS socket ever open?
    this.replayFailures = 0;   // straight failures to APPLY a frame
    this.replayBackoffMs = 0;
    this.replayDead = false;   // give up: send nothing further
    this.drawScale = 0;    // logical→CSS factor the backing store is sized for
    // The badge's source of truth. The DOM renders this; nothing reads
    // it back (see renderBadge).
    this.status = 'connecting';
    this.statusText = 'connecting';
    this.pendingClipboard = null;
    this.clipboardVersion = 0;
    this.clipboardWriteActive = false;
    this.copyUiOwner = null; // null, 'clipboard', or 'selection'
    this.selectionCopyVersion = 0;
    this.viewStartRow = 0;
    this.scrollRequest = null; // {fromPages, start, count}; at most one in flight
    this.scrollRequestTimer = null;
    this.selection = null; // {anchor, active, requestId, text}
    this.nextSelectionId = 0;
    this.selectionRequestTimer = null;
    this.selectionRequestVersion = 0;
    this.activeSelectionRequest = null; // {selection, id, generation}
    this.selectionFeedbackTimer = null;
    this.selectionFeedbackVersion = 0;
    this.drag = null;
    this.lastPointerX = null;
    this.lastPointerY = null;
    this.selectionScrollTimer = null;

    this.el = document.createElement('div');
    this.el.className = 'tile';
    // The id lives on the node too: reorder reads the wall's order back out
    // of the DOM, and the node is what a drag moves.
    this.el.dataset.tileId = String(id);
    this.el.innerHTML =
      `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><span class="badge connecting">connecting</span></header>`;
    // The label only: tiles come and go as sessions do, so a baked-in
    // number would lie the moment anything ended.
    this.el.querySelector('.label').textContent = label;
    // "New session here": the wall's one door. There is nothing to type —
    // the tile already names the daemon, and the daemon names the session.
    this.el.querySelector('.spawn').addEventListener('click', (ev) => {
      ev.stopPropagation(); // a spawn is not a zoom
      spawnHere(this.id);
      // Same reason the copy control does it: clicking a button takes
      // focus, and the hidden IME is what a zoomed tile's keys come from.
      ime.focus();
    });
    this.copyButton = this.el.querySelector('.copy-request');
    this.copyButton.addEventListener('click', (ev) => {
      ev.stopPropagation();
      if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard();
      else if (this.copyUiOwner === 'selection') this.copySelection();
      ime.focus();
    });
    this.canvas = document.createElement('canvas');
    this.el.appendChild(this.canvas);
    this.ctx = this.canvas.getContext('2d');
    wallEl.appendChild(this.el);

    this.el.addEventListener('click', (ev) => {
      if (!this.zoomed) { ev.stopPropagation(); zoom(this); }
    });
    this.el.addEventListener('wheel', (ev) => {
      if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); }
      // Wall tiles don't scroll (spec): the event falls through to the page.
    }, { passive: false });
    // Drag to reorder. The whole tile is the handle: the header is a thin
    // strip and a wall tile has no other inert surface to grab.
    this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
    this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
    this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
    this.canvas.addEventListener('pointercancel', (ev) => {
      if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
    });
    this.canvas.addEventListener('lostpointercapture', (ev) => {
      if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
    });
  }

  async start() {
    // NOT `const { instance }`: WebAssembly.instantiate has two return
    // shapes, and which one you get depends on what you passed. Given
    // BYTES it resolves to {module, instance}; given an already-compiled
    // Module — which is what compiledCore is — it resolves to the
    // Instance itself. Destructuring `instance` off an Instance yields
    // undefined, and the TypeError lands inside an async method whose
    // rejection nobody was reading. (verify.js passes bytes, so it takes
    // the other shape and is right to destructure.)
    const instance = await WebAssembly.instantiate(compiledCore, {});
    this.core = instance.exports;
    if (this.core.mux_init(80, 24) !== 0) { this.setStatus('gone', 'init failed'); return; }
    this.sizeCanvas();
    this.connect();
  }

  // The browser leg's own reconnect. The hub reconnects its daemon leg
  // and narrates it; nothing was reconnecting THIS socket, so a restarted
  // hub (or a laptop that slept) left every tile permanently 'gone' with
  // a reload as the only cure. The wasm core survives across this, so the
  // re-attach on `up` still quotes have_seq/have_epoch and resumes.
  connect() {
    this.wsOpened = false;
    this.ws = new WebSocket(`ws://${location.host}/ws/${this.id}`);
    this.ws.binaryType = 'arraybuffer';
    this.ws.onopen = () => {
      this.cancelScrollRequest();
      this.clearSelection();
      this.wsOpened = true;
      this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
      this.wsFailures = 0;
      // A genuinely new socket is a genuinely new chance: the hub may
      // have been restarted onto a different session, so the frame this
      // tile choked on may simply not exist any more, and the session
      // that refused may exist by now. Without this a tile that gave up
      // once would stay dead until the page reloaded.
      // The hub narrates from here on: connecting → up.
      this.revive('connecting', 'connecting');
    };
    this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data));
    // onerror always precedes onclose; the badge is decided in one place.
    this.ws.onerror = () => {};
    this.ws.onclose = () => {
      // FIRST: a shut-down tile's socket closes BECAUSE it was shut down.
      // Reconnecting it would resurrect a tile the wall no longer has, and
      // a status change would paint a node already out of the document.
      if (this.dead) return;
      this.cancelScrollRequest();
      this.clearSelection();
      this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
      const dead = this.wsFailures >= GONE_AFTER_FAILURES;
      this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
      const wait = this.wsBackoffMs;
      this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs);
      this.reconnectTimer = setTimeout(() => {
        this.reconnectTimer = null;
        this.connect();
      }, wait);
    };
  }

  // The tile is gone from the wall (the hub said so). Everything this tile
  // owns that outlives its DOM node — the socket, the pending reconnect —
  // is cut here, because a timer that fires later would dial a `/ws/<id>`
  // the hub answers with a plain 404 and narrate it onto a detached node.
  shutdown() {
    this.dead = true;
    if (zoomedTile === this) unzoom(); // never leave the shade over nothing
    if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
    this.reconnectTimer = null;
    this.cancelScrollRequest();
    this.clearSelection(false);
    this.ws?.close();
    this.el.remove();
  }

  // Every recovery from a bad frame answers with a re-attach, and the
  // daemon answers THAT with the same snapshot that just failed — so
  // "recover and retry" is a flood unless it is bounded. Bounded on the
  // machinery the socket's own reconnect already uses: the same schedule,
  // the same ceiling, and then it stops for good. A tile stuck on one
  // frame is a bad tile; a tile hammering the hub is a bad wall.
  //
  // `terminal` is what the badge says when it gives up — the cap case
  // and the core-refused case fail for different reasons and are worth
  // telling apart when someone comes to read it.
  replayFailed(why, terminal) {
    if (this.replayDead) return;
    this.replayFailures++;
    console.warn(`mux tile ${this.id}: ${why} (replay failure ${this.replayFailures})`);
    if (this.replayFailures >= GONE_AFTER_FAILURES) {
      this.replayDead = true; // sendAttach is gated on this
      this.setStatus('stuck', terminal);
      return;
    }
    const wait = this.replayBackoffMs;
    this.replayBackoffMs = nextBackoffMs(this.replayBackoffMs);
    setTimeout(() => this.sendAttach(true), wait);
  }
  // A frame that applies cleanly is the recovery every terminal state was
  // waiting for — the session shrank and its snapshot fits again, the
  // attach that was refused was admitted by the time we re-attached — so
  // it revives unconditionally. This is the clearing the old code spelled
  // twice (stuck→up here, refused→up at the call site) and still missed once.
  replaySucceeded() { this.revive('up', 'up'); }

  // The core is unusable — re-init and re-attach from nothing. Everything
  // it held is gone, so the attach quotes (0,0) and the daemon answers
  // with a snapshot.
  resetCore(why) {
    this.clearSelection(false);
    this.cancelScrollRequest();
    this.scrollPages = 0;
    if (this.core.mux_init(80, 24) !== 0) {
      this.clearBackingCanvas();
      this.replayDead = true;
      this.setStatus('stuck', 'core failed');
      return;
    }
    this.gotState = false;
    this.renderBadge(); // scrollPages changed; the mask must lift with it
    this.drawScale = 0; // force the backing store to be re-sized
    this.reflow(); // paint only after mux_init made the replacement core valid
    this.replayFailed(`re-initialized the core after ${why}`, 'replay failed');
  }

  // --- wasm memory access, always through fresh views ---
  mem() { return new Uint8Array(this.core.memory.buffer); }
  stage(bytes) {
    if (bytes.length > this.core.mux_input_cap()) return false;
    this.mem().set(bytes, this.core.mux_input_ptr());
    return true;
  }
  outBytes() {
    const ptr = this.core.mux_output_ptr(), len = this.core.mux_output_len();
    return this.mem().slice(ptr, ptr + len);
  }

  // --- wire out ---
  sendFrame(type, payload) {
    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
    const msg = new Uint8Array(6 + payload.length);
    msg[0] = ENV_FRAME;
    msg[1] = type;
    new DataView(msg.buffer).setUint32(2, payload.length, true);
    msg.set(payload, 6);
    try {
      this.ws.send(msg);
      return true;
    } catch (_) {
      return false;
    }
  }
  sendAttach(fresh) {
    // THE PASSIVITY CONTRACT: an unzoomed wall tile attaches at 0x0, which
    // is how `mux a` and an argv-only CLI wall view (`mux wall SPELLING...`)
    // spell the same thing — no size claim at all. Every CLI tile that
    // CREATES claims its own rect instead, and has done since panes
    // landed. The daemon answers a unicast snapshot carrying the true
    // grid and the slot stays 0x0 forever, so this tile can never move the
    // shared session. Only the zoomed tile claims its real size.
    //
    // 0x0 rather than a small size on purpose: 1x1 is a size a client on a
    // tiny terminal genuinely sends, so it cannot mean "no claim" without
    // also meaning "this client wants 1x1" (server.zig resolveSession).
    // ONE gate for every attach, wherever it comes from — the `up`
    // control message included. A tile that gave up on replaying must
    // not be talked back into asking for the same frame again.
    // A shut-down tile has no wall to talk for: replayFailed's retry may
    // already be scheduled, and would otherwise repaint a detached node.
    if (this.dead) return;
    // A session the user ENDED stays ended — wallview's pump ends on an
    // exit_status and leaves the tile dead, and this is that ruling on
    // the page's side of the wire. Not decoration: a ZOOMED tile attaches
    // at its real size below, and a sized attach CREATES (server.zig
    // resolveSession is attach-or-create for any usable size), so without
    // this typing `exit` in a zoomed tile spawns a fresh shell. Unzoomed
    // tiles were always safe by the passivity contract; the zoom is what
    // carries the size.
    //
    // Here rather than at the callers, because there are three and the
    // dangerous one is the least obvious: replayFailed's retry is a bare
    // setTimeout that nothing cancels, so a replay failure followed
    // within its wait by the shell exiting lands a sized attach on a tile
    // already badged exited. Beside `dead` rather than beside
    // `replayDead` so the clears below are skipped: a dead tile's last
    // screen is still worth selecting text off, and a hub that redials
    // every few seconds would otherwise wipe the selection each time.
    //
    // Exactly 'exited', not the whole TERMINAL set. 'refused' MUST keep
    // re-attaching — the hub's restore heal is refused → it births the
    // session → re-dial → `up` → this attach — and 'stuck' is what
    // `replayDead` below already answers. The latch is cleared by
    // ws.onopen's revive: a browser-leg reconnect (reload, hub restart)
    // is a new epoch and may recreate, which is the deliberate escape.
    if (this.status === 'exited') return;
    this.cancelScrollRequest();
    this.clearSelection();
    if (this.replayDead) return;
    const cols = this.zoomed ? this.zoomCols() : 0;
    const rows = this.zoomed ? this.zoomRows() : 0;
    const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
    if (n > 0) {
      // The session name is transport dressing, appended AFTER the payload
      // the replica built for itself — which is why wasm_core knows nothing
      // about it. Everything past the attach's fixed 20 bytes is the name
      // (protocol.encodeAttachNamed does exactly this append), so a tile
      // with no session sends exactly the bytes it sent before M18: the
      // empty name IS the default session, on the wire and here.
      // outBytes() already copies out of wasm memory, so the join below is
      // reading a buffer nothing else can move under it.
      let payload = this.outBytes();
      if (this.session) {
        const name = new TextEncoder().encode(this.session);
        const joined = new Uint8Array(payload.length + name.length);
        joined.set(payload); joined.set(name, payload.length);
        payload = joined;
      }
      this.gotState = false; this.sendFrame(MSG.attach, payload);
    }
  }
  sendKey(keyId, cp, mods) {
    this.clearSelection();
    const n = this.core.mux_key_encode(keyId, cp, mods);
    if (n > 0) this.sendFrame(MSG.input, this.outBytes());
  }
  // Plain UTF-8 bytes, no wrap. This is TYPING: an IME's compositionend
  // hands over finished text, and finished text is what the user typed,
  // however they reached it. Bracketing it would announce a paste to the
  // application — vim would skip paste mode's indentation, a shell's
  // bracketed-paste guard would refuse to run it.
  sendText(text) {
    this.clearSelection();
    const bytes = new TextEncoder().encode(text);
    for (let off = 0; off < bytes.length; off += PASTE_CHUNK) {
      const chunk = bytes.subarray(off, off + PASTE_CHUNK);
      if (!this.stage(chunk)) return;
      if (this.core.mux_text_encode(chunk.length) > 0)
        this.sendFrame(MSG.input, this.outBytes());
    }
  }
  // At most ONE wrap around the WHOLE paste, however many messages it
  // takes: optional begin, N unwrapped chunks, optional end. The WASM
  // semantic core owns the sampled mode-2004 policy; the shell sends only
  // the marker frames it returns.
  sendPaste(text) {
    if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
    // Closed even when a chunk fails to STAGE — that is the case this
    // buys, and it is worth buying: a short paste beats an application
    // left in paste mode.
    //
    // It buys nothing against the socket dying mid-paste. sendFrame drops
    // silently when the WebSocket is not OPEN, so the end marker never
    // transits and the application sits in paste mode with bytes it will
    // treat as pasted. Reconnecting cannot clear that: the state is on
    // the far side of the pty, and nothing this client can send is
    // distinguishable from more paste. Untreated in v1.
    try {
      this.sendText(text);
    } finally {
      if (this.core.mux_paste_end() > 0) this.sendFrame(MSG.input, this.outBytes());
    }
  }
  sendResizeIfDiffers() {
    // Only the zoomed tile, and only when its computed size actually
    // differs from the session's grid (spec: passive wall, latest-wins
    // respected).
    if (!this.zoomed) return;
    const cols = this.zoomCols(), rows = this.zoomRows();
    if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return;
    // A scrollback reply encodes the grid row count sampled by its request.
    // Never apply those bytes after changing the grid they describe.
    this.cancelScrollRequest();
    const p = new Uint8Array(4);
    const dv = new DataView(p.buffer);
    dv.setUint16(0, cols, true);
    dv.setUint16(2, rows, true);
    this.sendFrame(MSG.resize, p);
  }
  // The zoomed tile's OWN box, not the window: index.html insets it
  // (3vh 4vw) so a ring of shade stays visible and clickable, and that
  // ring is the only way out of zoom. Measuring the element rather than
  // re-deriving the inset here means the CSS stays the single source of
  // the geometry — change the inset and the grid follows.
  zoomBox() {
    const r = this.el.getBoundingClientRect();
    const hdr = this.el.querySelector('header').getBoundingClientRect().height;
    // border-box: the rect includes the 1px border on each side.
    return { w: Math.max(0, r.width - 2), h: Math.max(0, r.height - hdr - 2) };
  }
  zoomCols() { return Math.max(2, Math.floor(this.zoomBox().w / METRICS.w)); }
  zoomRows() { return Math.max(2, Math.floor(this.zoomBox().h / METRICS.h)); }

  // --- wire in ---
  onMessage(bytes) {
    if (bytes.length < 1) return;
    if (bytes[0] === ENV_CONTROL) {
      const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
      // The hub speaks about the DAEMON leg, which is not the whole of
      // this tile: setStatus withholds the badge while the tile holds a
      // terminal state of its own. The bookkeeping below runs regardless
      // — sendAttach has its own gates (dead, exited, replayDead) and
      // keeps them.
      if (state !== 'up') {
        this.cancelScrollRequest();
        this.clearSelection();
      }
      this.setStatus(state, state);
      if (state === 'up') this.sendAttach(false); // browser owns re-attach
      return;
    }
    if (bytes[0] !== ENV_FRAME || bytes.length < 6) return;
    const type = bytes[1];
    const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(2, true);
    if (6 + len !== bytes.length) return;
    const payload = bytes.subarray(6);

    switch (type) {
      case MSG.snapshot:
      case MSG.delta: {
        if (type === MSG.snapshot) {
          // A snapshot can replace the grid dimensions. History scratch and
          // retained coordinates name the old geometry, so leave history
          // without painting it; the successful apply below owns the one
          // repaint from the new live snapshot.
          this.clearSelection(false);
          this.cancelScrollRequest();
          this.scrollPages = 0;
          this.renderBadge();
        }
        // replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone
        // proves the attach was admitted — decodable or not — while a
        // short snapshot proves nothing. Without this an undecodable
        // delta followed by exit_status read as a refusal instead of as
        // the shell exiting.
        if (type === MSG.delta) this.gotState = true;
        if (!this.stage(payload)) {
          // Over the core's 256 KiB staging cap. Silently returning left
          // the replica permanently behind the session; a re-attach at
          // (0,0) costs one snapshot and is correct — but the snapshot it
          // asks for is the one that just failed, so it is bounded.
          this.replayFailed(
            `frame of ${payload.length} B over the staging cap`, 'frame too big');
          return;
        }
        const r = this.core.mux_apply_frame(type, payload.length);
        if (r === 0) {
          this.gotState = true;
          this.replaySucceeded();
          if (this.scrollPages === 0) this.paintLive();
          return;
        }
        if (r === 1) { this.sendAttach(true); return; } // RESYNC: quote (0,0)
        // Negative is the core refusing: -1 uninitialized or readout
        // buffers left stale by a failed grid move (wasm_core.zig
        // documents that one as fatal-re-init), -2 over the cap, -3 a
        // payload it will not replay. None of them leave a replica that
        // can be trusted to keep painting.
        this.resetCore(`mux_apply_frame(0x${type.toString(16)}) = ${r}`);
        return;
      }
      case MSG.exit_status: {
        this.cancelScrollRequest();
        this.clearSelection();
        // Before any state this is the daemon refusing the attach — the
        // CLI's own discriminator, mirrored, and its own word for it. WHY
        // it refused is the daemon's business and it does not say; a
        // guess ("session full") was wrong more often than not.
        if (!this.gotState) this.setStatus('refused', 'refused');
        else this.setStatus('exited', `exited ${payload[0] ?? 0}`);
        return;
      }
      case MSG.scrollback_chunk: {
        if (this.scrollPages === 0) return;
        if (payload.length < 6) {
          if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
          return;
        }
        const echoed = new DataView(payload.buffer, payload.byteOffset, 6);
        const start = echoed.getUint32(0, true);
        const count = echoed.getUint16(4, true);
        // A viewport becomes addressable by pointer coordinates only after
        // the exact request for it decoded and painted. Delayed older wheel
        // replies must never relabel the cells currently on the canvas.
        const request = this.scrollRequest;
        if (!request || start !== request.start || count !== request.count) return;
        // The WHOLE chunk, echoed header included: the rows are CellRows and
        // only that header says how many of them there are.
        if (!this.stage(payload)) { this.scrollRequestFailed(request); return; }
        if (this.core.mux_scroll_feed(payload.length) === 0) {
          this.clearScrollRequestTimer();
          this.scrollRequest = null;
          this.viewStartRow = start;
          this.moveDragToCurrentPointer();
          this.paintScroll();
        } else this.scrollRequestFailed(request);
        return;
      }
      case MSG.term_modes:
      case MSG.term_event:
      case MSG.selection_reply: {
        if (!this.stage(payload)) return;
        const action = this.core.mux_client_frame(type, payload.length);
        if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();
        else if (action === CLIENT_ACTION.selection) this.onSelectionReply();
        return;
      }
      case MSG.pty_mode: // no prediction in the web client (spec non-goal)
      default:
        return;
    }
  }

  onClipboardEffect() {
    if (!this.zoomed) return;
    // The shared core accepts every Pc xterm defines, because the native
    // client can honour them: `p` is PRIMARY, `s` the selection, `0`-`7`
    // the cut buffers. A browser has one destination and no way to route
    // the others, so writing them all to the system clipboard would let an
    // ordinary X11 mouse drag inside the session — which emits
    // `ESC]52;p;…` — silently clobber what the human last copied on their
    // own machine. Non-`c` targets stay valid on the wire and are simply
    // not this tile's business.
    if (this.core.mux_clipboard_target() !== CLIPBOARD_TARGET_C) return;

    // The semantic core's clipboard payload borrows the staging buffer.
    // Copy it before calling or awaiting anything: either can move WASM
    // memory or let the next frame replace the borrowed bytes.
    const ptr = this.core.mux_clipboard_ptr();
    const len = this.core.mux_clipboard_len();
    const encoded = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
    const text = clipboardText(encoded);
    if (text === null) return;

    this.clearSelectionFeedback();
    const version = ++this.clipboardVersion;
    this.pendingClipboard = text;
    this.renderCopyUi('clipboard', 'copy-request', 'Copy');
    return this.tryClipboardWrite(version, true);
  }

  renderCopyUi(owner, className, text) {
    this.copyUiOwner = owner;
    this.copyButton.className = className;
    this.copyButton.textContent = text;
  }

  // `.copy-request` is display:none without `.on`, and hiding a focused
  // element drops focus to <body>. The hidden IME is what receives
  // compositionend, so a tile that lost focus that way accepts no IME input
  // at all until the user clicks — and nothing on screen says why. The
  // paths that need this are the ones nobody asked for: the timers that
  // retire feedback under a button the user tabbed to (mux.js's Tab
  // affordance) and never activated. Every user-driven exit already hands
  // focus back in the click handler and is left alone.
  restoreImeFocusIfHidden() {
    if (document.activeElement !== this.copyButton) return;
    if (this.copyButton.classList.contains('on')) return;
    ime.focus();
  }

  releaseSelectionCopyUi() {
    if (this.copyUiOwner !== 'selection') return;
    if (this.pendingClipboard !== null) {
      this.renderCopyUi(
        'clipboard',
        this.clipboardWriteActive ? 'copy-request' : 'copy-request on',
        'Copy',
      );
    } else {
      this.renderCopyUi(null, 'copy-request', 'Copy');
    }
  }

  async tryClipboardWrite(version, automatic) {
    if (this.clipboardWriteActive) return;
    if (version !== this.clipboardVersion || this.pendingClipboard === null) return;
    const text = this.pendingClipboard;
    this.clipboardWriteActive = true;
    let succeeded = false;
    try {
      await this.writeClipboardText(text);
      succeeded = true;
    } catch (_) {
      // Expected platform failures are rendered below, never allowed to
      // reject the promise launched by onMessage or the button handler.
    }
    this.clipboardWriteActive = false;

    // A newer event owns the one pending slot. Serialize its write after
    // this one and skip every intermediate value it already replaced.
    if (version !== this.clipboardVersion || this.pendingClipboard !== text) {
      if (this.zoomed && this.pendingClipboard !== null)
        return this.tryClipboardWrite(this.clipboardVersion, true);
      return;
    }

    if (!succeeded) {
      if (this.copyUiOwner === 'clipboard') {
        this.renderCopyUi(
          'clipboard',
          automatic ? 'copy-request on' : 'copy-request on error',
          automatic ? 'Copy' : 'Copy failed',
        );
      }
      return;
    }

    this.pendingClipboard = null;
    if (this.copyUiOwner !== 'clipboard') return;
    this.renderCopyUi('clipboard', 'copy-request on', 'Copied');
    setTimeout(() => {
      if (version !== this.clipboardVersion || this.pendingClipboard !== null ||
          this.copyUiOwner !== 'clipboard') return;
      this.renderCopyUi(null, 'copy-request', 'Copy');
      this.restoreImeFocusIfHidden();
    }, 1200);
  }

  copyPendingClipboard() {
    if (!this.zoomed || this.copyUiOwner !== 'clipboard' || this.pendingClipboard === null) return;
    return this.tryClipboardWrite(this.clipboardVersion, false);
  }

  async writeClipboardText(text) {
    const writeText = navigator.clipboard?.writeText;
    if (typeof writeText !== 'function') throw new Error('clipboard unavailable');
    await writeText.call(navigator.clipboard, text);
  }

  async copySelection() {
    const selection = this.selection;
    if (!this.zoomed || !selection || selection.text === null) return;
    this.clearSelectionFeedback();
    // An explicit copy is the newest statement of what the human wants on
    // the clipboard, so it revokes the automatic lane rather than racing
    // it. A write already handed to the browser genuinely cannot be
    // cancelled — but the RE-DISPATCH that write performs when it settles
    // is ours to start, and starting it would put terminal text on the
    // clipboard after the gesture that asked for something else. Retiring
    // the version and the queued value together is what suppresses it.
    this.clipboardVersion++;
    this.pendingClipboard = null;
    const version = ++this.selectionCopyVersion;
    this.renderCopyUi('selection', 'copy-request', 'Copy');
    let succeeded = false;
    try {
      // This call occurs synchronously before the first await, preserving
      // the key/click user activation required by the Clipboard API.
      // It cannot cancel an OSC52 write already handed to the browser, so
      // those two calls may overlap. Versions order only our UI; the browser
      // owns external clipboard completion ordering for the active calls.
      await this.writeClipboardText(selection.text);
      succeeded = true;
    } catch (_) {
      // Clipboard refusal is visible browser UI, never an unhandled event
      // promise rejection.
    }
    if (!this.zoomed || this.selection !== selection ||
        version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return;
    if (!succeeded) {
      this.renderCopyUi('selection', 'copy-request on error', 'Copy failed');
      return;
    }
    this.renderCopyUi('selection', 'copy-request on', 'Copied');
    setTimeout(() => {
      if (!this.zoomed || this.selection !== selection ||
          version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return;
      this.releaseSelectionCopyUi();
      this.restoreImeFocusIfHidden();
    }, 1200);
  }

  onSelectionReply() {
    const active = this.activeSelectionRequest;
    if (!this.selection || !active || active.selection !== this.selection ||
        active.id !== this.selection.requestId ||
        active.generation !== this.selectionRequestVersion) return;
    // A Zig u32 result reaches JavaScript as a signed WASM i32.
    if ((this.core.mux_selection_id() >>> 0) !== active.id) return;
    this.invalidateSelectionRequest();
    if (this.core.mux_selection_status() !== 0) {
      this.selection.text = null;
      this.showSelectionUnavailable();
      return;
    }
    // Ordinary output RAISES the daemon's history without renaming a single
    // row — new rows are appended below the ones already there. Only a page
    // eviction lowers it, and that renumbers every absolute row the request
    // named, so a reply from below the sampled base is text belonging to
    // lines the user never highlighted. Discarding it is the same outcome
    // as any other unusable reply, so it takes the same path rather than
    // inventing a second kind of failure.
    if ((this.core.mux_selection_history_rows() >>> 0) < active.historyBase) {
      this.selection.text = null;
      this.showSelectionUnavailable();
      return;
    }
    const ptr = this.core.mux_selection_ptr();
    const len = this.core.mux_selection_len();
    // Selection text borrows the staging buffer. Snapshot it before any
    // other WASM call or asynchronous clipboard operation can replace it.
    const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
    try {
      this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
      this.clearSelectionFeedback();
      this.releaseSelectionCopyUi();
    } catch (_) {
      this.selection.text = null;
      this.showSelectionUnavailable();
    }
  }

  // --- retained selection ---
  cellAtPointer(ev, clampY = true) {
    const rect = this.canvas.getBoundingClientRect();
    const cols = this.core.mux_cols(), rows = this.core.mux_rows();
    const x = Math.max(0, Math.min(cols - 1,
      Math.floor((ev.clientX - rect.left) / this.drawScale / METRICS.w)));
    let y = Math.floor((ev.clientY - rect.top) / this.drawScale / METRICS.h);
    if (clampY) y = Math.max(0, Math.min(rows - 1, y));
    return { col: x, viewRow: y, row: this.viewStartRow + y };
  }

  orderedSelection() {
    if (!this.selection) return null;
    const a = this.selection.anchor, b = this.selection.active;
    if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return [a, b];
    return [b, a];
  }

  clearSelection(repaint = true) {
    const hadSelection = this.selection !== null;
    this.invalidateSelectionRequest();
    this.clearSelectionFeedback();
    if (hadSelection) this.selectionCopyVersion++;
    this.selection = null;
    this.stopSelectionDrag();
    this.releaseSelectionCopyUi();
    if (repaint && hadSelection && this.core) this.reflow();
  }

  // Nulling the handle and bumping the generation are ONE fact, not two:
  // nothing revokes a selection request without doing both, in this one
  // place. That makes the handle-identity comparisons downstream subsume
  // the generation comparisons beside them — several of those conditions
  // cannot fire on their own. They are deliberate belt-and-braces against a
  // future second revoker that forgets half the act, and are not the
  // invariant themselves. Do not "simplify" one away in isolation: the
  // pairing here is what would have to change first.
  invalidateSelectionRequest() {
    if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer);
    this.selectionRequestTimer = null;
    this.activeSelectionRequest = null;
    this.selectionRequestVersion++;
  }

  activateSelectionRequest(selection) {
    this.invalidateSelectionRequest();
    const request = {
      selection,
      id: selection.requestId,
      generation: this.selectionRequestVersion,
      // The absolute rows in `selection` are counted from the oldest
      // RETAINED history row, so they only name lines while that row keeps
      // its identity. Sampled here, which is the last instant before the
      // request becomes authoritative, and compared against the daemon's
      // own reading in onSelectionReply.
      historyBase: this.core.mux_history_rows() >>> 0,
    };
    this.activeSelectionRequest = request;
    return request;
  }

  // The two timers below compare setTimeout HANDLES by identity. Per the
  // HTML spec an id is unique only among timers that are still active, so a
  // handle can legitimately be reused once its predecessor has fired or
  // been cleared. That is why the version fields exist beside it rather
  // than instead of it: identity is the cheap first check, and the
  // monotonic generation is what actually settles which request is meant.
  startSelectionRequestTimeout(request) {
    const selection = request.selection;
    const timer = setTimeout(() => {
      if (this.selectionRequestTimer !== timer ||
          this.activeSelectionRequest !== request ||
          this.selectionRequestVersion !== request.generation ||
          this.selection !== selection || selection.requestId !== request.id) return;
      this.invalidateSelectionRequest();
      selection.text = null;
      this.showSelectionUnavailable();
    }, SELECTION_REQUEST_TIMEOUT_MS);
    this.selectionRequestTimer = timer;
  }

  clearSelectionFeedback() {
    if (this.selectionFeedbackTimer !== null) clearTimeout(this.selectionFeedbackTimer);
    this.selectionFeedbackTimer = null;
    this.selectionFeedbackVersion++;
  }

  showSelectionUnavailable() {
    const selection = this.selection;
    if (!selection) return;
    this.clearSelectionFeedback();
    const version = this.selectionFeedbackVersion;
    this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
    const timer = setTimeout(() => {
      if (this.selectionFeedbackTimer !== timer) return;
      this.selectionFeedbackTimer = null;
      if (this.selectionFeedbackVersion !== version ||
          this.selection !== selection || this.copyUiOwner !== 'selection') return;
      this.selectionFeedbackVersion++;
      this.releaseSelectionCopyUi();
      this.restoreImeFocusIfHidden();
    }, 1200);
    this.selectionFeedbackTimer = timer;
  }

  stopSelectionDrag() {
    const pointerId = this.drag?.pointerId;
    this.drag = null;
    this.lastPointerX = null;
    this.lastPointerY = null;
    if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
    this.selectionScrollTimer = null;
    // Clear drag state before release: releasePointerCapture queues a
    // lostpointercapture event, which must not erase a completed selection.
    if (pointerId !== undefined && this.canvas.hasPointerCapture(pointerId))
      this.canvas.releasePointerCapture(pointerId);
  }

  beginSelection(ev) {
    if (!this.zoomed || ev.button !== 0) return;
    ev.preventDefault();
    const point = this.cellAtPointer(ev);
    this.clearSelection(false);
    this.selection = { anchor: point, active: point, requestId: 0, text: null };
    this.drag = { pointerId: ev.pointerId, moved: false };
    this.lastPointerX = ev.clientX;
    this.lastPointerY = ev.clientY;
    this.canvas.setPointerCapture(ev.pointerId);
    this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120);
    this.reflow();
  }

  moveSelection(ev) {
    if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
    ev.preventDefault();
    this.lastPointerX = ev.clientX;
    this.lastPointerY = ev.clientY;
    const point = this.cellAtPointer(ev);
    const endpointChanged = point.row !== this.selection.active.row ||
      point.col !== this.selection.active.col;
    if (endpointChanged) this.drag.moved = true;
    this.selection.active = point;
    this.reflow();
  }

  endSelection(ev) {
    if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
    ev.preventDefault();
    this.lastPointerX = ev.clientX;
    this.lastPointerY = ev.clientY;
    const point = this.cellAtPointer(ev);
    const endpointChanged = point.row !== this.selection.active.row ||
      point.col !== this.selection.active.col;
    if (endpointChanged) this.drag.moved = true;
    this.selection.active = point;
    const moved = this.drag.moved;
    this.stopSelectionDrag();
    if (!moved) { this.clearSelection(); return; }

    this.nextSelectionId = (this.nextSelectionId + 1) >>> 0;
    this.selection.requestId = this.nextSelectionId;
    const a = this.selection.anchor, b = this.selection.active;
    const n = this.core.mux_selection_request(
      this.nextSelectionId, a.row, a.col, b.row, b.col,
    );
    if (n > 0) {
      const selection = this.selection;
      // Establish reply authority before send: a host is allowed to deliver
      // a matching semantic reply synchronously from its send hook.
      const request = this.activateSelectionRequest(selection);
      if (!this.sendFrame(MSG.selection_req, this.outBytes())) {
        this.clearSelection();
        return;
      }
      if (endpointChanged) this.reflow();
      if (this.activeSelectionRequest === request)
        this.startSelectionRequestTimeout(request);
    }
    else this.clearSelection();
  }

  pointerOutsideDirection() {
    if (!this.drag || this.lastPointerY === null) return 0;
    const rect = this.canvas.getBoundingClientRect();
    return this.lastPointerY < rect.top ? 1 :
      (this.lastPointerY >= rect.bottom ? -1 : 0);
  }

  moveDragToCurrentPointer() {
    if (!this.drag || !this.selection ||
        this.lastPointerX === null || this.lastPointerY === null) return false;
    const point = this.cellAtPointer({
      clientX: this.lastPointerX,
      clientY: this.lastPointerY,
    });
    if (point.row !== this.selection.active.row || point.col !== this.selection.active.col)
      this.drag.moved = true;
    this.selection.active = point;
    return true;
  }

  autoScrollSelection() {
    const direction = this.pointerOutsideDirection();
    if (direction === 0 || this.scrollRequest) return;
    const paintedStart = this.viewStartRow;
    if (!this.changeScrollPages(direction)) return;
    // Returning to the live viewport paints synchronously in exitScroll.
    // History requests update the endpoint only in scrollback_chunk after
    // their matching bytes have decoded and become the painted viewport.
    if (!this.scrollRequest && this.viewStartRow !== paintedStart &&
        this.moveDragToCurrentPointer()) this.reflow();
  }

  // --- painting ---
  clearBackingCanvas() {
    // resetCore calls this only when mux_init failed, so it must not consult
    // any geometry in the invalid old core.
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.drawScale = 0;
  }

  // A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
  // must be drawn small. Two ways to do that, and only one is legible:
  // draw at full logical size into a big bitmap and let CSS squash it
  // (every glyph resampled, twice over on a HiDPI screen), or draw
  // THROUGH a scaled context so the glyphs rasterize once, at device
  // resolution, at the size they will actually be seen. This is the
  // second. Everything below paints in logical cell coordinates and the
  // transform does the rest.
  sizeCanvas() {
    const cols = this.core.mux_cols(), rows = this.core.mux_rows();
    const logicalW = cols * METRICS.w, logicalH = rows * METRICS.h;
    const dpr = window.devicePixelRatio || 1;
    // The width the layout gives this tile. The zoomed tile computed its
    // grid FROM that box, so it lands at scale 1; a wall tile scales down.
    // Never up: stretching a terminal is not a feature.
    const availW = this.zoomed ? this.zoomBox().w : this.el.clientWidth;
    const scale = availW > 0 ? Math.min(1, availW / logicalW) : 1;
    const cssW = logicalW * scale, cssH = logicalH * scale;
    // The backing store is CSS pixels × devicePixelRatio: the resolution
    // the screen can actually show, and no more.
    const bw = Math.max(1, Math.round(cssW * dpr));
    const bh = Math.max(1, Math.round(cssH * dpr));
    if (this.canvas.width !== bw || this.canvas.height !== bh || this.drawScale !== scale) {
      this.canvas.width = bw;
      this.canvas.height = bh;
      this.canvas.style.width = `${cssW}px`;
      this.canvas.style.height = `${cssH}px`;
      this.drawScale = scale;
      this.core.mux_mark_all_dirty(); // assigning width cleared the bitmap
    }
    // Re-applied every paint, not just on resize: assigning canvas.width
    // resets the transform, and a paint that skipped this would draw one
    // grid's worth of cells into the top-left corner of the bitmap.
    this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
  }
  // Repaint after the theme or the font moved under pixels that are
  // already on the canvas. Painting straight over them is not enough: a
  // wall tile draws through a scaled transform, so every cell fill lands
  // on fractional device pixels and its antialiased seam keeps a blend of
  // what was underneath. Repainting a light theme over a dark one that
  // way leaves the old colour in a lattice along every cell boundary.
  repaintForSettings() {
    if (!this.core) return;
    this.clearBackingCanvas();
    this.reflow();
  }

  // The tile's CSS width moved (a window resize reflows the wall grid):
  // re-fit and repaint from what the core already holds. No frame is
  // requested and nothing is sent — a wall tile is passive.
  reflow() {
    if (!this.core) return;
    this.core.mux_mark_all_dirty();
    if (this.scrollPages === 0) this.paintLive();
    else this.paintScroll();
  }
  paintLive() {
    this.viewStartRow = this.core.mux_history_rows();
    this.sizeCanvas();
    const n = this.core.mux_read_viewport();
    const paintedRows = new Set();
    for (let i = 0; i < n; i++) {
      const row = this.core.mux_dirty_row(i);
      this.paintRow(row);
      paintedRows.add(row);
    }
    // The overlay is translucent. Restore every selected row from the
    // terminal cells before compositing it again, even when a delta dirtied
    // some other row, or repeated live paints would darken the selection.
    const ordered = this.orderedSelection();
    if (ordered) {
      const rows = this.core.mux_rows();
      const first = Math.max(0, ordered[0].row - this.viewStartRow);
      const last = Math.min(rows - 1, ordered[1].row - this.viewStartRow);
      for (let row = first; row <= last; row++) {
        if (!paintedRows.has(row)) this.paintRow(row);
      }
    }
    this.paintCursor();
    this.paintSelection();
  }
  paintScroll() {
    this.sizeCanvas();
    this.core.mux_read_scroll_viewport();
    const rows = this.core.mux_rows();
    for (let y = 0; y < rows; y++) this.paintRow(y);
    this.paintSelection();
  }
  paintRow(y) {
    const cols = this.core.mux_cols();
    const base = this.core.mux_viewport_ptr() + y * cols * 16;
    // Fresh view per row paint; see the header discipline.
    const cells = new DataView(this.core.memory.buffer, base, cols * 16);
    const ctx = this.ctx;
    ctx.font = FONT;
    ctx.textBaseline = 'alphabetic';
    for (let x = 0; x < cols; x++) {
      const cp = cells.getUint32(x * 16, true);
      const fg = cells.getUint32(x * 16 + 4, true);
      const bg = cells.getUint32(x * 16 + 8, true);
      const flags = cells.getUint32(x * 16 + 12, true);
      if (flags & (1 << 17)) continue; // spacer: the wide cell painted it
      const wide = (flags & (1 << 16)) !== 0;
      const inverse = (flags & (1 << 4)) !== 0;
      let fgC = colorOf(fg, DEFAULT_FG), bgC = colorOf(bg, DEFAULT_BG);
      if (inverse) { const t = fgC; fgC = bgC; bgC = t; }
      const px = x * METRICS.w, py = y * METRICS.h, cw = METRICS.w * (wide ? 2 : 1);
      ctx.fillStyle = bgC;
      ctx.fillRect(px, py, cw, METRICS.h);
      if (cp !== 0 && cp !== 32) {
        const bold = (flags & 1) !== 0, faint = (flags & 4) !== 0, italic = (flags & 2) !== 0;
        ctx.fillStyle = fgC;
        ctx.globalAlpha = faint ? 0.6 : 1;
        ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${FONT}`;
        ctx.fillText(String.fromCodePoint(cp), px, py + METRICS.ascent);
        ctx.globalAlpha = 1;
        ctx.font = FONT;
      }
      const ustyle = (flags >> 8) & 7;
      if (ustyle !== 0) {
        ctx.fillStyle = fgC;
        ctx.fillRect(px, py + METRICS.h - 2, cw, ustyle === 2 ? 2 : 1); // double→thick
      }
      if (flags & (1 << 6)) { // strikethrough
        ctx.fillStyle = fgC;
        ctx.fillRect(px, py + (METRICS.h >> 1), cw, 1);
      }
    }
  }
  paintCursor() {
    if (this.scrollPages !== 0) return;
    const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y();
    this.ctx.fillStyle = CURSOR_FILL;
    this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h);
  }

  paintSelection() {
    const ordered = this.orderedSelection();
    if (!ordered) return;
    const [start, end] = ordered;
    const cols = this.core.mux_cols(), rows = this.core.mux_rows();
    this.ctx.fillStyle = SELECTION_FILL;
    for (let y = 0; y < rows; y++) {
      const screenRow = this.viewStartRow + y;
      if (screenRow < start.row || screenRow > end.row) continue;
      const first = screenRow === start.row ? start.col : 0;
      const last = screenRow === end.row ? end.col : cols - 1;
      if (last < first) continue;
      this.ctx.fillRect(
        first * METRICS.w, y * METRICS.h,
        (last - first + 1) * METRICS.w, METRICS.h,
      );
    }
  }

  // --- scrollback ---
  changeScrollPages(pagesUpDelta) {
    if (this.scrollRequest) return false;
    const rows = this.core.mux_rows();
    const maxPages = Math.ceil(this.core.mux_history_rows() / rows);
    const next = Math.max(0, Math.min(maxPages, this.scrollPages + pagesUpDelta));
    if (next === this.scrollPages) return false;
    if (next === 0) { this.exitScroll(); return true; }
    const fromPages = this.scrollPages;
    this.scrollPages = next;
    this.renderBadge();
    const start = this.core.mux_scroll_start(this.scrollPages, rows);
    const request = { fromPages, start, count: rows };
    this.scrollRequest = request;
    const p = new Uint8Array(6);
    const dv = new DataView(p.buffer);
    dv.setUint32(0, start, true);
    dv.setUint16(4, rows, true);
    if (!this.sendFrame(MSG.fetch_scrollback, p)) {
      this.scrollRequestFailed(request);
      return false;
    }
    // WebSocket delivery is asynchronous in browsers, but keep the state
    // transition correct for any host that can answer during send().
    if (this.scrollRequest !== request) return true;
    this.scrollRequestTimer = setTimeout(() => {
      if (this.scrollRequest === request) this.scrollRequestFailed(request);
    }, SCROLL_REQUEST_TIMEOUT_MS);
    return true;
  }

  clearScrollRequestTimer() {
    if (this.scrollRequestTimer !== null) clearTimeout(this.scrollRequestTimer);
    this.scrollRequestTimer = null;
  }

  cancelScrollRequest() {
    if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
    else this.clearScrollRequestTimer();
  }

  scrollRequestFailed(request) {
    if (this.scrollRequest !== request) return;
    this.clearScrollRequestTimer();
    this.scrollRequest = null;
    this.scrollPages = request.fromPages;
    this.renderBadge();
  }

  onWheel(ev) {
    this.changeScrollPages(ev.deltaY < 0 ? 1 : -1);
  }

  exitScroll() {
    this.clearScrollRequestTimer();
    if (this.scrollPages === 0) { this.scrollRequest = null; return; }
    this.scrollRequest = null;
    this.scrollPages = 0;
    this.renderBadge();
    this.reflow(); // mark-all + paint live: the same repaint, one owner
  }

  // --- chrome ---
  // THE BADGE IS A RENDERING, NOT A STATE. Reading the class back — which
  // this used to do — made the DOM the state machine's memory and every
  // writer a peer of every other.
  //
  // Two independent facts share the one badge and scroll wins while it
  // lasts: a tile paged into history says so, and says whatever it was
  // saying again the moment it returns to live — including a state that
  // arrived while the user was reading history.
  renderBadge() {
    const [cls, text] = this.scrollPages > 0
      ? ['scroll', `history -${this.scrollPages}`]
      : [this.status, this.statusText];
    const b = this.el.querySelector('.badge');
    b.className = `badge ${cls}`;
    b.textContent = text;
  }
  // Link narration — this socket's lifecycle and the hub's control
  // messages alike — never overwrites a state the tile reached locally
  // and terminally. A `stuck` tile whose DAEMON leg re-dials is told
  // 'up': painting that green while sendAttach stays gated on replayDead
  // is exactly a silent tile that reads healthy. Terminal states do
  // replace each other — the newest fact about a dead session is still
  // the true one. The arguable exception, considered and accepted: a
  // browser-leg 'gone' (the page can no longer observe the session at
  // all) is also withheld while terminal. The word is then stale but the
  // color is not — index.html renders all four in the same red — and
  // splitting "link news that supersedes" from "link news that doesn't"
  // would reintroduce the peer-writers problem this field deleted.
  setStatus(cls, text) {
    if (TERMINAL.has(this.status) && !TERMINAL.has(cls)) return;
    this.status = cls;
    this.statusText = text;
    this.renderBadge();
  }
  // The ONE place a terminal state is left, and it clears replayDead in
  // the same breath because the two are one fact: the badge says gave
  // up, the flag gates every attach. Moving one without the other either
  // lies (badge stuck, tile sending fine) or goes silent (badge up,
  // sendAttach gated). Two callers: a frame that applied, a socket that
  // opened.
  revive(cls, text) {
    this.replayDead = false;
    this.replayFailures = 0;
    this.replayBackoffMs = 0;
    this.status = cls; // deliberately past setStatus's precedence
    this.statusText = text;
    this.renderBadge();
  }
}

// --- zoom / focus ---
let zoomedTile = null;
const shade = document.getElementById('shade');
const ime = document.getElementById('ime');

function zoom(tile) {
  // The click is bound in the constructor and start() is async: a click
  // that lands in that window has no core to size, resize or key against.
  // Gated HERE and not at each call because zoom is what makes the tile
  // reachable at all — no zoom, no keys, no wheel, no paste (spec).
  // `core` alone is not enough: a tile whose mux_init failed has a truthy
  // core and no socket — connect() was never reached.
  if (!tile.core || !tile.ws) return;
  if (zoomedTile) unzoom();
  // Settings belong to the wall, and the wall is what zoom covers. Closing
  // the panel keeps it from sitting open and focusable behind the shade.
  // It re-opens on the next unzoom (the tile is built with `open`).
  settingsTileEl?.querySelector('.settings')?.removeAttribute('open');
  zoomedTile = tile;
  tile.zoomed = true;
  tile.el.classList.add('zoomed');
  shade.classList.add('on');
  ime.focus();
  tile.sendResizeIfDiffers();
  // Re-fit now rather than at the next frame: an idle session sends
  // nothing, and a tile that zoomed but kept its wall-sized canvas would
  // sit there postage-stamped until something happened to be typed.
  tile.reflow();
}
function unzoom() {
  if (!zoomedTile) return;
  const was = zoomedTile;
  // Invalidate every outstanding write and feedback timer before any
  // exit-scroll or wall reflow work can run.
  was.clearSelection(false);
  was.clipboardVersion++;
  was.pendingClipboard = null;
  was.renderCopyUi(null, 'copy-request', 'Copy');
  was.exitScroll();
  was.zoomed = false;
  was.el.classList.remove('zoomed');
  zoomedTile = null;
  shade.classList.remove('on');
  ime.blur();
  // Unzoom sends NOTHING: the session stays attached, the grid stays
  // where it is (spec). The tile keeps painting as a wall tile — at the
  // wall's scale, which is the one thing that does have to be redone.
  was.reflow();
}
// The visible ring of shade around the inset tile IS the unzoom control.
// Escape is deliberately not bound: it belongs to the application.
shade.addEventListener('click', unzoom);

// Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
document.addEventListener('keydown', (ev) => {
  // The settings tile's fields are real form controls: while one has focus
  // its keys are a setting being edited, not input for any terminal. The
  // whole tile — zoom hides the panel behind the shade, but Tab still
  // reaches it, and a key answered by both would type the user's font size
  // into their shell.
  if (settingsTileEl?.contains(document.activeElement)) return;
  const t = zoomedTile;
  if (!t) return;
  if (ev.isComposing) return; // IME owns it; compositionend delivers
  // A visible clipboard retry is real browser chrome inside the tile.
  // Let its native Enter/Space activation and Tab navigation work rather
  // than translating those keys into terminal input.
  if (ev.target === t.copyButton &&
      (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar' || ev.key === 'Tab')) return;
  // The hidden IME normally owns focus, so plain Tab would otherwise be
  // encoded for the terminal. When a retry is visible, use that one key
  // to make the recovery control keyboard reachable.
  if (ev.target === ime && ev.key === 'Tab' &&
      !ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey &&
      t.copyButton.classList.contains('on') && (
        (t.copyUiOwner === 'clipboard' && t.pendingClipboard !== null) ||
        (t.copyUiOwner === 'selection' && t.selection?.text !== null &&
          t.selection?.text !== undefined && t.copyButton.classList.contains('error'))
      )) {
    ev.preventDefault();
    t.copyButton.focus();
    return;
  }
  const lower = ev.key.toLowerCase();
  const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined;
  // Copy is terminal-owned only when daemon-authoritative text exists.
  // With no retained result, Firefox keeps Ctrl+Shift+C for Inspector and
  // macOS keeps its Cmd family; plain Ctrl+C continues to shared input.
  const selectionCopy = lower === 'c' && (
    ev.metaKey || (ev.ctrlKey && !ev.altKey)
  );
  if (selectionCopy && hasSelection) {
    ev.preventDefault();
    t.copySelection();
    return;
  }
  const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0);
  // Leave genuine browser chords alone (copy/paste arrive as events):
  // Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which
  // is how a mac copies and pastes at all. Consuming Cmd+V typed a
  // literal 'v' into the session and suppressed the paste event with it.
  // No preventDefault and no bytes — shared input has no meta concept by
  // design, so a Cmd chord the browser does not want is simply dropped.
  if (ev.metaKey) return;
  if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return;
  if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') {
    t.clearSelection(false);
    t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule)
    ev.preventDefault();
    return;
  }
  if (KEY[ev.key] !== undefined) {
    ev.preventDefault();
    t.sendKey(KEY[ev.key], 0, mods);
    return;
  }
  if ([...ev.key].length === 1) {
    ev.preventDefault();
    t.sendKey(KEY.char, ev.key.codePointAt(0), mods);
  }
});
document.addEventListener('paste', (ev) => {
  if (!zoomedTile) return;
  ev.preventDefault();
  const text = ev.clipboardData?.getData('text');
  if (!text) return;
  // Input returns the tile to live, exactly as every keystroke path does.
  // It is NOT swallowed the way a stray keystroke is: a paste is never
  // stray — it carries content the user explicitly asked to send, and
  // discarding it would cost them the copy as well as the page.
  zoomedTile.clearSelection(false);
  zoomedTile.exitScroll();
  zoomedTile.sendPaste(text);
});
ime.addEventListener('compositionend', (ev) => {
  // sendText, NOT sendPaste: composed text is typing (see sendText).
  if (zoomedTile && ev.data) zoomedTile.sendText(ev.data);
  ime.value = '';
});
// --- the wall ---
// Keyed by the hub's tile id, never by position: two entries share a label
// whenever one daemon has two sessions, and an id is never reused. The DOM
// under #wall carries the hub's order.
const tilesById = new Map();
let settingsTileEl = null;  // built once in boot, always last

window.addEventListener('resize', () => {
  // The zoomed tile may claim a new grid; every wall tile just re-fits to
  // the width the reflowed grid gave it.
  zoomedTile?.sendResizeIfDiffers();
  for (const t of tilesById.values()) if (t !== zoomedTile) t.reflow();
  zoomedTile?.reflow();
});

// `GET /tiles` is the truth about the wall; this makes the page match it.
// The hub polls its daemons once a second and the page asks it just as
// often, so a session born or ended ANYWHERE — another browser, a
// terminal, a `mux a` — reaches this wall without the page being told.
// There is one shape of "what the wall is" and no local guess to drift.
//
// Two refetches can be in flight at once (Enter twice quickly, a reorder
// overlapping an add) and nothing orders their responses. The last one
// STARTED is the only one allowed to reconcile: an older cfg landing last
// would shut down a tile the newer cfg still contains, and the tile would
// vanish from the wall until something else refetched. One counter, checked
// after every await that precedes a side effect.
let refetchGen = 0;
async function refetchWall() {
  const gen = ++refetchGen;
  const res = await fetch('/tiles');
  if (gen !== refetchGen) return;
  // A 500 answers in plain text, not JSON: parsing it would reject and take
  // the caller's await with it. The wall simply keeps what it has.
  if (!res.ok) { console.error(`mux wall: GET /tiles said ${res.status}`); return; }
  const cfg = await res.json();
  if (gen !== refetchGen) return;
  const wallEl = document.getElementById('wall');
  const liveIds = new Set(cfg.map((t) => t.id));
  for (const [id, tile] of tilesById) {
    if (!liveIds.has(id)) { tilesById.delete(id); tile.shutdown(); }
  }
  for (const t of cfg) {
    let tile = tilesById.get(t.id);
    if (!tile) {
      // Each entry is {id, label, session}: the label is what the tile calls
      // itself, the session is what it attaches to. Two entries can name the
      // same host and differ only in the session — that is the whole point.
      tile = new Tile(t.id, t.label, wallEl, t.session);
      tilesById.set(t.id, tile);
      // start() is async: instantiate or mux_init can fail, and an unhandled
      // rejection left the tile stuck on 'connecting' with the reason only
      // in the console's rejection noise. The settled promise is kept:
      // "new session here" zooms the tile it just added, and zoom() refuses
      // a tile whose core and socket start() has not installed yet.
      tile.startup = tile.start().catch((err) => {
        tile.setStatus('gone', 'gone');
        console.error(`mux tile ${t.id} (${t.label}): start failed`, err);
      });
    }
    // appendChild MOVES an attached node: walking cfg in order lays the
    // wall out in wall order, existing sockets undisturbed.
    wallEl.appendChild(tile.el);
  }
  if (settingsTileEl) wallEl.appendChild(settingsTileEl); // stays last
}

// How many refetches a birth may wait for its tile, and how long between
// them. The tile cannot exist when the POST returns: `Hub.spawn` stores a
// poke and answers, and the host's poller feels that poke only at its next
// 50 ms slice, then still has to dial the daemon and diff the list. Six
// unspaced fetches on a kept-alive localhost socket all finish inside
// ~10 ms, so the loop lost that race and the user got a session born and
// unzoomed — the "sits on a badge" outcome the zoom exists to prevent.
// Measured against a live hub: the tile is on `/tiles` 59 ms after the
// POST answers (Debug, so a ceiling — the dial is the only part release
// speeds up). 250 ms clears that with room for a slow daemon; 6 of them
// bound the wait at 1.5 s, after which the 1 s interval picks it up.
const SPAWN_POLLS = 6;
const SPAWN_POLL_MS = 250;

// The wait between two of those refetches. Its own function so the delay
// is a thing a test can reach: verify.js cannot run `spawnHere`'s network
// path, and a bare inline `setTimeout` would be invisible to it.
function spawnPollDelay() {
  return new Promise((resolve) => setTimeout(resolve, SPAWN_POLL_MS));
}

// The `+`: a new session on THAT tile's daemon, named by the daemon. The
// tile appears through refetchWall like every other tile — one road onto
// the wall — and the zoom is what lands the user in the shell, because an
// unzoomed wall tile attaches passive at 0x0 (see sendResize).
async function spawnHere(id) {
  const tile = tilesById.get(id);
  try {
    const res = await fetch(`/tiles/${id}`, { method: 'POST' });
    if (!res.ok) {
      // The hub's own words for why, on the badge of the tile that asked —
      // there is no add box left to put them in.
      const why = (await res.text()).trim() || `hub said ${res.status}`;
      console.error(`mux spawn on tile ${id}: ${why}`);
      tile?.setStatus('gone', why);
      return;
    }
    // The hub answers {"session":"NAME"}. Parsed defensively: the session
    // IS made, so a body we cannot read must not read as a failed birth.
    const body = await res.json().catch(() => null);
    const label = tile?.label ?? null;
    for (let i = 0; i < SPAWN_POLLS && body !== null; i++) {
      // BEFORE the fetch, first pass included: the hub answered this POST
      // before its poller had heard about the birth, so asking now can
      // only ever miss.
      await spawnPollDelay();
      await refetchWall();
      const fresh = [...tilesById.values()].find(
        (t) => t.session === body.session && t.label === label,
      );
      if (!fresh) continue;
      await fresh.startup; // refetchWall fires start(), it does not await it
      // Re-checked after the await: a poll may have retired this tile, and
      // a zoom the user did meanwhile is theirs to keep.
      if (tilesById.get(fresh.id) === fresh && !zoomedTile) zoom(fresh);
      return;
    }
  } catch (err) {
    console.error(`mux spawn on tile ${id}: request failed`, err);
  }
  await refetchWall().catch((err) => {
    console.error('mux wall: refetch failed', err);
  });
}

// The settings panel, wired inside the settings tile. Every change goes through
// updateSettings — no caller can apply one without saving it — and every
// answer lands in .note, including the count of lines a file did NOT give
// us, which is how "I uploaded my whole ghostty config" reads as partial
// success instead of silence.
function wireSettingsPanel(el) {
  const file = el.querySelector('.theme-file');
  const clear = el.querySelector('.theme-clear');
  const family = el.querySelector('.font-family');
  const size = el.querySelector('.font-size');
  const note = el.querySelector('.note');
  const say = (text, bad) => {
    note.textContent = text;
    note.className = bad ? 'note bad' : 'note';
  };

  family.value = settings.fontFamily || '';
  size.value = clampFontSize(settings.fontSize);

  file.addEventListener('change', async () => {
    const chosen = file.files?.[0];
    if (!chosen) return;
    // Capped before a byte is read: no theme needs 64 KiB, and this is
    // the whole defence against someone picking a video.
    if (chosen.size > THEME_BYTES_MAX) {
      say(`too big for a theme file (${chosen.size} bytes)`, true);
      return;
    }
    let text = '';
    try {
      text = await chosen.text();
    } catch (_) {
      say('could not read that file', true);
      return;
    }
    const parsed = parseGhosttyTheme(text);
    if (!parsed) {
      say('no colours in that file — is it a ghostty theme?', true);
      return;
    }
    updateSettings({ theme: parsed.theme });
    const skipped = parsed.ignored ? `, ${parsed.ignored} lines ignored` : '';
    say(`${parsed.applied} colours applied${skipped}`, false);
  });

  clear.addEventListener('click', () => {
    updateSettings({ theme: null });
    file.value = ''; // else re-picking the same file fires no change event
    say('theme cleared', false);
  });

  const applyFont = () => {
    const wanted = clampFontSize(size.value);
    size.value = wanted; // the clamp is shown, not applied behind the value
    updateSettings({ fontFamily: family.value.trim() || null, fontSize: wanted });
  };
  family.addEventListener('change', applyFont);
  // 'change', not 'input': typing 18 passes through 1, and each keystroke
  // would claim a new grid for every client on the zoomed session.
  size.addEventListener('change', applyFont);
}

// The settings tile: a tile-shaped panel at the end of the wall. It is NOT
// a Tile — no core, no socket, never zooms — so the zoom click path (bound
// per Tile) cannot reach it. Nothing here authors the wall: the wall is the
// hosts file, and `mux hosts add|rm` is how it changes.
function buildSettingsTile() {
  const el = document.createElement('div');
  el.className = 'tile settings-tile';
  el.innerHTML =
    `<details class="settings" open><summary>settings</summary>` +
    `<div class="row"><span>theme</span>` +
    `<input type="file" class="theme-file" aria-label="ghostty theme file">` +
    `<button type="button" class="theme-clear">clear</button></div>` +
    `<div class="row"><span>font</span>` +
    `<input type="text" class="font-family" aria-label="font family"` +
    ` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` +
    `<input type="number" class="font-size" aria-label="font size"` +
    ` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` +
    `<div class="caveat">tiles are the live sessions of the daemons in your` +
    ` hosts file — <code>mux hosts add HOST</code> to list one, + on a tile` +
    ` to start a session there; theme is this browser's alone, and font size` +
    ` claims a new grid, so every client on the zoomed session reflows</div>` +
    `<div class="note"></div></details>`;
  const settingsEl = el.querySelector('.settings');
  wireSettingsPanel(settingsEl);
  // Nothing above this tile should read a click meant for its own controls.
  el.addEventListener('click', (ev) => ev.stopPropagation());
  return el;
}

// --- boot ---
(async function boot() {
  // Before any tile exists, so the first frame is painted in the user's
  // colours at the user's cell size rather than repainted into them.
  settings = loadSettings();
  applySettings(settings);
  // A second tab of the same hub is the same wall: settings follow it
  // live rather than waiting for a reload. A cleared key (newValue null)
  // means "back to defaults".
  window.addEventListener('storage', (ev) => {
    if (ev.key !== SETTINGS_KEY) return;
    settings = loadSettings();
    applySettings(settings);
  });
  compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
  settingsTileEl = buildSettingsTile();
  await refetchWall();
  // The hub's own poll is a second, so asking it any faster only costs
  // requests. This interval is the whole of how a session born or ended
  // elsewhere reaches this page: nothing pushes.
  setInterval(() => {
    refetchWall().catch((err) => console.error('mux wall: refetch failed', err));
  }, 1000);
})();