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
+66 -2
View File
@@ -51,11 +51,30 @@ export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_P
* model, never predicted. Inline group ROWS are deliberately not counted. */
export function frameCostOf(model) {
return {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
// The totals strip is chrome on the same terms as a header level: always
// there when there are totals, and never a data row (improvise-p7lh).
rows:
model.col_levels.length +
(model.col_groups.length > 0 ? 1 : 0) +
(model.totals.length > 0 ? 1 : 0),
cols: model.row_levels,
};
}
/** The page bar: one entry per category fixing which records are visible. The
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
export function pageEntries(model) {
return model.page_coords.map((axis) => ({
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
// exactly as `indicator`'s is.
text: `${axis.cat} = ${axis.selected}`,
// `cycle` is null for a Filter axis, which constrains without being
// navigable: there is no position to show and no key that would move it.
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
navigable: Boolean(axis.cycle),
}));
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive
@@ -109,7 +128,52 @@ export function gridPlan(model) {
};
});
return { cornerSpan, groups, levels: model.col_levels, body };
// An EMPTY strip means this grid has no totals — not a row of blanks, and
// not "not caught up yet" (gotcha 33). So `null`, not an empty row.
const totals =
model.totals.length === 0
? null
: {
span: cornerSpan,
label: "Total",
cells: model.totals.map((cell) => ({
text: cell.text,
classes: { numeric: cell.numeric, error: cell.error },
})),
};
return { cornerSpan, groups, levels: model.col_levels, body, totals };
}
/** What a data cell carries on its node, so one delegated handler can read
* back the answers `gridPlan` already made (improvise-b8ot, clients.md). */
export function cellAttrs(cell) {
return { row: String(cell.row), col: String(cell.col), edit: cell.editText };
}
/** …and the inverse: what a delegated mousedown MEANS. `null` where the event
* did not land on a data cell, which is every header and the corner. */
export function cellAction(dataset, editable) {
const row = whole(dataset.row);
const col = whole(dataset.col);
// A node that carries no coordinates is not a cell: every header and the
// corner reach here, and none of them means anything.
if (row === null || col === null) return null;
return { row, col, editable, editText: dataset.edit ?? "" };
}
/** The same, for the surfaces whose rows are named by one index: the panels,
* the tile bar and the category tree. `null` where there is no row. */
export function indexAction(dataset) {
return whole(dataset.index);
}
/** A non-negative whole number off an attribute, or `null`. An ABSENT
* attribute and the string `""` are both "no index", and `Number` says 0. */
function whole(text) {
if (typeof text !== "string" || text.trim() === "") return null;
const value = Number(text);
return Number.isInteger(value) && value >= 0 ? value : null;
}
/** What the worker just posted. A protocol frame is a STRING and a shell