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) => {
+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
# Assemble the static site from `web/` into `site/`.
#
# `web/` is the *source* directory: it also holds build.sh, assemble.sh and
# .gitignore, which have no business being served. This copies what ships.
#
# Extracted from the CI workflow so that the Pages job and the browser harness
# assemble the same tree — a harness that runs against a different arrangement
# of files is testing something nobody deploys (improvise-vvl3). Same reason
# `web/build.sh` exists rather than two copies of the wasm-pack invocation.
#
# Usage: web/assemble.sh [OUT_DIR] [CACHE_BUST_TOKEN]
#
# The token cache-busts the two generated bundles. Pages serves everything
# `max-age=600` and the wasm-pack filenames carry no content hash, so for ten
# minutes after a deploy a returning visitor runs the PREVIOUS build — which
# during improvise-9x6 debugging made a shipped fix look like it had not
# shipped, twice. CI passes the commit SHA; a local run defaults to `dev`.
#
# Both halves are stamped, because a fresh glue file does NOT pull a fresh
# .wasm: the glue locates it with `new URL('..._bg.wasm', import.meta.url)`,
# and a URL constructed against a base does not inherit the base's query
# string. Stamping only the import specifier would refresh the wrapper and
# keep the stale binary — the failure mode this is here to end.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
out=${1:-"$root/site"}
token=${2:-dev}
test -d "$root/web/pkg" || { echo "web/pkg missing — run web/build.sh first" >&2; exit 1; }
test -d "$root/web/worker-pkg" || { echo "web/worker-pkg missing — run web/build.sh first" >&2; exit 1; }
rm -rf "$out"
mkdir -p "$out"
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$out/"
# The document the page opens on a browser that has never seen one.
# web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust,
# which writes them through the VFS — so leaving it out is an empty grid, not
# a 404 the page could report.
cp -f "$root/web/demo.improv" "$out/"
cp -rf "$root/web/pkg" "$out/pkg"
cp -rf "$root/web/worker-pkg" "$out/worker-pkg"
rm -f "$out/pkg/.gitignore" "$out/pkg/package.json"
rm -f "$out/worker-pkg/.gitignore" "$out/worker-pkg/package.json"
# BSD sed (macOS) needs an argument to -i; GNU sed must not be given one.
sedi() { sed "$1" "$2" > "$2.tmp" && mv -f "$2.tmp" "$2"; }
sedi "s|\./pkg/improvise_wasm_client\.js|&?v=${token}|" "$out/app.js"
sedi "s|\./worker-pkg/improvise_worker_server\.js|&?v=${token}|" "$out/worker.js"
sedi "s|'improvise_wasm_client_bg\.wasm'|'improvise_wasm_client_bg.wasm?v=${token}'|" \
"$out/pkg/improvise_wasm_client.js"
sedi "s|'improvise_worker_server_bg\.wasm'|'improvise_worker_server_bg.wasm?v=${token}'|" \
"$out/worker-pkg/improvise_worker_server.js"
# One `grep` over all four files succeeds when ANY one of them matches, so
# three failed substitutions could still produce a "successful" assembly under
# `set -e` (found by Copilot on PR #24). These checks are the only guard
# against the generated glue changing shape, so each stamped reference is
# verified on its own and names itself when it is the one missing.
stamped() {
grep -q "$2" "$1" || {
echo "assemble: $1 is missing the cache-bust stamp: $2" >&2
exit 1
}
grep -n "$2" "$1"
}
stamped "$out/app.js" "pkg/improvise_wasm_client.js?v=${token}"
stamped "$out/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}"
stamped "$out/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}"
stamped "$out/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"
+7 -3
View File
@@ -65,9 +65,13 @@
<code>wss://</code></strong>, because this page is served over https and
an https origin refuses a <code>ws://</code> address as mixed content.
Note that <code>improvise-ws-server</code> speaks plain
<code>ws://</code> only, so reaching it from here means terminating TLS
in front of it. Served over <code>http://localhost</code> the same page
defaults to <code>ws://</code> and pairs with it directly.
<code>ws://</code> only, so reaching it from <em>this</em> page means
terminating TLS in front of it with a reverse proxy or a tunnel — the
README has a worked Caddy example. Served over
<code>http://localhost</code> the same page defaults to
<code>ws://</code> and pairs with it directly, with nothing in between;
that is the case the pairing is really for. Sharing a document is what
the server is for — not running one is what everything above is for.
</p>
<main>