// The browser half of improvise-cr3, talking to either of two peers: five // string calls, no protocol knowledge. See context/code/clients.md. import init, { Client } from "./pkg/improvise_wasm_client.js"; import { COL_PX, HANDSHAKE_TIMEOUT_MS, ROW_PX, cellAction, cellAttrs, classifyWorkerMessage, createShell, createToasts, documentTitle, extentOf, frameCostOf, gridPlan, indexAction, indicator, isFatal, MENU_ARROW, menuKey, NOTICE_KEY, pageItemAction, noticeOpen, noticeState, noticeText, pageEntries, socketUrl, } from "./shell.js"; const params = new URLSearchParams(location.search); // present => socket, absent => in-page worker. The value is a host:port, and // the scheme is NOT the caller's to get wrong: see `socketUrl`. const WS_PEER = params.get("ws"); const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null; const DOC = params.get("doc") ?? "demo.improv"; const SEED = params.get("seed") ?? "demo.improv"; const el = { grid: document.getElementById("grid"), categories: document.getElementById("categories"), formulas: document.getElementById("formulas"), views: document.getElementById("views"), tiles: document.getElementById("tiles"), pages: document.getElementById("pages"), pageMenu: document.getElementById("page-menu"), mode: document.getElementById("mode"), minibuffer: document.getElementById("minibuffer"), toast: document.getElementById("toast"), hint: document.getElementById("hint"), help: document.getElementById("help"), status: document.getElementById("status"), viewName: document.getElementById("view-name"), docName: document.getElementById("doc-name"), notice: document.getElementById("backend-notice"), }; // The deployment notice, and the one thing this page remembers about its // visitor. `localStorage` THROWS in a private window and in a context with // site data blocked, and returns null having forgotten — so every access is // guarded and the absent answer is the default, not a third state. What is // stored is per-viewer and per-origin: it never reaches Rust, never travels // to a peer and never reaches another tab's document. const remembered = (key) => { try { return localStorage.getItem(key); } catch { return null; } }; const remember = (key, value) => { try { localStorage.setItem(key, value); } catch { // A viewer whose browser will not remember still gets a working page: the // disclosure stays operable, it just re-opens at the default next time. } }; el.notice.open = noticeOpen(remembered(NOTICE_KEY)); // `toggle` fires for the keyboard and the pointer alike, because the opening // is the BROWSER's: this listener records an answer rather than making one. el.notice.addEventListener("toggle", () => remember(NOTICE_KEY, noticeState(el.notice.open)), ); await init(); const client = new Client(); // The document's own messages, out of flow and on a timer. `status_msg` is // STATE, so a repaint re-offers it: the identity check is in `createToasts`. const toasts = createToasts({ onShow: (text) => { el.toast.textContent = text; el.toast.hidden = false; }, // The text is deliberately LEFT in place — see index.html. onHide: () => { el.toast.hidden = true; }, }); // A peer that never answers the handshake, and the two facts the page shows. // Two SINKS, never joined: the transport's is `#status` and lasts, the // document's is a toast. Both are decided in shell.js; this is the wire. const shell = createShell({ onShell: (text) => { el.status.textContent = text; // The line never wraps, so a long peer address ellipsises. The whole of // it stays reachable rather than being lost to the cap. el.status.title = text; }, onModel: (text) => toasts.offer(text), timeoutMs: HANDSHAKE_TIMEOUT_MS, }); // The harness asks whether the watchdog is ARMED, because a negative assertion // about it is vacuous where it is not (clients.md). Read by tests, nothing else. window.__improviseShell = shell; // What the grid's own frame costs, in whole cells, as of the last repaint — // measured off the painted model, never predicted. context/code/clients.md. let frameCost = { rows: 1, cols: 1 }; function sendViewport() { const box = el.grid.getBoundingClientRect(); const metrics = { rowPx: ROW_PX, colPx: COL_PX }; const { rows, cols } = extentOf(box, frameCost, metrics); // `set_viewport` RECORDS the window as well as framing it; the frame-only // twin beside it is deleted, so there is nothing to call by mistake (seik). // QUIET: an extent announcement is not a user edit, and a `ResizeObserver` // firing after a drop must not overwrite the precise close message. transport.send(client.set_viewport(rows, cols), { loud: false }); } /** What the worker said that was not a frame: a boot or save failure carrying * its REASON, which is the channel improvise-1wft exists to add. */ function onNotice(notice) { // A notice is a LOUD failure, so it disarms the watchdog rather than being // replaced by it eight seconds later (gotcha 48). shell.fail(noticeText(notice)); if (isFatal(notice)) console.error("improvise: boot failed", notice); } /** One frame, one fold, in arrival order. Never batch or reorder these. */ function onFrame(text) { shell.sawFrame(); try { client.on_frame(text); } catch (err) { // `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare // listener it reached the console alone and the grid just stopped. shell.setShell("bad frame — the grid has stopped updating (see console)"); console.error("improvise: frame rejected", text, err); return; } repaint(); } // Transports. /** The in-page worker: the standalone deployment, no server process. */ function workerTransport() { const query = new URLSearchParams({ doc: DOC, seed: SEED }); // `type: "module"` is not optional: worker.js is an ES module (it `import`s // the worker bundle), and a classic worker would fail to parse it. const worker = new Worker(`./worker.js?${query}`, { type: "module" }); worker.addEventListener("message", (event) => { // A protocol frame is a string; a shell notice is an object. The rule is // `shell.js`'s and is tested there. const message = classifyWorkerMessage(event.data); if (message.kind === "frame") onFrame(message.text); else if (message.kind === "notice") onNotice(message.notice); else console.error("improvise: worker sent an unreadable message", event.data); }); worker.addEventListener("messageerror", () => shell.fail("the worker sent a frame this page could not read"), ); worker.addEventListener("error", (event) => { shell.fail(`worker failed to start: ${event.message ?? "see console"}`); console.error("improvise: worker error", event); }); // Not yet a claim that it works: the first frame makes that (improvise-i0v9). shell.setShell(`starting the worker · ${DOC}…`); // Frames posted before the worker has finished `start()` are held by // worker.js in arrival order, so the handshake can go out immediately. return { send: (text) => worker.postMessage(text), ready: Promise.resolve() }; } /** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */ function socketTransport() { const socket = new WebSocket(WS_URL); const unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`; // Did this socket ever open? The page must record it itself: nothing on the // close event can discriminate. See context/code/clients.md. let opened = false; const ready = new Promise((resolve) => { socket.addEventListener("open", () => { opened = true; shell.setShell(`connected · ${WS_URL}`); resolve(); }); }); socket.addEventListener("message", (event) => onFrame(event.data)); // Without this, a connection that never opens left #status blank and // "server not running" read exactly like "still connecting". socket.addEventListener("error", () => shell.fail(unreachable)); // A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms // route through `shell.fail`, which disarms the watchdog — clients.md. socket.addEventListener("close", () => shell.fail(opened ? "disconnected" : unreachable), ); shell.setShell(`connecting to ${WS_URL}…`); return { // CLOSING can THROW and CONNECTING/CLOSED drop silently, which loses an // edit with nothing on screen to say so (improvise-ociy). send: (text, { loud = true } = {}) => { if (socket.readyState !== WebSocket.OPEN) { if (loud) { shell.fail(`not sent — ${opened ? "the connection dropped" : "not connected yet"}`); } return; } try { socket.send(text); } catch (err) { if (loud) shell.fail("not sent — the connection is closing"); console.error("improvise: send failed", err); } }, ready, }; } const transport = WS_URL ? socketTransport() : workerTransport(); // Painting. let painted = -1; let editing = null; // {row, col} while an input is open, so a repaint cannot steal it // A render that threw is not retried: `painted` is unchanged on that path, so // every later event re-ran the same failure forever (improvise-7gf8). let renderBroken = false; function repaint() { if (editing || renderBroken) return; let model; try { model = JSON.parse(client.render_model()); } catch (err) { // Tripped, not counted: a render model that cannot be built is a permanent // state, and a reload is the only recovery this page has. renderBroken = true; shell.fail("cannot render the current state — reload the page (see console)"); console.error("improvise: render failed", err); return; } if (model.revision === painted) return; painted = model.revision; // The bar is about to be rebuilt, so the chip this menu is anchored to is // gone. Closing is the honest answer; re-anchoring would be a guess. closePageMenu(); el.mode.textContent = model.mode; // The mode's own hint, off `help::hint_line`. The page spells no keys // itself; see `RenderModel::hint`. el.hint.textContent = model.hint; paintHelp(model); paintMinibuffer(model); shell.setModel(model.status); el.viewName.textContent = model.view_name; // The title bar, off the cache `src/draw.rs::draw_title` folds — one // derivation, both front ends (improvise-z754, improvise-5jna). el.docName.textContent = documentTitle( model.doc_name, model.doc_dirty, model.doc_stored_as, ); paintPages(model); el.grid.replaceChildren(gridTable(model)); // The flag is the client's answer, not the page's: `C` toggles it and the // TUI draws the panel on exactly the same condition (improvise-wsaw). el.categories.hidden = !model.category_panel_open; el.categories.replaceChildren(categoryList(model)); paintTiles(model); paintPanel(el.views, model.views, viewRow); paintPanel(el.formulas, model.formulas, formulaRow); // The selection must be on screen. `#grid` scrolls on its own — ROW_PX and // COL_PX under-estimate and group rows are uncharged — so a selection the // projection did send can still sit below the fold (improvise-jq8l). el.grid .querySelector("td.selected") ?.scrollIntoView({ block: "nearest", inline: "nearest" }); // What the frame costs, so the NEXT viewport announcement is honest. // Inline group ROWS are deliberately NOT counted — context/code/clients.md. const next = frameCostOf(model); const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols; frameCost = next; if (changed) sendViewport(); } /** The bottom-bar prompt. `null` is "no text-entry mode is open", decided in * `render.rs`, so the page asks nothing about the mode (improvise-fdt6). */ function paintMinibuffer(model) { const mb = model.minibuffer; el.minibuffer.hidden = mb === null || mb === undefined; if (el.minibuffer.hidden) return; el.minibuffer.className = mb.color.toLowerCase(); el.minibuffer.textContent = mb.prompt + mb.value; } /** The help overlay. An empty `help` is what says it is closed -- the page * asks no question about the mode, and writes none of the content: the rows, * their keys and the page titles are `improvise_protocol::help`'s, the same * ones the TUI draws (improvise-ktv1). */ function paintHelp(model) { el.help.hidden = model.help.length === 0; if (el.help.hidden) { el.help.replaceChildren(); return; } const tabs = document.createElement("div"); tabs.className = "help-tabs"; model.help_titles.forEach((title, i) => { const tab = document.createElement("span"); tab.textContent = title; tab.className = i === model.help_page ? "help-tab active" : "help-tab"; tabs.append(tab); }); const body = document.createElement("dl"); body.className = "help-body"; for (const row of model.help) { const keys = document.createElement("dt"); keys.textContent = row.keys; keys.className = `help-${row.kind}`; body.append(keys); // A prose row has no description and spans both columns (its class says // so); emitting an empty `dd` beside it would leave a blank grid cell. if (row.description) { const desc = document.createElement("dd"); desc.textContent = row.description; body.append(desc); } } el.help.replaceChildren(tabs, body); } /** The page bar: which item each fixed category is fixed to. Without it a * grid filtered to Q1 says nothing at all, and `[`/`]` move it in silence * (improvise-p7lh). An empty list is what says there are none. */ function paintPages(model) { const entries = pageEntries(model); pageAxes = entries; el.pages.hidden = entries.length === 0; el.pages.replaceChildren(...entries.map(pageChip)); } /** One page chip. A navigable axis is a