4 Commits

Author SHA1 Message Date
Edward Langley a93d668e5e 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
2026-09-21 19:21:57 -07:00
Edward Langley 628f43121d feat(web): the title bar is painted, a click survives an editor, and the twin door is gone
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with
one shape: a thing that looks done from every check except running it.

improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and
painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING
SEARCH over the page's source, so a field named by a function nothing calls reads
as painted, which is also how `model.minibuffer` shipped dead. So the guard is a
harness assertion that loads the page and reads `#doc-name`, proved by writing it
first (`element(s) not found`). The joining is `shell.js::documentTitle`, the
TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a
harness one, because this deployment persists on every accepted batch and clears
the flag in the same round trip, so the marker is transient. UNPAINTED is now
empty.

improvise-0x9u. From an EDITING state a cell click did nothing at all: the
mousedown moved focus, the input's blur ran `close`, `close` repainted,
`replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click`
was ever dispatched. The old spec pressed Escape between the two clicks, so it
only ever clicked from a non-editing page — this repo's characteristic defect.

Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the
first attempt failed the same assertion with the same symptom. Removing a focused
`<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside
itself and detached the `<td>` again. `close` therefore no longer repaints, the
`blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus
change happens at all — otherwise the default action focuses `#grid` after
`input.focus()` and blurs the new editor. `close` restores the DRAWN text, since
nothing repaints behind it any more.

improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept
for a reason `web/app.js` falsified in the same wave. It is one letter from
`set_viewport` and fails SILENTLY — identical frame, forgotten window, every
cursor key an upstream black hole. Deleted, along with the exported `init()` free
function that duplicated `new Client()`. The guard is not the deletion:
`wasm_surface.test.js` now reads the generated `.d.ts` and requires every
declared method to be one `app.js` calls or to be named in `UNCALLED` with its
reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts`
and watching it fail by name.

Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium
harness (was 23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:37:44 -07:00
Edward Langley 9863d96eef fix(harness): restore the close-routing guard PR #61 dropped, on a faked clock
Adversarial review of PR #61 found one real coverage loss, and it is the
defect class that PR's own body names: an assertion that survives the deletion
of the thing it was about. `shell.spec.js` lost "a socket that opened and then
dropped reads as disconnected, and stays that way" and both MutationObserver
negative assertions. Reverting PR #21's routing half in `web/app.js` — the
socket `error` and `close` arms calling `setShell` instead of `fail` — left the
node suite 26/26 and Playwright 13/13 green.

`shell.test.js` cannot cover it, and why is the whole boundary argument:
"a loud failure DISARMS the watchdog" tests `createShell` in isolation and
cannot see whether `app.js` ROUTES the close event through `fail()`. That
routing is the half gotcha 48 says keeps getting lost; this was the third time,
after two earlier mis-confirmations that simulated the `opened` flag and not
the routing.

Both tests are back under their original names, so the two `####` headings in
context/test-evidence/clients.md name live tests again. They cost ~75ms each
rather than ~9s, because `page.clock` fakes the PAGE's timers: the page, the
socket, the listeners and the `setTimeout` call are all real and only the
passage of time is simulated. That is not a weakening, and the injections are
the proof in both directions — if `runFor` did not advance the page's timers, a
broken routing could not surface the watchdog's message and they would pass:

  - both arms reverted to `setShell`  -> "…and stays that way" fails
  - close arm only                    -> "…and stays that way" fails
  - close arm drops the `opened` flag -> "…the watchdog stays out of it" fails

Three injections, three outcomes, none of them the unit test.

The helper guards its own premise, because it turned out not to: `runFor`
without `clock.install()` does nothing AND throws nothing, so a test that
forgot the install asserted "the watchdog never spoke" against a watchdog that
never ran — 6/6 green. `outlastTheWatchdog` now requires the page's own
`Date.now()` to have moved, and that omission fails by name.

Also: `render_surface.test.js` matched `model.<field>` over source INCLUDING
comments, so un-painting a field and mentioning it in a comment passed —
demonstrated against the pre-fix version, then fixed and re-demonstrated.

And the PR's speed claim is corrected where it is documented. The eleven
deleted per-widget tests cost 3.3s between them against 3.4s for the three
contract tests replacing them: they were free. Essentially all of 36.9s -> 15.5s
was two real-timer waits, one of them this guard. The wait was the cost, not
the test.

Filed, not fixed: improvise-igxp (the help overlay's rows are asserted as
present, never as content — inherited verbatim from the test contract.spec.js
replaced) and improvise-4ktt (the js job's bundle-freedom is a shell-glob
convention, not a gate). The `####`-vs-`###` blind spot in --check-evidence,
which is why the dangling heading survived the gate, is a comment on
improvise-r0qa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:59:06 -07:00
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

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