Files
improvise/web/app.js
T
Edward Langley 955c30a864 fix(web): land the review findings main is missing
Repair-forward for the accidental push at 959f249, which put the PRE-review
version of the browser-widget work onto main. The three defects review found
are all in the version main currently carries; this is the delta that
supersedes it.

1. `categoryList` did not call `indicator()`. It inlined U+25B8/U+25BE, the
   SMALL triangles, where `indicator()` — the page's declared ONE spelling —
   the page's own grid group headers, and src/ui/category_panel.rs all draw
   U+25B6/U+25BC. The browser's category panel disagreed with the TUI's and
   with its own grid, inside the file whose comment says "only this spelling
   is the page's".

2. Two specs pinned the divergent pair, so the obvious cure would have failed
   tests that shipped with the feature. widgets.spec.js AND pointer.spec.js —
   the latter written on a different branch, which had grown the same wrong
   assertions independently. Three files converging on a spelling nothing
   compares is design-principles §5's predicted failure, observed. Proved
   discriminating by running the corrected specs against the pre-fix bundle,
   where they fail with `Received string: "▸ Product (4) R"`.

   The pointer.spec.js line is in this PR and not a later one because main
   already carries that file: without it this change leaves CI red. It is one
   assertion corrected by the same rule as its sibling, not feature work.

3. `el.tiles.hidden` was a no-op, removed with its false comment. `#tiles`
   declares `display: flex`, an author-origin rule that beats the UA sheet's
   `[hidden] { display: none }`, so the guard never hid anything. Worth
   knowing before restoring it: `#tiles` being ALWAYS laid out is why its
   height never changes and it stays out of the ResizeObserver feedback path.

Also `playwright.config.js` takes IMPROVISE_HARNESS_PORT (default 8099, so CI
is unchanged). The hard-coded port plus `reuseExistingServer` let Playwright
silently ADOPT a server left by another worktree and test THAT tree. It fired
in both directions in one session: a false negative (a fix reported absent)
and — the dangerous half — a false POSITIVE, 5 passed against a tree verified
to contain no #tiles at all. A failure sends you looking; a false pass prompts
nobody.

The evidence prose records all of it, plus the ResizeObserver acyclicity proof
that retires "argued, not tested" there: frameCost.cols is row header LEVELS
(independent of projected rows) and frameCost.rows derives from the column
window, so cols -> rows and nothing depends on rows. No cycle to oscillate.

Gates: 1359 Rust tests, rustfmt, comment-run and evidence linters, 21/21
Chromium harness (on a private port, so not another checkout's tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:03:14 -07:00

498 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
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");
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
function socketUrl(peer) {
const secure = location.protocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
return `${secure ? "wss" : "ws"}://${peer}`;
}
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
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"),
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();
// `#status` carries two independent facts, composed rather than overwritten:
// a repaint must not 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();
}
// 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 };
/** The extent this window can show, in whole DATA cells. */
function extent() {
const box = el.grid.getBoundingClientRect();
return {
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
};
}
function sendViewport() {
const { rows, cols } = extent();
// `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));
}
// A peer that never answers the handshake. Deliberately about the *first
// frame*, not either peer's own failure modes — context/code/clients.md.
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, so the
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
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 console alone and the grid just stopped.
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 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;
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".
socket.addEventListener("error", () => setShellFailure(unreachable));
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
// route through `setShellFailure`, which disarms the watchdog — clients.md.
socket.addEventListener("close", () =>
setShellFailure(opened ? "disconnected" : unreachable),
);
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 `help::hint_line`. The page spells no keys
// itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
paintHelp(model);
modelStatus = model.status;
paintStatus();
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);
paintPanel(el.formulas, model.formulas, formulaRow);
// What the frame costs, so the NEXT viewport announcement is honest.
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
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 model in which every frame decision is already
* made (improvise-anr9). The page decides nothing here: 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 table = document.createElement("table");
const head = table.createTHead();
const corner = () => {
const th = document.createElement("th");
th.colSpan = model.row_levels;
return th;
};
// The column-group line, when there is one. An empty `col_groups` means
// this grid has no column groups at all -- not that every group is blank.
if (model.col_groups.length > 0) {
const groups = head.insertRow();
groups.appendChild(corner());
for (const group of model.col_groups) {
const th = document.createElement("th");
th.colSpan = group.span;
// `label` is null for a run in no group; an empty STRING is a legal group
// name, so this is a null check and not a truthiness test.
th.textContent =
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
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 model.col_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();
// The cell coordinates a click sends are DATA row indices and a group header
// is not a data row, so this counts rather than trusting `model.rows`.
let r = -1;
for (const row of model.rows) {
const tr = body.insertRow();
if (row.kind === "group") {
const th = document.createElement("th");
th.colSpan = model.row_levels + model.col_levels[0].length;
th.textContent = indicator(row.collapsed) + " " + row.label;
th.classList.add("group");
tr.appendChild(th);
continue;
}
r += 1;
// `r` is one binding shared by every closure below, so a click handler
// that captured it would fire with the LAST row's index. Snapshot it.
const dataRow = r;
for (const label of row.labels) {
const header = document.createElement("th");
header.scope = "row";
header.textContent = label;
tr.appendChild(header);
}
row.cells.forEach((cell, c) => {
const td = tr.insertCell();
// A keyboard edit is open when `model.editing` is a string, and it holds
// what has been typed. Both facts are decided in Rust.
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.
// Drill is not on the wire yet, so an aggregate is not offered.
td.classList.toggle("editable", cell.editable);
// 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(dataRow, c);
if (frame) transport.send(frame);
if (cell.editable) edit(td, dataRow, c, cell.edit_text);
else repaint();
});
});
}
return table;
}
/** The collapse glyph. `collapsed` is the fact, decided by the view and
* carried on the frame; only this spelling is the page's (gotcha 26). */
function indicator(collapsed) {
return collapsed ? "\u25b6" : "\u25bc";
}
/** 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) {
host.hidden = rows.length === 0;
const list = document.createElement("ul");
for (const row of rows) list.appendChild(build(row));
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
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);