feat(wasm-client,web): the DOM renderer for the browser grid (improvise-cr3)

The last blocker of improvise-1ey. Mostly assembly: `Client` already had the
fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a
cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's
flattening. What was missing was the three joints between them.

`render.rs` is the one that matters. The DOM half is JavaScript and JavaScript
here is untested, so every question with a *silent* wrong answer is answered
in Rust under test and crosses as decided data: `editable` is a fact about the
`CellId` variant (Record only — an Aggregate is a drill target and `Drill` is
not on the wire yet, and `None` is "no projected cell", not "aggregate"), the
selection is placed by subtracting the scroll offset because `GridCache` is
viewport-indexed while the cursor is not, and the category panel is
`flatten_cat_tree` rather than a second walk of the cache.

`socket.rs` frames what `improvise-ws-server` actually speaks, which is not
what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at
extent zero is an empty grid), it batches `Commands`, and it has no `Key`
variant. The two enums are duplicated because a native crate cannot be a wasm
dependency; `improvise-ws-server` is a dev-dependency so the compatibility is
checked through its own types, and a companion test proves the check is
discriminating on the variant names that would drift.

`web/` is the shell, and it is thin on purpose: four string calls in and out,
no protocol knowledge, no framework.

Gates: 1047 passing (was 1031), clippy and fmt clean, and
`cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean.
Verified end to end headlessly — the wasm-pack artifact driven from node over
a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed,
"98,765" projected back. The DOM painting itself is unverified; there is no
headless browser here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 17:34:39 -07:00
parent f007be88af
commit c72832f542
11 changed files with 1432 additions and 4 deletions
+2
View File
@@ -0,0 +1,2 @@
# wasm-pack output; built, never committed.
pkg/
+167
View File
@@ -0,0 +1,167 @@
// The browser half of improvise-cr3, kept as thin as it can be made.
//
// 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.
// Four calls are the whole harness —
//
// Client.viewport(rows, cols) -> the handshake frame, and every resize
// client.on_frame(json) -> fold one socket 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"
//
// — 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.
import init, { Client } from "./pkg/improvise_wasm_client.js";
const params = new URLSearchParams(location.search);
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`;
// 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"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
await init();
const client = new Client();
const socket = new WebSocket(WS_URL);
/** 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() {
if (socket.readyState !== WebSocket.OPEN) return;
const { rows, cols } = extent();
socket.send(Client.viewport(rows, cols));
}
let painted = -1;
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
function repaint() {
const model = JSON.parse(client.render_model());
if (model.revision === painted || editing) return;
painted = model.revision;
el.mode.textContent = model.mode;
el.status.textContent = model.status;
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();
td.textContent = cell.text;
td.classList.toggle("numeric", cell.numeric);
td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected);
// `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));
});
});
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) {
if (editing) return;
editing = { row, col };
const original = td.textContent;
const input = document.createElement("input");
input.value = original;
td.replaceChildren(input);
input.focus();
input.select();
const close = (commit) => {
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) socket.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);
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;
}
socket.addEventListener("open", () => {
sendViewport(); // the handshake: the snapshot is built at this extent
el.status.textContent = "connected";
});
socket.addEventListener("message", (event) => {
// One frame, one fold, in arrival order. Never batch or reorder these.
client.on_frame(event.data);
repaint();
});
socket.addEventListener("close", () => {
el.status.textContent = "disconnected";
});
new ResizeObserver(sendViewport).observe(el.grid);
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Build the wasm bundle this page loads, then say how to run it.
#
# Needs wasm-pack (cargo install wasm-pack) and the wasm32-unknown-unknown
# target. Nothing else in the repo depends on either — the `wasm` CI job only
# runs `cargo check --target wasm32-unknown-unknown`, which does not need a
# bindgen step.
set -eu
root=$(cd "$(dirname "$0")/.." && pwd)
wasm-pack build "$root/crates/improvise-wasm-client" \
--target web --out-dir "$root/web/pkg"
cat <<'MSG'
Built. To run it, in two terminals:
improvise-ws-server your-model.improv --port 8787
python3 -m http.server 8080 --directory web
then open http://127.0.0.1:8080/ (override the socket with ?ws=ws://host:port).
MSG
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<!--
The browser shell for improvise-ws-server (improvise-cr3).
Deliberately three elements and no framework. Everything that could be
*wrong* — what a cell is, whether it may be typed into, where the
selection sits, how the category tree flattens — is decided in Rust and
arrives already decided (see `render.rs`). This page paints it.
Build the wasm module into ./pkg first:
wasm-pack build crates/improvise-wasm-client --target web --out-dir ../../web/pkg
then serve this directory over http (a module script will not load from
file://) and run the server beside it:
improvise-ws-server bank-info.improv --port 8787
python3 -m http.server 8080 --directory web
The socket address can be overridden with ?ws=ws://host:port.
-->
<header>
<h1>improvise</h1>
<span id="view-name"></span>
<span id="mode"></span>
<span id="status" role="status"></span>
</header>
<main>
<aside id="categories" aria-label="Categories"></aside>
<div id="grid" tabindex="0" aria-label="Grid"></div>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
/* Legibility only. Styling the browser client properly is not improvise-cr3. */
:root {
--bg: #fbfaf7;
--fg: #1c1b19;
--line: #d8d4cc;
--accent: #2f6f4f;
--selected: #e8e2d4;
--error: #a8322d;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17181a;
--fg: #e7e4de;
--line: #35373b;
--accent: #7fc4a0;
--selected: #2c2f33;
--error: #e2827c;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
color: var(--fg);
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
}
header {
display: flex;
gap: 1rem;
align-items: baseline;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--line);
}
header h1 {
margin: 0;
font-size: 1rem;
color: var(--accent);
}
#mode {
color: var(--accent);
}
#status {
margin-left: auto;
opacity: 0.75;
}
main {
flex: 1;
display: flex;
min-height: 0;
}
#categories {
width: 15rem;
flex: none;
overflow: auto;
padding: 0.5rem;
border-right: 1px solid var(--line);
}
#categories ul {
margin: 0;
padding: 0;
list-style: none;
}
#categories .item {
padding-left: 1.5rem;
opacity: 0.85;
}
#categories .selected,
td.selected {
background: var(--selected);
outline: 1px solid var(--accent);
}
#grid {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--line);
padding: 0.25rem 0.5rem;
min-width: 6rem;
height: 1.75rem;
text-align: left;
font-weight: normal;
}
th {
background: color-mix(in srgb, var(--line) 35%, transparent);
font-weight: 600;
}
td.numeric {
text-align: right;
}
td.error {
color: var(--error);
}
/* The only affordance that matters: a cell the server said is settable. */
td.editable {
cursor: text;
}
td:not(.editable) {
opacity: 0.7;
}
td input {
width: 100%;
border: none;
padding: 0;
font: inherit;
color: inherit;
background: transparent;
}