feat(protocol): the grid's frame on the wire, as AxisFrame

`GridCache::row_labels`/`col_labels` were `Vec<String>` built by
`GridLayout::row_label`, which slash-joins a multi-level tuple — the exact
rendering `two_row_categories_produce_cross_product_labels` asserts must not
appear. So the protocol carried a form of the labels the TUI itself
considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely
that form: the browser showed `Food/Alice` where the TUI showed two header
columns, with no groups and no collapse indicators (gotcha 44).

Both fields become one `AxisFrame` per axis, carrying the five facts gotcha
44 enumerates: the categories (hence the header levels), the label *tuples*
and the inline group headers, the group a position sits in, and each group's
collapse state. `record_col` stays off — session-local, keys pending drill
edits, correct to keep off.

It is one type and not a `row_levels` beside `row_labels`, because two
answers to "what are the labels" is the disease (improvise-edp). The rules
that used to be written twice are methods on it and are now called from both
front ends: `shows_level` (repeat suppression, improvise-jjx's fix),
`group_of`, `body_from`, and `header_widths` — the row-header width rule,
which existed twice already, inline in `render_grid` and again in
`viewport::compute_row_header_width` (improvise-1125).

`GridWidget` loses its `&View`: nothing in it reads the view any more.

Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization
over a grid with row groups, column groups, a page axis, multi-level headers
and a total row — passes byte for byte, so the TUI is unchanged. The new
`tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and
the browser client through a `Snapshot` of the *same* cache, and compares
what each draws; re-introducing the slash-join on either side was injected
and watched to fail before the test was kept.

`tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is
deleted: it guarded the gap this closes, and a consolidation that keeps its
compatibility test is unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-19 22:43:47 -07:00
parent 97ea48957e
commit 301a74821a
25 changed files with 979 additions and 464 deletions
+64 -13
View File
@@ -118,7 +118,7 @@ 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
// from what it announced -- `row_frame.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
@@ -244,24 +244,69 @@ function repaint() {
el.categories.replaceChildren(categoryList(model));
}
/** 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().insertRow();
head.appendChild(document.createElement("th"));
for (const label of model.col_labels) {
const head = table.createTHead();
const corner = () => {
const th = document.createElement("th");
th.textContent = label;
head.appendChild(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;
th.textContent = group.label ? 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();
model.rows.forEach((row, r) => {
// The cell coordinates a click sends are DATA row indices, and a group
// header is not a data row -- so this counts rows rather than trusting the
// position in `model.rows`.
let r = -1;
for (const row of model.rows) {
const tr = body.insertRow();
const header = document.createElement("th");
header.scope = "row";
header.textContent = row.label;
tr.appendChild(header);
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;
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();
@@ -283,10 +328,16 @@ function gridTable(model) {
td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
});
});
}
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) {
+7
View File
@@ -126,6 +126,13 @@ th {
font-weight: 600;
}
/* A group header, on either axis. The TUI draws these green and bold; the
glyph is already in the text (`indicator` in app.js). */
th.group {
color: var(--accent, inherit);
font-weight: 700;
}
td.numeric {
text-align: right;
}