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:
@@ -0,0 +1,2 @@
|
||||
# wasm-pack output; built, never committed.
|
||||
pkg/
|
||||
+167
@@ -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
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user