feat(browser): paint the page bar and the totals strip, and delegate the pointer

improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.

`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).

`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.

improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.

All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.

Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-21 19:21:57 -07:00
parent 523d57197c
commit a93d668e5e
11 changed files with 819 additions and 27 deletions
+100 -22
View File
@@ -6,15 +6,19 @@ import {
COL_PX,
HANDSHAKE_TIMEOUT_MS,
ROW_PX,
cellAction,
cellAttrs,
classifyWorkerMessage,
createShell,
documentTitle,
extentOf,
frameCostOf,
gridPlan,
indexAction,
indicator,
isFatal,
noticeText,
pageEntries,
socketUrl,
} from "./shell.js";
@@ -33,6 +37,7 @@ const el = {
formulas: document.getElementById("formulas"),
views: document.getElementById("views"),
tiles: document.getElementById("tiles"),
pages: document.getElementById("pages"),
mode: document.getElementById("mode"),
minibuffer: document.getElementById("minibuffer"),
hint: document.getElementById("hint"),
@@ -211,16 +216,15 @@ function repaint() {
model.doc_dirty,
model.doc_stored_as,
);
paintPages(model);
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),
);
paintPanel(el.views, model.views, viewRow);
paintPanel(el.formulas, model.formulas, formulaRow);
// The selection must be on screen. `#grid` scrolls on its own — ROW_PX and
// COL_PX under-estimate and group rows are uncharged — so a selection the
@@ -286,6 +290,27 @@ function paintHelp(model) {
el.help.replaceChildren(tabs, body);
}
/** The page bar: which item each fixed category is fixed to. Without it a
* grid filtered to Q1 says nothing at all, and `[`/`]` move it in silence
* (improvise-p7lh). An empty list is what says there are none. */
function paintPages(model) {
const entries = pageEntries(model);
el.pages.hidden = entries.length === 0;
el.pages.replaceChildren(
...entries.map((entry) => {
const span = document.createElement("span");
span.className = "page-axis";
// Only a Page axis is navigable; a Filter one constrains without being
// something `[`/`]` can move (gotcha 27).
span.classList.toggle("navigable", entry.navigable);
span.textContent = entry.position
? `${entry.text} (${entry.position})`
: entry.text;
return span;
}),
);
}
/** 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
@@ -352,20 +377,29 @@ function gridTable(model) {
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.
// On MOUSEDOWN, and defaulted away: from an editing state the input's
// blur repainted first and this <td> was gone before `click` could fire
// (improvise-0x9u). See context/code/clients.md.
td.addEventListener("mousedown", (event) => {
event.preventDefault();
if (editing) editing.close(false);
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();
});
// The plan's own answers, CARRIED rather than re-derived: `row` counts
// data rows, which walking the table back up would get wrong (b8ot).
Object.assign(td.dataset, cellAttrs(cell));
}
}
// The totals strip, PROJECTED: the sum runs over every row including the
// ones this viewport scrolled past, so nothing here adds anything up.
if (plan.totals) {
const foot = table.createTFoot();
const tr = foot.insertRow();
const label = document.createElement("th");
label.scope = "row";
label.colSpan = plan.totals.span;
label.textContent = plan.totals.label;
tr.appendChild(label);
for (const cell of plan.totals.cells) {
const td = tr.insertCell();
td.textContent = cell.text;
for (const [name, on] of Object.entries(cell.classes)) {
td.classList.toggle(name, on);
}
td.classList.add("total");
}
}
return table;
@@ -411,6 +445,8 @@ function edit(td, row, col, seed) {
};
editing = { row, col, close };
// These two stay DIRECT while the five surfaces above are delegated: one
// transient node, one at a time, and `blur` does not bubble (improvise-b8ot).
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") dismiss(true);
else if (e.key === "Escape") dismiss(false);
@@ -428,12 +464,12 @@ function edit(td, row, col, seed) {
/** 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) {
function paintPanel(host, rows, build) {
host.hidden = rows.length === 0;
const list = document.createElement("ul");
rows.forEach((row, index) => {
const li = build(row);
li.addEventListener("click", () => send(clicked(index)));
li.dataset.index = String(index);
list.appendChild(li);
});
host.replaceChildren(list);
@@ -473,7 +509,7 @@ function paintTiles(model) {
span.className = "tile";
span.classList.toggle("selected", tile.selected);
span.textContent = `${tile.category} ${tile.axis}`;
span.addEventListener("click", () => send(client.click_tile(index)));
span.dataset.index = String(index);
return span;
});
el.tiles.replaceChildren(...tiles);
@@ -490,7 +526,7 @@ function categoryList(model) {
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)));
li.dataset.index = String(index);
list.appendChild(li);
});
return list;
@@ -503,6 +539,48 @@ function send(frame) {
repaint();
}
// The pointer. ONE handler per surface, on a root OUTSIDE what is redrawn:
// `replaceChildren` replaces an element's children, so these outlive every
// repaint (user ruling, improvise-b8ot). See context/code/clients.md.
/** A surface whose rows are named by one index. */
function delegateIndex(root, selector, act) {
root.addEventListener("click", (event) => {
const node = event.target.closest(selector);
if (!node || !root.contains(node)) return;
const index = indexAction(node.dataset);
if (index !== null) send(act(index));
});
}
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
delegateIndex(el.tiles, "span[data-index]", (i) => client.click_tile(i));
// MOUSEDOWN, not click, and deliberately: from an editing state the input's
// blur repaints first and the <td> is gone before `click` could fire
// (improvise-0x9u). Delegation does not change that — the target is detached.
el.grid.addEventListener("mousedown", (event) => {
const td = event.target.closest("td[data-row]");
// Defaulted away only once this really is a cell: a header had no listener
// before, so preventing its default would be a new behaviour.
if (!td || !el.grid.contains(td)) return;
// `editable` is the class the plan already set; the coordinates are the
// attributes it already wrote. Nothing here walks the table.
const action = cellAction(td.dataset, td.classList.contains("editable"));
if (!action) return;
event.preventDefault();
// The live editor closes FIRST, before the click is composed. Order matters.
if (editing) editing.close(false);
const frame = client.click_cell(action.row, action.col);
if (frame) transport.send(frame);
// Every cell moves the selection; an editable one also opens its editor.
// NOT repainted in that branch: a repaint would take the <input> away.
if (action.editable) edit(td, action.row, action.col, action.editText);
else 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).