Merge main into wave/a-grid-frame

Two conflicts, both expected.

**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.

**`lib.rs`'s composition test.** Main's deletion taken: 18189c1 moved
`client_showing`, `published_basis` and
`a_client_composes_a_write_from_the_id_it_was_given` into `socket.rs` when
it consolidated `set_cell_at` and `typed`.

Main's new `GridUpdate::RowLabels`/`ColLabels` call sites are converted, and
five doc comments naming the retired spellings with them — a fact stated in
several places is a grep, not an edit.

1327 passing, 0 failing. clippy/fmt clean, wasm gate clean, both bundles
build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 00:10:15 -07:00
63 changed files with 7263 additions and 1984 deletions
+32 -3
View File
@@ -39,6 +39,9 @@
// 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.
import init, { Client } from "./pkg/improvise_wasm_client.js";
@@ -206,8 +209,17 @@ function workerTransport() {
/** 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 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.
let opened = false;
const ready = new Promise((resolve) => {
socket.addEventListener("open", () => {
opened = true;
setShellStatus(`connected · ${WS_URL}`);
resolve();
});
@@ -215,10 +227,27 @@ 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).
socket.addEventListener("error", () =>
setShellFailure(`cannot reach ${WS_URL} — is improvise-ws-server running?`),
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.
socket.addEventListener("close", () =>
setShellFailure(opened ? "disconnected" : unreachable),
);
socket.addEventListener("close", () => setShellStatus("disconnected"));
setShellStatus(`connecting to ${WS_URL}…`);
return {
send: (text) => {