docs(web,harness): cap the JavaScript and the browser harness

web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two
ordering rules — including the one nothing here can test, that an `async`
message listener does not serialize — are now prose in context/code/clients.md.
The harness keeps its own section: why a worker, why a report, the engine
coverage and what `wipe` deliberately cannot clear.

harness/tests/*.spec.js's comments were the record of what each assertion
proves; they are in context/test-evidence/clients.md under the exact test
titles, including the `test.fail` measurement that could NOT become a CI guard
because its trigger is the host's network stack, and the closed-invalid
improvise-xps8 report that a simulated fix appeared to confirm.

ws-server/src/main.rs's ```text fence was prose claiming the doctest
exemption, not a runnable doctest; the synopsis is now two lines and the full
form is in the prose file. No fences remain in this unit.

Verified: node --check on every JS file touched; the non-comment diff for both
'*.rs' and '*.js' is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 10:02:03 -07:00
parent 8ed3df8ce3
commit 3d3bec43e0
10 changed files with 603 additions and 517 deletions
+41 -179
View File
@@ -1,47 +1,5 @@
// The browser half of improvise-cr3, kept as thin as it can be made, now
// talking to either of two peers (improvise-djm).
//
// The boundary is JSON strings in and out, so this file holds no protocol
// knowledge at all: it never inspects a Command, never builds a CellId, never
// decides whether a cell is editable or whether a typed string is a number.
// Five calls are the whole harness —
//
// client.set_viewport(rows, cols) -> the handshake frame, and every resize;
// also records the window so keys resolve here
// client.on_frame(json) -> fold one frame, in arrival order
// client.render_model() -> everything to paint, already decided
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
// client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally
// first where this client may answer it
//
// — and the two rules this file must not break are ordering and framing:
// frames are folded one at a time in the order they arrive (a cell update
// that overtakes its labels is dropped, not replayed), and the *first* frame
// out must be the viewport, because the server builds the snapshot at it.
//
// # Two transports, one client
//
// The wasm bundle is the SAME artifact either way (improvise-xks6: one
// envelope pair, two deployments). Only where the strings come from differs,
// so the difference is confined to `openTransport` and nothing below it knows
// which peer it has:
//
// default an in-page Web Worker hosting the whole App over OPFS
// (improvise-9x6). No server process; this is what makes a
// GitHub Pages copy of this directory a working application.
// ?ws=host:port
// a websocket to `improvise-ws-server`. That process is how a
// document opened in the TUI is shared, so the path is kept; the
// presence of the parameter is the whole selection mechanism,
// because a second knob naming the same fact is a second thing to
// get wrong. The scheme defaults to `wss://` — see `socketUrl`.
//
// Neither is reachable silently. Every transport writes what it is doing to
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
// a peer that never arrives reads differently from one that went away.
// "disconnected" means exactly the second: a socket that opened and then
// closed. A socket that never opened keeps saying "cannot reach …", because
// the close event there is the tail of the failure and not news.
// 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";
@@ -50,23 +8,8 @@ const params = new URLSearchParams(location.search);
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
const WS_PEER = params.get("ws");
/**
* The websocket URL for `?ws=`, defaulting to TLS.
*
* `wss://` is the default and a bare `host:port` gets it, because the address
* that works from where this page is actually served is the one it should be
* easiest to write. This page ships to GitHub Pages, which is https, and an
* https origin refuses a `ws://` address as mixed content — so a `ws://`
* default would hand the browser a URL that cannot work and report it as a
* connection failure, which reads like "the server is not running".
*
* An explicit `ws://` is honoured only where it can function: a page served
* over plain http, which is the local `python3 -m http.server` case that pairs
* with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS,
* so reaching it from https means terminating TLS in front of it). From an
* https page an explicit `ws://` is upgraded rather than passed through, since
* passing it through can only produce a failure the user cannot act on.
*/
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
function socketUrl(peer) {
const secure = location.protocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
@@ -95,9 +38,8 @@ const el = {
await init();
const client = new Client();
// `#status` carries two independent facts — what the transport is doing, and
// what the document last said — so it composes them rather than letting a
// repaint quietly erase a connection error.
// `#status` carries two independent facts, composed rather than overwritten:
// a repaint must not quietly erase a connection error.
let shellStatus = "";
let modelStatus = "";
function paintStatus() {
@@ -108,19 +50,8 @@ function setShellStatus(text) {
paintStatus();
}
// What the grid's own frame costs, in whole cells, as of the last repaint.
// One header row and one header column was right until improvise-anr9: the
// column header is now one row PER LEVEL plus an optional group row, the row
// header is `row_levels` columns wide, and inline group headers take a row
// each. Reserving one of each announces a viewport the page cannot show, and
// `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of
// its own while the client's cursor offsets were computed from the larger
// window — putting the selected cell off screen.
//
// It is measured off the painted model rather than predicted, and defaults to
// the old one-and-one before the first paint, which is the only moment there
// is nothing to measure. What it counts, and the one thing it deliberately
// does not, is at the tail of `repaint`.
// 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 };
/** The extent this window can show, in whole DATA cells. */
@@ -135,22 +66,12 @@ function extent() {
function sendViewport() {
const { rows, cols } = extent();
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
// frame, but it also RECORDS the window. A client learns its window only
// from what it announced -- `row_frame.len()` is the window clipped to the
// grid, so the two differ exactly at the bottom edge, which is where the
// scroll arithmetic matters. Without this the client cannot clamp, so every
// cursor key goes upstream into `Host::handle`'s empty vec and the arrow
// keys silently do nothing (improvise-bpq2).
// frame, but it also RECORDS the window. Why that matters: clients.md.
transport.send(client.set_viewport(rows, cols));
}
// A peer that never answers the handshake. The socket has an `error` event
// for this; the worker has none that fires, because worker.js catches its own
// `start()` rejection — so a browser with no OPFS, or a worker bundle that
// failed to load, would leave the page saying "in-page worker" over an empty
// grid forever. The watchdog is deliberately about the *first frame* rather
// than about either peer's own failure modes: it is the one condition that
// means the same thing in both deployments.
// A peer that never answers the handshake. Deliberately about the *first
// frame*, not either peer's own failure modes — context/code/clients.md.
const HANDSHAKE_TIMEOUT_MS = 8000;
let watchdog = null;
function armWatchdog(message) {
@@ -158,10 +79,8 @@ function armWatchdog(message) {
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
}
// A peer that failed *loudly* has already said something more precise than
// the watchdog's guess, so the watchdog must be disarmed and not merely
// pre-empted: it fires later and would overwrite the better message with
// "did not answer" (PR #13 review). The watchdog is for silence only.
// A peer that failed *loudly* has already said something more precise, so the
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
function setShellFailure(text) {
clearTimeout(watchdog);
watchdog = null;
@@ -174,9 +93,8 @@ function onFrame(text) {
try {
client.on_frame(text);
} catch (err) {
// `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare
// listener it reached the devtools console and nothing else, and the grid
// simply stopped updating with nothing on screen saying why (improvise-hwpi).
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
// listener it reached the console alone and the grid just stopped.
setShellStatus("bad frame — the grid has stopped updating (see console)");
console.error("improvise: frame rejected", text, err);
return;
@@ -184,7 +102,7 @@ function onFrame(text) {
repaint();
}
// ── transports ───────────────────────────────────────────────────────────
// Transports.
/** The in-page worker: the standalone deployment, no server process. */
function workerTransport() {
@@ -202,7 +120,7 @@ function workerTransport() {
});
setShellStatus(`in-page worker · ${DOC}`);
// Frames posted before the worker has finished `start()` are held by
// worker.js and replayed in order, so the handshake can go out immediately.
// worker.js in arrival order, so the handshake can go out immediately.
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
}
@@ -210,12 +128,8 @@ function workerTransport() {
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 has to record that itself, because
// nothing on the close event carries it: `readyState` is already CLOSED by
// the time a close listener runs, whichever happened, and a refused
// connection and a mid-session network drop are BOTH code 1006 with
// `wasClean: false`. So "never connected" and "was connected and went away"
// are distinguishable only by remembering.
// 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", () => {
@@ -226,25 +140,10 @@ function socketTransport() {
});
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" (improvise-hwpi).
// "server not running" read exactly like "still connecting".
socket.addEventListener("error", () => setShellFailure(unreachable));
// A socket that never opened did not "disconnect" — it failed to connect,
// and the close event is the tail of that failure rather than a second,
// vaguer thing that happened (improvise-taxx). Writing "disconnected" here
// unconditionally replaced the `error` listener's precise message with the
// least informative of the two facts.
//
// Same family as the watchdog bug and NOT the same fix: the watchdog is
// disarmed because it is for silence only, whereas suppressing `close`
// outright would hide a genuine mid-session disconnect, which is the one
// thing this listener is really for. Hence the split rather than a guard.
// Both arms go through `setShellFailure`, which DISARMS the watchdog. That
// matters on the opened path: the watchdog is armed after `transport.ready`
// resolves — i.e. on `open` — so a socket that opened and then dropped
// before its first frame reaches here with the timer still pending, and
// "disconnected" is more precise than the guess it would otherwise fire 8s
// later. On the never-opened path there is no timer yet and the clear is a
// no-op. Neither arm leaves one armed.
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
// route through `setShellFailure`, which disarms the watchdog — clients.md.
socket.addEventListener("close", () =>
setShellFailure(opened ? "disconnected" : unreachable),
);
@@ -259,7 +158,7 @@ function socketTransport() {
const transport = WS_URL ? socketTransport() : workerTransport();
// ── painting ─────────────────────────────────────────────────────────────
// Painting.
let painted = -1;
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
@@ -287,26 +186,8 @@ function repaint() {
el.grid.replaceChildren(gridTable(model));
el.categories.replaceChildren(categoryList(model));
// What the frame costs, so the NEXT viewport announcement is honest: the
// header lines, a group line if there is one, the row header's width in
// columns, and every inline group row.
//
// Inline group ROWS are NOT reserved, and that is a decision rather than
// an oversight. They take real height, but how many are in view depends on
// how many rows the window asked for, so every way of counting them is
// either a feedback loop (count the current ones: a smaller window crosses
// fewer groups and asks for a larger one) or a ratchet (keep the maximum:
// a group-dense grid stores an oversized cost on its first oversized
// projection and never lets it fall — one group header per row clamps the
// grid to a single row, permanently). Both were tried on this PR.
//
// What is left is an over-request bounded by the groups in view. The cost
// is that a grouped grid asks for a few rows more than the table shows,
// which matters only for a selection near the bottom edge — and that gap
// is not new and not specific to groups: nothing in this page calls
// `scrollIntoView` at all, so a selection the client has not yet scrolled
// to can already sit below the fold. Filed as its own ticket rather than
// answered with a heuristic in a file no test in this repo can execute.
// What the frame costs, so the NEXT viewport announcement is honest.
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
@@ -338,9 +219,8 @@ function gridTable(model) {
for (const group of model.col_groups) {
const th = document.createElement("th");
th.colSpan = group.span;
// `label` is null for a run in no group. An empty STRING is a legal
// group name and still gets its indicator, which is why this is a null
// check and not a truthiness test.
// `label` is null for a run in no group; an empty STRING is a legal group
// name, so this is a null check and not a truthiness test.
th.textContent =
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
th.classList.add("group");
@@ -361,9 +241,8 @@ function gridTable(model) {
}
const body = table.createTBody();
// The cell coordinates a click sends are DATA row indices, and a group
// header is not a data row -- so this counts rows rather than trusting the
// position in `model.rows`.
// The cell coordinates a click sends are DATA row indices and a group header
// is not a data row, so this counts rather than trusting `model.rows`.
let r = -1;
for (const row of model.rows) {
const tr = body.insertRow();
@@ -389,21 +268,16 @@ function gridTable(model) {
row.cells.forEach((cell, c) => {
const td = tr.insertCell();
// A keyboard edit is open on the selected cell when `model.editing` is
// a string; it holds what has been typed so far. Both facts are
// decided in Rust (`RenderModel::editing`) — the page does not know
// which modes are editing modes or which buffer they type into.
// Without this the mode indicator would say INSERT while every
// keystroke vanished into a buffer nobody drew.
// A keyboard edit is open when `model.editing` is a string, and it holds
// what has been typed. Both facts are decided in Rust.
const typing = cell.selected && typeof model.editing === "string";
td.textContent = typing ? model.editing + "\u258f" : cell.text;
td.classList.toggle("numeric", cell.numeric && !typing);
td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected);
td.classList.toggle("typing", typing);
// `editable` is the server's answer relayed through Rust: a stored
// cell. An aggregate is a drill target and drill is not on the wire
// yet, so it is simply not offered here.
// `editable` is the server's answer relayed through Rust: a stored cell.
// Drill is not on the wire yet, so an aggregate is not offered.
td.classList.toggle("editable", cell.editable);
if (cell.editable)
td.addEventListener("click", () => edit(td, dataRow, c, cell.edit_text));
@@ -423,9 +297,8 @@ function indicator(collapsed) {
function edit(td, row, col, seed) {
if (editing) return;
editing = { row, col };
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
// one. `td.textContent` has been through the number format, so a stored
// 1200 reads "1,200" and committing that stores the string "1,200".
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn one:
// `td.textContent` has been through the number format.
const original = seed;
const input = document.createElement("input");
input.value = original;
@@ -434,7 +307,7 @@ function edit(td, row, col, seed) {
input.select();
// Enter removes the focused <input>, which fires `blur`, which calls
// `close` again. Harmless before, but only by accident (improvise-hwpi).
// `close` again. Harmless before, but only by accident.
let closed = false;
const close = (commit) => {
if (closed) return;
@@ -475,16 +348,8 @@ function categoryList(model) {
return list;
}
// ── the keyboard ─────────────────────────────────────────────────────────
//
// `Client::on_key_event` classifies the event and folds it locally where this
// client may answer it — text entry, and since improvise-bpq2 the grid, panel
// and tile cursors too, which is why `sendViewport` must call `set_viewport`
// and not `Client.viewport` (keys.rs carries the list, and what is still
// upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which
// `Host::handle` answers with an empty vec: those keys are sent and do
// nothing. That is settled rather than unfinished — the host must not know
// the keymap — and closing one means teaching this client, not that arm.
// The keyboard. `Client::on_key_event` folds locally where it can; what it
// cannot answer is sent and does nothing, by design (context/code/clients.md).
window.addEventListener("keydown", (e) => {
// The inline editor owns the keyboard while it is open. It stops
@@ -496,18 +361,15 @@ window.addEventListener("keydown", (e) => {
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
if (frame) {
// A frame means the page claimed the key, so the browser must not also
// act on it — an arrow that both moves the cursor and scrolls the page is
// the worse of the two errors. Combinations under Ctrl or Alt are left
// alone regardless: those are the browser's own shortcuts, and this
// client resolves nothing under them anyway.
// A frame means the page claimed the key, so the browser must not also act
// on it. Ctrl/Alt combinations are left alone: they are the browser's.
if (!e.ctrlKey && !e.altKey) e.preventDefault();
transport.send(frame);
}
repaint();
});
// ── start ────────────────────────────────────────────────────────────────
// Start.
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent