Commit Graph

9 Commits

Author SHA1 Message Date
Edward Langley 301a74821a 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>
2026-09-19 22:43:47 -07:00
Edward Langley cf086f8c04 Merge: one keymap table, shared by the TUI and the browser (improvise-bpq2 successor)
The browser's hand-written key classifier was a second spelling of
default_keymaps(), held honest by a lockstep test over a hand-written list --
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)'s
exact shape. So the TABLE moved to improvise-protocol and improvise-command
keeps only what needs a CmdRegistry. Evidence is binding_dump_diff, not the
suite: 4021 resolved (mode, key, modifier) lookups over sixteen modes,
BYTE-IDENTICAL, with the dump kept as an opt-in test and proved
discriminating by renaming one binding.

The boundary that did NOT move is the better one: key -> intent is shared,
intent -> Command stays client-side, and the named list of what still needs
the host is now a list of COMMAND NAMES rather than keys -- so a rebinding no
longer invalidates it.

AND A LOCKSTEP TEST FOUND A LIVE BUG, BY A COMMA. CellDisplay::text has been
through format_f64; App::display_value_at_cursor has not. Seeding an editor
from the drawn text and committing turns a stored 1200 into the string
'1,200' -- display rounding fed back into the model, which design-principles
4 forbids outright. CellDisplay::edit_text puts the unformatted value on the
wire, and it repairs web/app.js's click-to-edit, which had been seeding its
input from td.textContent since it was written. That bug is on the live page
today for any value with a thousands separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:36:47 -07:00
Edward Langley 59cc6720b5 feat(browser): the page draws the mode's hint instead of spelling keys
`?` now enters `Help` — the keymap says so and this client resolves
`enter-mode` — but the page has no help overlay: `src/ui/help.rs` is 600
lines of ratatui and nothing shared carries its content. Landing the key
without this would have swapped the mode label to HELP over an unchanged
grid, which looks broken rather than unfinished.

`AppMode::hint_text()` already exists in the protocol crate and is what the
TUI's footer draws, so `RenderModel::hint` relays it. Every mode now states
its own keys in the browser, the page spells none of them, and the modes
this client can enter but not yet draw say what they are. A real help
overlay needs the content moved into the shared layer first, which is the
same move design-principles §5 asks for and is filed rather than faked.

`web/*` is unrun; `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:35:54 -07:00
Edward Langley e13635638c feat(browser): the browser reads the TUI's keymap, and i opens an editor
Follows the keymap move. `keys.rs`'s hand-written per-mode classifier is
gone; `resolve` now looks the key up in `Keymaps::default_keymaps()` — the
same table the terminal dispatches against — and what is left here is the
narrow residue: turning a bound command NAME into a `Command`, for the
subset a client holding no `CmdContext` can compose.

That is a better seam than the one it replaces. The drift risk was "which
key does what": 170 bindings, no direct test, invisible when wrong. It is
now "which command names can I execute", which is a list — and
`the_normal_mode_keys_that_still_need_the_host_are_these` becomes
`the_command_names_that_still_need_the_host_are_these` to match, so a
rebinding no longer invalidates it and a new binding for an
already-executable command reaches the browser with no change here.

Several gaps close by sharing rather than by code. Search-mode typing
resolves locally for the first time (the table binds `append-char
["search"]`, and "search" is `ViewState::search_query` — gotcha 11's pair is
mirrored and nothing else is). `Esc` resolves as the `Binding::Sequence` it
is, all-or-nothing. A modified character in a text-entry mode now types,
because the `AnyChar` fallback being reached for is the host's own — the old
"unmodified only" rule was a hedge against a table this client no longer
guesses at. And a key nothing binds is `Ignored` rather than forwarded into
`Host::handle`'s empty vec.

What is newly executable, and so what a user gets back: `edit-or-drill`
(`i`/`a`), `commit-cell-edit`/`commit-and-advance-right` (Enter/Tab in
`Editing`), `enter-mode` (`?`/F1, Esc), `enter-tile-select` (`T`),
`toggle-panel-visibility` (Ctrl-F/C/V), `toggle-panel-and-focus` (F/C/V) and
`transpose` (`t`) — four of which an independent browser smoke test had just
found inert.

`i` on an **aggregate** does not go upstream. Every upstream key is a black
hole, and a keypress that visibly does nothing is the worst of the three
answers, so it answers with a `SetStatus` naming drill. A view-half command
is folded into the asker's own mirror and broadcast to nobody, so that
message reaches this user and no one else.

The write itself is not composed twice: the keyboard commit calls
`socket::typed_cell_command`, the function click-to-edit already used, so
Enter and a click cannot disagree about what `""` or `"42"` means.

## `CellDisplay::edit_text`, found by lockstep

`tests/lockstep.rs` drove `i` through the host and the client over one grid
and they disagreed by a comma: the host seeds its edit buffer from
`App::display_value_at_cursor` (the stored `1200`) while the client had only
`CellDisplay::text`, which has been through `format_f64` (`1,200`).
Committing that stores the *string* `"1,200"` and the number is gone —
display rounding fed back into the model, which design-principles §4 forbids
outright.

So the projection is enriched rather than the client taught to un-format:
`CellDisplay::edit_text` is `display_value_of` on the wire, and it repairs
**click**-to-edit too, which had been seeding its `<input>` from the drawn
text all along.

## Making the edit visible

`RenderModel::editing` carries what the user has typed so far, or `None`
when no edit is open. The decision — is this an editing mode, and which
buffer does it type into — is made in Rust; the page draws the string in the
selected cell. Without it the mode indicator would read INSERT while every
keystroke vanished into a buffer nobody painted.

`web/app.js` and `web/style.css` change accordingly, and are **unrun**: there
is no browser here (gotcha 48). `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:35:14 -07:00
Edward Langley 94d26d8705 fix(web): default the socket to TLS, and cache-bust the deployed bundles
?ws= now takes a host:port and the page supplies the scheme: wss:// when it
is itself served over https, ws:// when over http. An explicit ws:// from an
https page is UPGRADED rather than passed through, because an https origin
refuses it as mixed content -- so passing it through can only produce a
failure that reads like 'the server is not running'. The scheme is not the
caller's to get wrong.

And the deploy now stamps ?v=<sha> onto both bundles. Pages serves everything
max-age=600 and the wasm-pack filenames carry no content hash, so for ten
minutes after a deploy a returning visitor runs the PREVIOUS build -- which
made a shipped fix look like it had not shipped, twice, during the first real
browser debugging session.

BOTH halves are stamped, because a fresh glue file does not pull a fresh
.wasm: the glue locates it with new URL('..._bg.wasm', import.meta.url), and
a URL built against a base does not inherit the base's query. Stamping only
the import specifier would refresh the wrapper and keep the stale binary --
exactly the failure this is here to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:17:08 -07:00
Edward Langley bf02472d89 fix(web): an async message listener does not serialize, and a watchdog must not overwrite a real error
Three findings from the review-only PR #13, which put 28 already-merged
commits in front of a second reader, plus two comments that had gone false.

worker.js — `self.onmessage` was `async` and awaited `handle()`, which awaits
`server.flush()`. An async listener does NOT serialize: the event loop may
start the next `message` while an earlier one is suspended at an await, so two
`on_message`/`flush` pairs interleave and a later frame's replies can be posted
before an earlier frame's. That breaks the one rule the file names first — a
`GridUpdate::Cells` that overtakes its labels is DROPPED by the client's fold,
not replayed (gotcha 33) — and the pre-start `pending` drain had the same race
twice over, since `server` was assigned before the loop that drained it, so a
message arriving mid-drain overtook the frames it was queued behind. Every
frame is now chained onto one tail promise, which also makes "arrived before
start() finished" an ordinary case of the same rule instead of a second path.

app.js — the handshake watchdog fires 8s after the handshake and writes "the
peer did not answer". A peer that failed LOUDLY has already said something more
precise (worker `error`, `messageerror`, socket `error`), and the watchdog then
overwrote it with the vaguer guess. Those three paths now disarm it:
`setShellFailure` clears the timer rather than merely pre-empting it. The
watchdog is for silence only, which is what it was always for.

README — the "In the browser" section still said the deployed page needs a
server and that the in-page worker "is not built yet". Both landed in the same
unreviewed batch (improvise-9x6, improvise-djm), so the README described the
published site as a client with nowhere to connect while index.html described
it correctly. Rewritten, and it now states the caveat the commits state: no
line of that JavaScript has ever run.

Two comments that had gone false with it, and for the same reason — a follow-up
commit updated one copy and not the others. `web/app.js`'s keyboard note said
key resolution was "text entry, and nothing else" and that upstream keys would
work "until improvise-bpq2 lands"; bpq2 landed, moved the grid, panel and tile
cursors to the client, and settled that the host must not know the keymap.
`Host::handle`'s `ClientFrame::Key` arm said the same thing; 43422d4 corrected
the worker's copy of that argument and missed this one.

NOT VERIFIED, and it cannot be here: there is no browser and no headless DOM in
this environment, so neither JavaScript change has been executed. Both are
argued rather than tested, which is why each carries its argument in the file.
`node --check` passes on both, the wasm bundles build, and the Rust gates are
unchanged: 1172 passing / 0 failed, clippy clean, fmt clean, actionlint clean,
wasm32 clean for all seven bundle crates.

Declined from the same review, with the reason on the thread: `Host::commands`
can apply a batch's earlier commands before rejecting a later one. The finding
is correct, the hole is real, and it is already stated on `ClientFrame::Commands`
and in `Host::commands` — closing it means the basis check moving from the
command to the batch, which is a design change with ADR 001 consequences and
not a patch to land beside three small ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 20:30:18 -07:00
Edward Langley 145d76e7da fix(web): the page records its window, or every cursor key vanishes
sendViewport called the associated Client.viewport, which builds the
handshake frame without RECORDING the window. A client learns its window
only from what it announced -- row_labels.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 the record the client cannot clamp, so every
cursor key goes upstream into Host::handle's empty vec and the arrow keys
silently do nothing.

Flagged by the improvise-bpq2 agent as the one line its work depended on and
which it could not make, web/ being another agent's ground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:36:26 -07:00
Edward Langley a550201fae feat(web): the page spawns the worker, opens a demo, and reads a key
Three pieces of the standalone deployment, all in web/.

improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.

improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).

One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.

improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.

Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:28:27 -07:00
Edward Langley c72832f542 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
2026-09-19 17:34:39 -07:00