Files
improvise/web/app.js
T
Edward Langley 59cc6720b5 feat(browser): the page draws the mode's hint instead of spelling keys
`?` now enters `Help` — the keymap says so and this client resolves
`enter-mode` — but the page has no help overlay: `src/ui/help.rs` is 600
lines of ratatui and nothing shared carries its content. Landing the key
without this would have swapped the mode label to HELP over an unchanged
grid, which looks broken rather than unfinished.

`AppMode::hint_text()` already exists in the protocol crate and is what the
TUI's footer draws, so `RenderModel::hint` relays it. Every mode now states
its own keys in the browser, the page spells none of them, and the modes
this client can enter but not yet draw say what they are. A real help
overlay needs the content moved into the shared layer first, which is the
same move design-principles §5 asks for and is filed rather than faked.

`web/*` is unrun; `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:35:54 -07:00

360 lines
15 KiB
JavaScript

// 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=URL 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.
//
// 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.
import init, { Client } from "./pkg/improvise_wasm_client.js";
const params = new URLSearchParams(location.search);
const WS_URL = params.get("ws"); // present => socket, absent => in-page worker
const DOC = params.get("doc") ?? "demo.improv";
const SEED = params.get("seed") ?? "demo.improv";
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
const ROW_PX = 26;
const COL_PX = 110;
const el = {
grid: document.getElementById("grid"),
categories: document.getElementById("categories"),
mode: document.getElementById("mode"),
hint: document.getElementById("hint"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
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.
let shellStatus = "";
let modelStatus = "";
function paintStatus() {
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
}
function setShellStatus(text) {
shellStatus = text;
paintStatus();
}
/** The extent this window can show, in whole cells. */
function extent() {
const box = el.grid.getBoundingClientRect();
return {
rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1),
cols: Math.max(1, Math.floor(box.width / COL_PX) - 1),
};
}
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_labels.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).
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.
const HANDSHAKE_TIMEOUT_MS = 8000;
let watchdog = null;
function armWatchdog(message) {
clearTimeout(watchdog);
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.
function setShellFailure(text) {
clearTimeout(watchdog);
watchdog = null;
setShellStatus(text);
}
/** One frame, one fold, in arrival order. Never batch or reorder these. */
function onFrame(text) {
clearTimeout(watchdog);
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).
setShellStatus("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) => onFrame(event.data));
worker.addEventListener("messageerror", () =>
setShellFailure("the worker sent a frame this page could not read"),
);
worker.addEventListener("error", (event) => {
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
console.error("improvise: worker error", event);
});
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.
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 ready = new Promise((resolve) => {
socket.addEventListener("open", () => {
setShellStatus(`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" (improvise-hwpi).
socket.addEventListener("error", () =>
setShellFailure(`cannot reach ${WS_URL} — is improvise-ws-server running?`),
);
socket.addEventListener("close", () => setShellStatus("disconnected"));
setShellStatus(`connecting to ${WS_URL}…`);
return {
send: (text) => {
if (socket.readyState === WebSocket.OPEN) socket.send(text);
},
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
function repaint() {
if (editing) return;
let model;
try {
model = JSON.parse(client.render_model());
} catch (err) {
setShellStatus("cannot render the current state (see console)");
console.error("improvise: render failed", err);
return;
}
if (model.revision === painted) return;
painted = model.revision;
el.mode.textContent = model.mode;
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
// keys itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
modelStatus = model.status;
paintStatus();
el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model));
el.categories.replaceChildren(categoryList(model));
}
function gridTable(model) {
const table = document.createElement("table");
const head = table.createTHead().insertRow();
head.appendChild(document.createElement("th"));
for (const label of model.col_labels) {
const th = document.createElement("th");
th.textContent = label;
head.appendChild(th);
}
const body = table.createTBody();
model.rows.forEach((row, r) => {
const tr = body.insertRow();
const header = document.createElement("th");
header.scope = "row";
header.textContent = row.label;
tr.appendChild(header);
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.
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.
td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
});
});
return table;
}
/** Open an inline editor. Committing sends a frame; the value that comes
* back is the server's, never the string typed here. */
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".
const original = seed;
const input = document.createElement("input");
input.value = original;
td.replaceChildren(input);
input.focus();
input.select();
// Enter removes the focused <input>, which fires `blur`, which calls
// `close` again. Harmless before, but only by accident (improvise-hwpi).
let closed = false;
const close = (commit) => {
if (closed) return;
closed = true;
editing = null;
if (commit) {
const frame = client.typed_cell(row, col, input.value);
// null only if the grid moved under the editor; the repaint that
// follows restores whatever the server now says is there.
if (frame) transport.send(frame);
}
td.replaceChildren(original);
painted = -1;
repaint();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") close(true);
else if (e.key === "Escape") close(false);
// The editor owns the keyboard while it is open; the page-level handler
// below must not also see these.
e.stopPropagation();
});
input.addEventListener("blur", () => close(false));
}
function categoryList(model) {
const list = document.createElement("ul");
for (const row of model.categories) {
const li = document.createElement("li");
li.className = row.is_category ? "category" : "item";
li.classList.toggle("selected", row.selected);
li.textContent = row.is_category
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
: row.label;
list.appendChild(li);
}
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.
window.addEventListener("keydown", (e) => {
// The inline editor owns the keyboard while it is open. It stops
// propagation, so this is belt and braces for a focus we did not predict.
if (editing) return;
// BrowserKey drops anything held with the platform meta key precisely so
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
if (e.metaKey) return;
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.
if (!e.ctrlKey && !e.altKey) e.preventDefault();
transport.send(frame);
}
repaint();
});
// ── start ────────────────────────────────────────────────────────────────
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
armWatchdog(
WS_URL
? `${WS_URL} accepted the connection but sent no document`
: "the in-page worker did not answer — this browser may not allow it (see console)",
);
new ResizeObserver(sendViewport).observe(el.grid);