57a25807af
PR #61 landed while this branch was open. It deleted `harness/tests/pointer.spec.js` and `harness/tests/widgets.spec.js`, split `web/app.js` into app/shell/serial, and added a Node suite under `web/tests/`. Two conflicts, both resolved deliberately rather than by reflex. **`harness/tests/pointer.spec.js` — modify/delete, resolved as DELETE.** The habitual "keep both sides" would resurrect a file #61 removed on purpose. `contract.spec.js` states its own scope as the RenderModel→DOM boundary and the pointer's half of the JS→wasm one, so that is where these assertions belong. Merged in, rather than restored: - the category sidebar is open at launch and `C` still hides it; - a first tile click selects and does NOT touch the document, a second cycles; - the view row and formula row clicks improvise-iioi added, which `contract.spec.js` had no entry for at all. **`context/test-evidence/clients.md` — content, resolved as BOTH SIDES.** The two `pointer.spec.js` subsections are retargeted at the file that exists, with a note saying why restoring the deleted one would have been wrong. The two older `#### … the file's own scope` sections keep their history and gain a forward pointer, because a reader following them would otherwise hunt for a file that is gone. **`web/tests/bundle/wasm_surface.test.js` now names `click_formula` and `click_view`.** That test is an exact `deepEqual` over the page's `client.*` calls and its comment says a new call "has to come and say so". It did: it failed on the merge, which is the mechanism working. It could not have been satisfied earlier — it did not exist when this branch was cut. Two review findings fixed on top, neither a live bug: - **The tile guarantee was overclaimed.** `T` leaves `tile_cat_idx` at 0, so a click on the LEFTMOST tile cycles on the first gesture. "A click never mutates on its own" and "a second click cycles" are both false against that probe. The behaviour is right — a click does what `Enter` does there — so the prose now states the implemented rule, and `t_then_a_click_on_the_leftmost_tile_cycles_it_immediately` names the case and asserts the neighbouring tile answers the same gesture differently. - **`switch_view_at`'s `Option` had no guard on the keyboard side.** Removing the `unwrap_or_default()` makes `Enter` in an empty view panel `Upstream` — gotcha 47's black hole — and passed the whole workspace. That would make membership of the host-needed list state-dependent, which is the one property that list denies. One test closes it. Gates on the merged tree: 1391 Rust tests, 26 + 3 Node tests, 16 Playwright (3 WebKit OPFS skips), clippy -D warnings, fmt, harness fmt + wasm32 clippy, eight wasm crates, comment runs and 513 evidence entries clean. `.beads/` matches gh/main exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
448 lines
16 KiB
JavaScript
448 lines
16 KiB
JavaScript
// 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";
|
||
import {
|
||
COL_PX,
|
||
ROW_PX,
|
||
createShell,
|
||
extentOf,
|
||
frameCostOf,
|
||
gridPlan,
|
||
indicator,
|
||
socketUrl,
|
||
} from "./shell.js";
|
||
|
||
const params = new URLSearchParams(location.search);
|
||
// present => socket, absent => in-page worker. The value is a host:port, and
|
||
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
||
const WS_PEER = params.get("ws");
|
||
|
||
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
|
||
const DOC = params.get("doc") ?? "demo.improv";
|
||
const SEED = params.get("seed") ?? "demo.improv";
|
||
|
||
const el = {
|
||
grid: document.getElementById("grid"),
|
||
categories: document.getElementById("categories"),
|
||
formulas: document.getElementById("formulas"),
|
||
views: document.getElementById("views"),
|
||
tiles: document.getElementById("tiles"),
|
||
mode: document.getElementById("mode"),
|
||
hint: document.getElementById("hint"),
|
||
help: document.getElementById("help"),
|
||
status: document.getElementById("status"),
|
||
viewName: document.getElementById("view-name"),
|
||
};
|
||
|
||
await init();
|
||
const client = new Client();
|
||
|
||
// A peer that never answers the handshake, and the two facts `#status` shows.
|
||
// Both are decided in shell.js; this is the wire to the element.
|
||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||
const shell = createShell({
|
||
onStatus: (text) => {
|
||
el.status.textContent = text;
|
||
},
|
||
timeoutMs: HANDSHAKE_TIMEOUT_MS,
|
||
});
|
||
|
||
// 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 };
|
||
|
||
function sendViewport() {
|
||
const box = el.grid.getBoundingClientRect();
|
||
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
|
||
const { rows, cols } = extentOf(box, frameCost, metrics);
|
||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||
// frame, but it also RECORDS the window. Why that matters: clients.md.
|
||
transport.send(client.set_viewport(rows, cols));
|
||
}
|
||
|
||
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
||
function onFrame(text) {
|
||
shell.sawFrame();
|
||
try {
|
||
client.on_frame(text);
|
||
} catch (err) {
|
||
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
|
||
// listener it reached the console alone and the grid just stopped.
|
||
shell.setShell("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", () =>
|
||
shell.fail("the worker sent a frame this page could not read"),
|
||
);
|
||
worker.addEventListener("error", (event) => {
|
||
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
|
||
console.error("improvise: worker error", event);
|
||
});
|
||
shell.setShell(`in-page worker · ${DOC}`);
|
||
// Frames posted before the worker has finished `start()` are held by
|
||
// worker.js in arrival 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 unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`;
|
||
// 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", () => {
|
||
opened = true;
|
||
shell.setShell(`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".
|
||
socket.addEventListener("error", () => shell.fail(unreachable));
|
||
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
|
||
// route through `shell.fail`, which disarms the watchdog — clients.md.
|
||
socket.addEventListener("close", () =>
|
||
shell.fail(opened ? "disconnected" : unreachable),
|
||
);
|
||
shell.setShell(`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) {
|
||
shell.setShell("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 `help::hint_line`. The page spells no keys
|
||
// itself; see `RenderModel::hint`.
|
||
el.hint.textContent = model.hint;
|
||
paintHelp(model);
|
||
shell.setModel(model.status);
|
||
el.viewName.textContent = model.view_name;
|
||
el.grid.replaceChildren(gridTable(model));
|
||
// The flag is the client's answer, not the page's: `C` toggles it and the
|
||
// TUI draws the panel on exactly the same condition (improvise-wsaw).
|
||
el.categories.hidden = !model.category_panel_open;
|
||
el.categories.replaceChildren(categoryList(model));
|
||
paintTiles(model);
|
||
paintPanel(el.views, model.views, viewRow, (i) => client.click_view(i));
|
||
paintPanel(el.formulas, model.formulas, formulaRow, (i) =>
|
||
client.click_formula(i),
|
||
);
|
||
|
||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||
const next = frameCostOf(model);
|
||
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
||
frameCost = next;
|
||
if (changed) sendViewport();
|
||
}
|
||
|
||
/** The help overlay. An empty `help` is what says it is closed -- the page
|
||
* asks no question about the mode, and writes none of the content: the rows,
|
||
* their keys and the page titles are `improvise_protocol::help`'s, the same
|
||
* ones the TUI draws (improvise-ktv1). */
|
||
function paintHelp(model) {
|
||
el.help.hidden = model.help.length === 0;
|
||
if (el.help.hidden) {
|
||
el.help.replaceChildren();
|
||
return;
|
||
}
|
||
|
||
const tabs = document.createElement("div");
|
||
tabs.className = "help-tabs";
|
||
model.help_titles.forEach((title, i) => {
|
||
const tab = document.createElement("span");
|
||
tab.textContent = title;
|
||
tab.className = i === model.help_page ? "help-tab active" : "help-tab";
|
||
tabs.append(tab);
|
||
});
|
||
|
||
const body = document.createElement("dl");
|
||
body.className = "help-body";
|
||
for (const row of model.help) {
|
||
const keys = document.createElement("dt");
|
||
keys.textContent = row.keys;
|
||
keys.className = `help-${row.kind}`;
|
||
body.append(keys);
|
||
// A prose row has no description and spans both columns (its class says
|
||
// so); emitting an empty `dd` beside it would leave a blank grid cell.
|
||
if (row.description) {
|
||
const desc = document.createElement("dd");
|
||
desc.textContent = row.description;
|
||
body.append(desc);
|
||
}
|
||
}
|
||
|
||
el.help.replaceChildren(tabs, body);
|
||
}
|
||
|
||
/** The grid, painted from a plan in which every decision is already made
|
||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||
* which labels repeat and are therefore blank, how many header levels there
|
||
* are, which columns share a group and whether it is collapsed all arrive
|
||
* answered, by the same `AxisFrame` the TUI widget reads. */
|
||
function gridTable(model) {
|
||
const plan = gridPlan(model);
|
||
const table = document.createElement("table");
|
||
const head = table.createTHead();
|
||
const corner = () => {
|
||
const th = document.createElement("th");
|
||
th.colSpan = plan.cornerSpan;
|
||
return th;
|
||
};
|
||
|
||
// The column-group line, when there is one. An empty `groups` means this
|
||
// grid has no column groups at all -- not that every group is blank.
|
||
if (plan.groups.length > 0) {
|
||
const groups = head.insertRow();
|
||
groups.appendChild(corner());
|
||
for (const group of plan.groups) {
|
||
const th = document.createElement("th");
|
||
th.colSpan = group.span;
|
||
th.textContent = group.text;
|
||
th.classList.add("group");
|
||
groups.appendChild(th);
|
||
}
|
||
}
|
||
|
||
// One header line per level. A blank entry is repeat suppression, decided
|
||
// in Rust -- never an absent label.
|
||
for (const level of plan.levels) {
|
||
const tr = head.insertRow();
|
||
tr.appendChild(corner());
|
||
for (const label of level) {
|
||
const th = document.createElement("th");
|
||
th.textContent = label;
|
||
tr.appendChild(th);
|
||
}
|
||
}
|
||
|
||
const body = table.createTBody();
|
||
for (const row of plan.body) {
|
||
const tr = body.insertRow();
|
||
if (row.kind === "group") {
|
||
const th = document.createElement("th");
|
||
th.colSpan = row.colSpan;
|
||
th.textContent = row.text;
|
||
th.classList.add("group");
|
||
tr.appendChild(th);
|
||
continue;
|
||
}
|
||
|
||
for (const label of row.labels) {
|
||
const header = document.createElement("th");
|
||
header.scope = "row";
|
||
header.textContent = label;
|
||
tr.appendChild(header);
|
||
}
|
||
|
||
for (const cell of row.cells) {
|
||
const td = tr.insertCell();
|
||
td.textContent = cell.text;
|
||
for (const [name, on] of Object.entries(cell.classes)) {
|
||
td.classList.toggle(name, on);
|
||
}
|
||
// Every cell moves the selection; an editable one also opens its editor,
|
||
// which is the pointer's `i`. Not repainted in that branch: `edit` has
|
||
// set `editing`, and a repaint would take the <input> away.
|
||
td.addEventListener("click", () => {
|
||
const frame = client.click_cell(cell.row, cell.col);
|
||
if (frame) transport.send(frame);
|
||
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
|
||
else repaint();
|
||
});
|
||
}
|
||
}
|
||
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.
|
||
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.
|
||
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));
|
||
}
|
||
|
||
/** A side panel. An empty row list is what says it is closed — the page asks
|
||
* no question about the mode, exactly as `paintHelp` does not. */
|
||
function paintPanel(host, rows, build, clicked) {
|
||
host.hidden = rows.length === 0;
|
||
const list = document.createElement("ul");
|
||
rows.forEach((row, index) => {
|
||
const li = build(row);
|
||
li.addEventListener("click", () => send(clicked(index)));
|
||
list.appendChild(li);
|
||
});
|
||
host.replaceChildren(list);
|
||
}
|
||
|
||
function viewRow(row) {
|
||
const li = document.createElement("li");
|
||
li.classList.toggle("selected", row.selected);
|
||
li.classList.toggle("active", row.active);
|
||
// The active marker is the TUI's, and the axis line is the shared
|
||
// derivation's — the page spells neither the summary nor the axis letters.
|
||
const name = document.createElement("span");
|
||
name.className = "name";
|
||
name.textContent = (row.active ? "▶ " : " ") + row.name;
|
||
li.append(name);
|
||
if (row.axes) {
|
||
const axes = document.createElement("span");
|
||
axes.className = "axes";
|
||
axes.textContent = row.axes;
|
||
li.append(axes);
|
||
}
|
||
return li;
|
||
}
|
||
|
||
function formulaRow(row) {
|
||
const li = document.createElement("li");
|
||
li.classList.toggle("selected", row.selected);
|
||
li.textContent = row.text;
|
||
return li;
|
||
}
|
||
|
||
/** The tile bar. `selected` is only ever true in tile-select mode, which is
|
||
* decided in Rust. The strip is always laid out — context/code/clients.md. */
|
||
function paintTiles(model) {
|
||
const tiles = model.tiles.map((tile, index) => {
|
||
const span = document.createElement("span");
|
||
span.className = "tile";
|
||
span.classList.toggle("selected", tile.selected);
|
||
span.textContent = `${tile.category} ${tile.axis}`;
|
||
span.addEventListener("click", () => send(client.click_tile(index)));
|
||
return span;
|
||
});
|
||
el.tiles.replaceChildren(...tiles);
|
||
}
|
||
|
||
function categoryList(model) {
|
||
const list = document.createElement("ul");
|
||
model.categories.forEach((row, index) => {
|
||
const li = document.createElement("li");
|
||
li.className = row.is_category ? "category" : "item";
|
||
li.classList.toggle("selected", row.selected);
|
||
// `indicator`, not a second pair of triangles: this inlined U+25B8/U+25BE
|
||
// where the TUI and the page's own group headers draw U+25B6/U+25BC.
|
||
li.textContent = row.is_category
|
||
? `${indicator(!row.expanded)} ${row.label} (${row.item_count}) ${row.axis}`
|
||
: row.label;
|
||
li.addEventListener("click", () => send(client.click_category(index)));
|
||
list.appendChild(li);
|
||
});
|
||
return list;
|
||
}
|
||
|
||
/** Send what a `click_*` call returned, and repaint. `null` means the click
|
||
* resolved to nothing, which is an answer and not a failure. */
|
||
function send(frame) {
|
||
if (frame) transport.send(frame);
|
||
repaint();
|
||
}
|
||
|
||
// 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
|
||
// 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. Ctrl/Alt combinations are left alone: they are the browser's.
|
||
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
|
||
shell.arm(
|
||
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);
|