# Clients: the browser client, the two hosts, and the harness The arguments that used to live in the comment headers of `improvise-wasm-client`, `improvise-ws-server`, `improvise-worker-server`, `web/` and `harness/`. Written for a reader with the code open. Bug narratives keyed by test name are in `context/test-evidence/clients.md`. Repo-map gotchas 42, 44, 45, 46, 47 and 48 cover the same ground from the "where" side; this file is the "why", at the density the comments had. --- ## `improvise-worker-server/src/worker.rs` — the message loop `Worker` is the whole browser host minus the two things a browser has to supply — `self.onmessage`/`self.postMessage`, and OPFS. Both are in `crate::bindings`, which is `cfg(target_arch = "wasm32")` and has no decisions in it. Everything that could be *wrong* about what the main thread is told is in `worker.rs`, where `cargo test` can drive it. ### It is the socket's `Host`, not a second one `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec`. Nothing in it is a socket, and improvise-9x6 is what made that load-bearing rather than tidy: the worker needs the same session loop and cannot depend on `improvise-ws-server`, which is tokio and tungstenite. So `Host` moved down to `improvise_command::ui::host` and **both deployments name the one type**. Re-implementing it here would have been a mirror, and improvise-xks6 had just finished deleting the last one: a second copy of a decision layer drifts, and the drift is invisible until a client sees it. What the worker adds on top is only what a `MessagePort` differs in: - **One client, by construction.** A `MessagePort` is point to point, so the `SessionId` is not an address the transport has to carry — it is a field. `Addressed` is still honoured rather than assumed away (`Worker::frames_for`), because a `SharedWorker` would make it matter again and the honest filter costs one line. `Host::relay` addresses a broadcast to every session it knows, and with one session that is always this one — a loop that assumed it away would be silently wrong at that point rather than obviously incomplete. - **A handshake with no close.** `ClientFrame::Viewport` must come first, for the reason it must on the socket: a `Snapshot` seeds the *visible* cells, so one built at extent zero is an empty grid. A socket enforces that by closing the connection; a worker has nothing to close, so a frame that arrives before the handshake is **dropped**, which is the same outcome without a mechanism that does not exist here. ### `ClientFrame::Key` is answered with nothing, and that is not laziness A worker holds the authoritative `App`, so `ui::reduce::reduce_key` really is callable here where it is not on a socket. Calling it would still be wrong, and the reason is not about transports at all: **nothing in the projection stream carries the host's own `ViewState`.** A client's view slice moves only by folding `Command`s, and a keypress resolved against the `App` moves the *`App`'s* slice — its mode, its cursor, its minibuffer — with no `Command` produced that any client could fold. `ClientSession::project` emits document projections, a grid diff and a basis; none of the three is a mode change. So a half-wired `Key` arm does not under-deliver, it **diverges**: the host would believe it is in `AppMode::Editing` while the client draws Normal, and `ClientSession::viewport()` reads the *mirror's* cursor, so the grid would not even scroll to follow. improvise-bpq2 answered it, and **not** by deriving `Command`s from a `ViewState` delta. The user's ruling is that a keymap is UI and the server must not know it, so resolution moves to the *client*: a key the client can answer arrives here already resolved, as a `ClientFrame::Commands` batch it has folded itself, and this arm's emptiness stops being a gap and becomes the statement. What remains upstream is the set of bindings a client still cannot compose — prefix keys, anything naming a row's category or a group, anything touching `Storage` — and those are closed by enriching the projection, one at a time, not by wiring this arm up. The list lives in `keys.rs` (see below). ### Persistence is the worker's, whether or not anybody asks There is no `ModelCommand::Save`, and that is not an oversight: `effect::Save` is `EffectKind::ViewOnly` (it writes the document to *this host's* file path and clears *this host's* dirty flag; it mutates no document state), so the derivation that produces `ModelCommand` from the model effects cannot reach it (`decision(improvise-cqi, command_set_derived_not_invented)`). Saving was therefore unreachable from any client on any transport until improvise-368h added `ClientFrame::Save` — a *request to the host*, framed rather than commanded precisely because it mutates nothing shared. **A browser still never sends it**, and that is the right answer rather than a gap: there is no file the user owns and no Ctrl-S semantics to honour, so asking would be the wrong question — the document *is* the OPFS entry, and a page that loses an edit because nobody pressed a key has simply lost it. So the worker persists on its own behalf: `Worker::persist` runs the one existing `Save` effect through `App::apply_effects`, so effects remain the only mutation site and the save path is not duplicated. A worker with no `file_path` silently does nothing, which is what `Save` already does; a failed save leaves its message in the status and aborts the batch. **The trigger is an observation of the document, not a table of command variants.** It used to be the latter, and the reason was honest: a wire `ModelCommand::SetCell` reached `effect::SetCell`, which did not raise `model_state.dirty` — that was a separate `MarkDirty` effect only the key path composed — so "save if dirty" was a trigger that never fired (improvise-gv9r). With dirtiness raised by `App::apply_effects` off the same `EffectKind` the `Dirty` is, the flag *is* the observation, and asking it is better than enumerating variants for the same reason the cursor reset watches `cursor_epoch` rather than matching on `CycleAxis`, and the rebase watches `document_generation` rather than matching on `LoadModel`: a table of variants is a second description of which commands change the document, free to disagree with the first. It is also strictly tighter — a refused batch changes nothing, so it raises nothing, and the "was anything rejected" special case that used to guard this went with it. One cost remains and is stated rather than hidden: `Storage` is whole-document by construction, so OPFS rewrites the file per accepted model command — correct, not yet cheap. And a failed write leaves the flag raised, so the next frame retries: right for a transient failure, and harmless for a permanent one (a worker with no `file_path`) because the only thing a failed `Save` produces is a status line, and nothing in a worker renders one. The one frame `on_frame` does **not** auto-persist after is `ClientFrame::Save`: a client that asked to save has already been answered, and a failed write leaves `dirty` set — so retrying would repeat the I/O and, on a transient failure, tell the client `SaveFailed` about a document that is now on disk. The automatic write exists for edits *nobody asked* to save. So this is the browser's own answer, and `ClientFrame::Save` is the other one — for a socket client editing a TUI-hosted document, where there *is* a file somebody owns. ### Ordering `Worker::on_frame` returns the frames for one inbound frame, in order, and a `ServerFrame::Commands` batch must reach the client **whole and in sequence**: a `GridUpdate::Cells` that overtakes the labels framing it is dropped, not replayed. That is a property of the fold, so a `postMessage` transport owes it exactly as a socket does — never coalesce two batches, never split one. ### Malformed frames are loud `Worker::on_message` returns `Err` on a frame that will not parse rather than swallowing it. A worker that silently ignores a malformed frame leaves the client waiting forever for an answer that is never coming. `Worker` is not `Debug`, for the reason `Host` is not: it owns the whole document. --- ## `improvise-ws-server` — the socket host Three layers, and the split is the point — the frames are a fourth thing this crate speaks but does not own: - **the frames are not here.** `ClientFrame`/`ServerFrame` are `improvise-protocol`'s, because the same envelopes frame the worker deployment and a browser bundle cannot depend on this crate (improvise-xks6). This crate speaks them and owns none of them. - **`host`** — the session loop: one authoritative `App`, one `Sessions`, and a `handle(id, msg) -> Vec` that is where every decision is made and every test is pointed. Not here either, and for the same reason the frames are not: the browser worker hosts the same loop over a `MessagePort` and cannot depend on tokio. `src/host.rs` is a re-export of `improvise_command::ui::host`, kept rather than deleted so `improvise_ws_server::host::Host` still resolves. `Host` knowing nothing about sockets was always true — the file used to say "a websocket is awkward to test, so nothing that decides anything lives in one" — and improvise-9x6 made it load-bearing. A second copy is a mirror, and a mirror drifts; that is the same answer improvise-xks6 gave for the frames one ticket earlier. - **`registry`** — the documents: a map of `Host`s keyed by document id, the lifecycle rules, and the one place a per-document `SessionId` becomes a server-wide address. Socket-free. It stayed on the server because a worker hosts exactly one document. - **`transport`** — tokio and tungstenite: framing, JSON, the handshake rule, and reading the requested document off the URL path. No decisions about what a client is told. Native-only: tokio's networking does not build for `wasm32-unknown-unknown`, and it should not. ### The registry's grain Sessions editing *independent* documents are trivially non-conflicting, which is why ADR 001's basis tiers — `document_generation`, `layout_version`, `record_versions` — live on the `App` and scope per document **for free**. A registry that let two documents share any of that would throw the free scoping away, so the registry owns no state of its own beyond the map: there is nothing here for two documents to share. **A `SessionId` is not unique server-wide.** `Sessions` mints ids from a counter it owns, so every document's first client is `SessionId(0)`. The server-wide address is therefore the **pair**, `ClientId`, and `Registry::route` is the one place a `Host`'s `SessionId` acquires the document it came out of. That is the whole cross-document isolation argument, and it is structural rather than checked: a `Host` can only name its own sessions, and the only tag `route` can apply is the document whose `Host` produced the frames. In particular an `Applied::Rebased` fans a fresh `Snapshot` out to every client of *that* document and cannot reach further, because the ids it returns are meaningless anywhere else. ### Lifecycle **Open** happens at startup, from the documents the host names. A client cannot ask the server to open a path it was not told about, and that is not an oversight: deciding which paths a client may open is *access control*, which improvise-j31x puts explicitly out of scope. Serving only what was named leaves the question unanswered rather than answering it badly. A stem collision is fatal rather than resolved: two files with the same stem are two documents a client could not tell apart, and silently serving one is worse than saying so. **Close does not happen when the last session leaves.** A document stays resident for the life of the server. Evicting it was considered and rejected twice over: - Unsaved edits would vanish. The server has no autosave obligation, so a reconnect would silently roll the document back to what is on disk. - It would rewind the basis tiers. `document_generation` and `record_versions` start at zero in a fresh `App`, so a client that reconnected holding a *stale* basis would have it accepted as current — a correctness hole in the exact mechanism ADR 001 is building, paid for with a memory saving nobody asked for. An idle document therefore costs its `App`, and `Registry::is_idle` exists so a future eviction policy has somewhere to ask the question from — nothing calls it to evict, because nothing evicts. ### The transport's two rules **A client names its document in the URL** (improvise-j31x): `ws://host:port/quarterly` asks for `quarterly`, and `ws://host:port/` asks for whichever one there is when there is exactly one. The path was chosen over a new `ClientFrame` variant for two reasons: a document is a *resource*, which is what a URL path is for, and `ClientFrame` stays exactly as it was, so every client written against the single-document server keeps working unchanged. Which document a name resolves to is `Registry::resolve`'s decision, not the transport's; all that happens there is `path -> Option<&str>`. It is not a path walker — everything after the leading slashes is the name. The other rule is that **the first frame of a connection reports the extent**, because the bootstrap `Snapshot` cannot be built without it. A refused connection is closed with a websocket close frame carrying the reason. No `ServerFrame::Error` was added: the frame vocabulary is derived from the mutations that need it, and "this connection is not happening" is a fact about the connection, which the protocol already has a frame for. A `Registry` is not `Send` (a `Host`'s `App` owns a `Box`), and must not be shared anyway: it holds the authoritative documents, and one owner is the point. So it sits in a single task on a `LocalSet` and every connection talks to it over a channel. That is also what serializes the clients — commands arrive in one order, and the projections each client gets are produced in that order, which matters because a `GridUpdate::Cells` that overtakes its labels is dropped. One task for the whole registry rather than one per document is a *throughput* choice and nothing more: the runtime is single-threaded either way, and no state is shared between documents by sharing a queue. **No auth, no TLS, localhost only.** A deliberate MVP limit (improvise-q08), not an oversight: anyone who can open a socket to this port can rewrite every document it serves, so it binds a loopback address by default and `serve` says so. `--bind` exists so the limit is a choice you can see rather than one buried in the code. ADR 001 owns what multi-writer safety becomes. ### Origin `src/origin.rs` is the handshake's one access control, and the reason it cannot be delegated: **a websocket handshake is exempt from the same-origin policy**. There is no CORS preflight, so any page a user visits can open a socket to the endpoint *from that user's own address*, and read and edit every document the server holds — cross-site websocket hijacking (improvise-kc7n). An address check cannot see it, because the request genuinely comes from an allowed address; an IP allow-list, a private network and loopback binding all answer "may this **client** reach the endpoint", and the attacker's answer is honestly yes. Only `Origin` says what is **driving** the client, which is `doctrine(who_may_connect_is_two_questions)`. **Loopback is the least protected case, not the safest**: there is no proxy in front of it to check anything, and loopback is not a boundary a browser enforces, so while the server runs any page open in that browser can reach `ws://127.0.0.1:8787`. **The default allows loopback pages and nothing else**, because a default that fails open is worth nothing and a default that breaks the shipped recipe is a different bug. The two shipped paths were the constraint. The local pairing recipe serves the page with `python3 -m http.server 8080 --directory web`, so its origin is `http://localhost:8080` while the socket is on another loopback port — loopback is therefore the *default*, not a flag, or the one case worth optimising for would need configuring. The deployed Pages page sends `https://fiddlerwoaroof.github.io` and can only reach the server through a TLS proxy (`improvise-ws-server` speaks plain `ws://`), which is already a multi-step setup with an Origin allow-list in it; one `--allow-origin` is therefore an honest cost there and the project's own deployment origin is not hard-coded as a default. `OriginPolicy::permits` is the whole decision, and the transport's `admitted` is the only caller. **An absent `Origin` is allowed, decided rather than defaulted.** Every browser sends one on a handshake, unconditionally — unlike `fetch`, there is no no-`Origin` mode to fall into — so an absent one is a non-browser client: curl, a native client, this crate's own `tests/socket.rs`. Refusing those would break the repo's own tests and every scripted client while buying nothing, since an attacker who can run a non-browser client already has direct network access, which is the question `--bind` and a proxy answer. The cost is stated rather than hidden: **a proxy that strips `Origin` turns this check off**, so the proxy's own Origin allow-list stays in the recipe as defence in depth. Two shapes state no origin a policy can answer for, and both are refused rather than read as absent: an `Origin` that is not valid text, and **two `Origin` headers**. `HeaderMap::get` is first-wins, and the deployment this README recommends puts a proxy in front of the socket — one that appends rather than sets the header leaves two, and answering from the first lets the second be anything at all. No browser sends two. **What this does not buy is authentication.** It answers "what page is driving this client", never "who is connecting"; anything that is not a browser page sends no `Origin` and is unaffected by any of it. improvise-kpnr is still open and is a different question. --- ## `improvise-protocol/src/resolve.rs` — one keypress → `Command`s ### It is not the browser's module any more (improvise-0yq8) `resolve`/`step` and `ClientState` were `improvise-wasm-client`'s, and nothing about either was a browser fact: both depend on `ViewState`, the protocol widget caches and the announced window, every one of which is already a protocol type. Being in the wasm crate meant a second front end could reach them only by depending on a `cdylib` built for a browser, so the move cost no new dependency and bought the rule design-principles §5 states — **a new front end needs exactly two things**: its own event → `KeyEvent` mapping, and painting. `BrowserKey` is the first of those two and is the only thing left in `improvise-wasm-client/src/keys.rs`; `ClientState` is now `improvise_protocol::client`, and `typed_cell_command` went with `step`, which calls it. What did **not** become trivial is `tests/lockstep.rs`. It drives `improvise_command::ui::reduce::reduce_key` — the host's `Cmd`/`Effect` path — against `resolve`, and those are still two derivations of the same answer, in two crates, one of which cannot be reached from a browser. Moving `resolve` changed which crate it is spelled in, not that it is a mirror, so `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` does not fire and the test stays. ### The keymap is not this module's It used to be. improvise-bpq2 gave this file a hand-written classifier that decided, per key and per mode, what a keypress meant — a second spelling of `default_keymaps()`, kept in step by a lockstep test over a hand-written list. The user's ruling is that the web front end uses the **same** keymap as the TUI, not a mirror of it, and `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says why: two definitions held honest by a compatibility test are one definition waiting to be made. So the table moved to `improvise_protocol::keymap` and this module reads it. Which key means what is now correct **by construction**: a rebinding in `default_keymaps()` reaches the browser with no change here, and a new binding for a command this module can already execute simply works. ### What is left here, and why it is the better boundary A lookup yields a command **name and arguments**. Turning that into something that runs needs a `CmdContext` — a `Model`, a `GridLayout`, an `App` — which this client does not have. So *key → intent* is shared and *intent → `Command`* is ours, for the subset we can compose. `step` is that subset, and it is the list that replaces the old list of keys. That is a list of names rather than of keys, which is strictly better in two ways: it is enumerable, and it does not go stale when a binding moves. What this client can execute, and what each one needed: - **Text entry** (`append-char`, `pop-char`, `clear-buffer`). The buffer is the binding's own argument now — no longer read off `AppMode::minibuffer()` and hoped to agree with what the host's keymap named. `TextEntry::new` derives one from the other, so the two were equal; they are now the *same string*. The pair `read_buffer`/`write_buffer` mirrors `cmd::core`'s, which are the only code on the host that knows a runtime-named buffer may not be an entry in `buffers` at all (improvise-gdl): the name `"search"` is `ViewState::search_query`. The shared keymap binds `append-char ["search"]` in `SearchMode`, so this client needs the same pair — and having it is what lets search typing resolve here for the first time. - **Mode entry** (`enter-mode`), decoded through `AppMode::from_name`, which moved next to `AppMode` for the same reason the keymap did. - **Grid, panel and tile cursors**, which needed `GridExtent` and the window this client announced. - **Entering an edit** (`edit-or-drill`), which needed ADR 002: the branch `EditOrDrill` takes on "is this cell aggregated" is exactly the `CellId` variant the server already projects. - **Committing one** (`commit-cell-edit`, `commit-and-advance-right`), which needed the record surrogate — again ADR 002 — and nothing else for the plain-grid case. ### The second tranche, and the three it could not reach (improvise-e24v) Fifteen more command names resolve here: `cycle-panel-focus`; the category panel's `cycle-axis-at-cursor`, `open-item-add-at-cursor`, `filter-to-item`, `toggle-cat-expand` and `delete-category-at-cursor`; the view panel's `switch-view-at-cursor`, `create-and-switch-view` and `delete-view-at-cursor`; tile select's `set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's `help-page-next`/`help-page-prev`; `commit-buffer` in all three of its kinds; and the two cell writes `clear-cell` and `paste`. Each is `Cmd`-for-`Cmd`, including the parts that look like noise: the status sentence a command writes beside its mutation is *part of the answer*, and a client that emitted the mutation alone would diverge on the status line and nowhere else — which is precisely the sort of divergence nobody notices. Two orderings are load-bearing and copied rather than rationalised. `CommitBuffer` writes its optimistic status **first** so the add's own failure message wins (improvise-6t3), and `TileAxisOp` writes the axis **first** and the status after. **Three of the tranche's own list turned out to need enrichment after all** (improvise-4bwa), and the reasons are worth more than the code: - `toggle-prune-empty` words its status off `View::prune_empty`, which is in the `Workbook`, in no cache, and not in `ViewState`. - `delete-formula-at-cursor` needs `(target, target_category)`; `FormulaCache` carries the formula's raw *text*, and recovering the two from it means reimplementing `parse_formula` on the client. - `view-back` was listed here too, and **improvise-cfly removed it**: once PR #115 put `Command::ViewBack` in the view half there was nothing left to enrich. See "The three improvise-e24v listed as free" below. **The lockstep harness these are tested through** (`both_ends` in `tests/lockstep.rs`) is worth two sentences, because two of its choices were wrong first. It compares the document as **`.improv` text**, not as `Debug`: `Debug` prints the symbol table's `HashMap`s, so two byte-identical documents print differently per process (gotcha 18), and the first version failed on nothing but hash order. And it applies the client's composed commands to a **second authoritative host** rather than eyeballing them, restating the basis against that host with `caught_up` — the claim was published by the first host, and the basis is the only thing that legitimately differs between two hosts holding the same document. **How the client claims, and why there is nothing here to keep in step.** A client must answer the same question the server does — which records does this `ModelCommand` write — to build its `Basis`. The first version of this answered it *again*, in two helpers (`structural`, claiming nothing, and `record_write`, claiming the cell's own record), and the adversarial review of PR #50 showed that nothing tested them: `lockstep.rs`'s `apply_on` restates the basis against the twin with `caught_up`, which re-derives it with the **server's** `records_named` and throws the client's claim away before anything is compared. Emptying `record_write`'s claim left the workspace green. The fix is not a test. `records_named` is pure — `&ModelCommand -> Vec`, no `App` — and `RecordHash` is an `improvise-core` type the protocol already re-exports, so it **moved down into `improvise-protocol`** beside the enum it reads, and `improvise-command` re-exports it. `resolve.rs` has one `model()` that claims `records_named(&cmd)`. The client's claim is now right by construction, and the guard is the one that was always there: `records_named` is an exhaustive match, so a new record-writing variant fails the build. What the old shape would have cost is worth recording, because it is not a compile error: `structural` accepted **any** variant and claimed `[]`, so a new record-writing command would have compiled clean on the client with the wrong answer, and the symptom is `Rejection::RecordUnstated` — the browser binding becomes a dead key, refused, with nothing failing. **`both_ends` compares every cursor**, not only the document, the mode and the status. Three of the resolvers' decisions *are* a cursor move and nothing else — `delete_view_at_cursor`'s step-back is the sharpest — so a comparison that stopped at the document would agree while the panel cursor diverged. ### Mirror the `Cmd` line for line, *including where siblings disagree* `doctrine(mirror_the_cmd_line_for_line)`. `Move` emits `SetSelected` even when it clamps and `MovePanelCursor` returns nothing when the cursor does not move; `MoveTileCursor` emits regardless, like `Move`. **Tidying that into one rule would make this a second derivation**, which is exactly what `tests/lockstep.rs` exists to rule out. Which widgets say nothing about a move that goes nowhere is not a rule of this module — it is whatever the `Cmd` being mirrored does, and the three differ. Two more places the mirroring is literal. `panel_cursor` clamps a cursor past the end *before* applying the delta, and derives the category panel's length from `flatten_cat_tree` — which depends on `expanded_cats`, per-client, which is why it is derived rather than sent and why the derivation lives in `improvise-protocol` where both ends reach it (improvise-edp). And `commit` mirrors `change_mode(exit_mode_for(edit_mode))`, then `EnterAdvance` — which **aborts the rest of the batch** at the bottom-right corner, so the re-entry is skipped and the user lands in normal mode — then `EnterEditAtCursor`, which seeds the `edit` buffer from the cell it landed on. The trailing `clear-buffer` the keymap welds to every commit then empties that buffer again. That ordering looks wrong and is exactly what the TUI does; mirroring it is the point. `GridMove` is a hand-spelled mirror of `navigation.rs`'s `MoveKind`, because the command crate is not on a browser's dependency list and never will be. The *keys* that reach it are no longer spelled twice. `scrolled_to` mirrors `navigation.rs::viewport_effects` over the window this client announced. ### The four outcomes `KeyOutcome::Local(Vec)` — resolved here; the caller folds them locally *and* sends them, as one frame and in this order (the asker's own fold is its own; the host forwards only to the others). A `Vec` because one keypress is one binding but several `Effect`s: a cursor move is `SetSelected` plus whichever scroll offsets follow, and a `Binding::Sequence` is several commands to begin with. **Never empty** — a keypress this client can answer with nothing is `Ignored`, and `KeyOutcome::of` is the one constructor so that contract is a property of the type's users rather than a rule each resolver remembers. `Request(Request)` — resolved here, but as a **request to the host** rather than as state this client can compose. Today the one member is `Request::Save`. `Upstream(KeyEvent)` — bound to a command this client cannot compose. `Ignored` — nothing bound, or what is bound resolves to no change. **Nothing bound is not "the host might know better"**: the host looks the key up in this same table. That is new; it used to go upstream into an empty vec. ### Why `Request` is its own enum and not a `ClientFrame` (improvise-jz6w) `ClientFrame::Save` and a `Host::handle` arm for it had existed since improvise-368h, and **no key could reach either**: `KeyOutcome` had no shape that said "send this frame", so `save` sat on the host-needed list, resolved to `Upstream`, and landed in the black hole. This is the gap a vocabulary audit cannot see — nothing was missing from `Command` or `ModelCommand`, and the enumeration that would have found it is over **`KeyOutcome`'s variants**, which are as much of the protocol surface as the frames are. Enumerate them too. The fourth variant could have carried a whole `ClientFrame`. It does not, and the reason is not tidiness: a resolver handing out arbitrary frames could compose a `Viewport`, whose extent is the *page's* fact and not the keymap's (gotcha 47), or a `Commands` batch — which would be **indistinguishable on the wire from a `Local`** while silently skipping the local fold that `Local`'s whole contract is. The narrow enum costs one type and one `frame()` mapping, and in exchange a request is exactly "ask the host to do a thing". `Request::frame` is the one place a resolver's decision becomes an envelope. Two consequences. `requested` is checked **first** in `step`, before `declined` and `composed`, so no name can be in two lists — the same layering the other two already have, and `composed` has no `"save"` arm to go stale. And a request **cannot travel inside a `Binding::Sequence`**: a sequence is one frame and a request is a frame of its own, so a sequence naming one is refused as `Upstream`. Nothing binds one there; `no_binding_puts_a_request_inside_a_ sequence` is what keeps that arm unreachable rather than merely untested. What `Request` deliberately does **not** do is become a `Command`. Gotcha 45's argument stands: a `ClientFrame::Commands` batch promises all-or-nothing, and a runtime-fallible write inside one reported a failed save and then applied the edit that followed it. A `Binding::Sequence` is **all or nothing**: half of `Esc` — clearing the buffer without leaving the mode — is worse than none of it. Each step is resolved against the *unchanged* state, because the host resolves every step against one immutable `CmdContext`; the commands are then folded in order. ### What still needs the host `the_command_names_that_still_need_the_host_are_these` names them, and `Host::handle` answers a `Key` frame with an empty vec, so each one is a binding that **does nothing at all** in a browser (`doctrine(an_upstream_key_is_a_black_hole)`). **An upstream key that the app also ADVERTISES is a different severity, and it had its own class of defect** (improvise-rmvx). A key nobody offers doing nothing is a gap; a key named in the mode's own hint line doing nothing reads as *broken*. The two are crossed by `every_advertised_command_answers_in_its_own _mode`, which asks `help::advertised` — a table of command **names**, so the cross-reference is exact and nothing tokenises the rendered string — against `resolve` in every mode, over a fixture furnished enough that an `Upstream` can only mean an unhandled name. The old guard asked only about `Normal`, which is why `add-record-row` and `delete-formula-at-cursor` were outside its reach entirely; `view-back` in RecordsNormal was a seventh instance nobody had noticed. **`Ignored` is a black hole too**, and the guard watches both variants. `KeyOutcome::of` turning an empty `Local` into an honest `Ignored` is good design and also a **laundering** step — every wrong answer that composes nothing arrives wearing the variant that is usually innocent. A guard's discriminator must therefore be *state* (did anything the user can see change) and never the shape of the return value. Watching only `Upstream` let three of the six declines go completely silent with 1397 Rust, 19 harness and 26 node tests green, because no browser spec presses those three keys (PR #71 review). `nothing_declined_is_also_composed` closes the other direction: `step` reaches `declined` first, so an arm in `composed` for a declined name is **unreachable**, and the browser would go on saying "not available yet" over working code. It is derived from `declined` itself over every bound `(name, args)` in every mode, so a seventh decline is covered without editing it. `enter-mode` was a real overlap on its first run; rather than move the check, **both halves hold the rule** — `composed` returns `None` for a stranded mode — so removing one fails towards `Upstream` rather than towards a dead room. `declined` is the answer, and it is one list rather than an arm apiece: six command names, each with **its own true subject**. Wording matters here more than it looks — the `>` fix was nearly routed through a message about aggregates, which would have been true-sounding about the wrong thing and worse than silence, because a reader who is told something plausible stops looking. `step` checks `declined` first, so `composed` has no arm for any of them and the two lists cannot overlap. A declining step takes its **whole sequence** with it: `o` in RecordsNormal is `[add-record-row, enter-edit-at-cursor]`, and the second step's premise is false once the first has declined. Returning only the decline, rather than the decline plus whatever composed before it, is what keeps `Binding::Sequence`'s "every step or none of them" true — an abort is not a rollback (gotcha 21), and this client has no way to undo a `Command` it has already put on the wire. The classes that remain: - **Prefix keys.** `Command::SetTransientPrefix` exists and this client could arm one; what it cannot do is what the *second* key then runs, which is `yank`, `wq` and `toggle-col-group-under-cursor`. Arming a prefix whose payload is unreachable is worse than not arming it. - **Anything naming a row, column, item or group** — `hide-selected-row-item`, `toggle-group-under-cursor`, `delete-formula-at-cursor`. The first two are **no longer blocked** and are simply not built here yet (improvise-e24v): since improvise-anr9 `GridCache` carries an `AxisFrame` per axis, so `cats[0]` + `label(idx, 0)` is exactly what `HideSelectedRowItem` reads off `GridLayout`, and `group_of(idx)` is exactly `ToggleGroup`'s `(category, group)`. The one thing to get right is that the frame is **windowed** and the cursor is absolute, so a client must reconcile the scroll offset first, as `render.rs` does for the selection. `delete-formula-at-cursor` stays: it names a position in the formula panel, not in the grid. - **Anything touching storage or an arbitrary command line.** The host's by definition. - **The records-mode commit** — *except the two creating columns*. A wide-records row is one coordinate tuple and editing a category column moves every measure cell in it; `commit_plain_records_edit` needs `GridLayout::record_col` to know which. A `CellId::Create` does not: it commits the typed NAME as one existing `ModelCommand` (`AddItem` into `_Measure`, or `AddCategory`), reads no `record_col` and writes no cell, so improvise-gcvb narrowed the early return rather than removing it. The `+cat` half **must not advance**: an inserted column renumbers every index to its right and the advance was computed from the pre-commit layout (gotcha 23), so `CommitAndAdvance` returns straight after it and a client that advanced anyway would land one column off, silently. `AddCategory` can also be refused by the 12-category limit and sets `abort_effects`; the mode change therefore goes **first**, where an abort cannot reach it (gotcha 21). **Since improvise-bd9o that exception is unreachable**, and this bullet describes `commit` alone: the records decline moved to the *entrance* of `edit_or_drill`, so no key path opens `RecordsEditing` in a browser and nothing arrives at `create_column`. The narrowing is kept rather than reverted — see the entrance/exit section below and improvise-x0xd, which is the ticket to give it a door again. - **Drill.** Session-local residue, and the one case that does **not** go upstream silently — see below. #### The records-mode decline is on the entrance, not the exit (improvise-bd9o) `declined()` answers `toggle-records-mode` with "records mode is not available in the browser yet", and for a while the code read as though that closed the route. **It did not, and the ticket's own account of the remaining silence was one case short of complete.** The route is the tile bar. `project::tile_entries` walks `View::tileable_cats`, which includes the virtual `_Index` and `_Dim` (gotcha 27), and `set-axis-for-tile` is fully composable here. Records mode **auto-detects** from `_Index` on Row plus `_Dim` on Column (gotcha 1), so a handful of ordinary, undeclined keys — `T`, a tile cursor walk, `r`, another walk, `c`, `Esc` — put a browser in `RecordsNormal` with `grid.records` true. This is failure-catalogue #4, an unreachability claim only as wide as the routes its author enumerated: the *toggle* was considered and the *axis assignment* was not. Once there, `i` was silent, and **from two different sources** — which is why a fixture standing on one of them cannot see the other: - On a **measure** column the cell is a `CellId::Record`, so `edit_or_drill` opened the editor; `commit` then returned `None` for `state.grid.records`, so `Enter` went upstream and `Host::handle` answered `ClientFrame::Key` with an explicit empty vec (gotcha 47). The editor stayed open holding the typed text. This is the case the ticket describes. - On a **coordinate** column — the leftmost columns, the ones carrying the row's category items — the projected cell carries **no `CellId` at all**. Per gotcha 42 a `None` id means "no projected cell", so `edit_or_drill`'s `cell.id.as_ref()?` returned `None` before reaching any arm, and `i` did not even open an editor. Nothing happened and nothing was said. The ticket did not predict this one, and it is not a second bug: it is improvise-fdk1 seen from the key path, since a coordinate rename has no wire form precisely because the `RecordHash` surrogate names records that already exist. So the guard goes at the **entrance** — the first statement of `edit_or_drill`, before the cell is even looked at — and not on the `CellId::Record` arm where the ticket's case lives. A guard on the arm answers *is this cell one we cannot commit*, where the question is *is this grid one we cannot commit in*; the coordinate column separates the two, and `the_tile_bar_route_into_records_mode_does_not_strand_the_user` walks every column of the row for exactly that reason. It tests `state.grid.records` rather than `state.view.mode.is_records()` for the reason `commit` already did: the grid is the **observed** fact and the mode is settled from it (`settled_mode`), so a mode-shaped guard would be a second description of records-ness free to disagree with the first — one arm of `doctrine(a_substitution_is_a_defect_at_every_site_that_makes_it)`. **And that clause needed its own test, because the route test cannot state it.** `the_tile_bar_route_into_records_mode_does_not_strand_the_user` enters records mode the way a user does, where the grid and the mode *agree*, so it cannot separate them: review injected both mode-shaped guards — `state.view.mode` and the keymap's own `mode` argument — and the **whole workspace stayed green**. That is this file's own `a_careful_comment_is_weak_evidence_that_anything_checks_it` caught in the act, and the more carefully the paragraph above argued, the more it was standing in for a check. The separating input is a client whose mode has **drifted off its own records grid**, which is exactly the fixture `a_records_commit_is_declined_off_the_grid_and_not_off_the_mode` already built for `commit`; `opening_an_editor_is_declined_off_the_grid_and_not_off_the_mode` is its twin for the door, with the same regular-grid control so the decline is not merely "this client never edits". **What this costs, stated rather than discovered later.** `create_column` (improvise-gcvb) is now **explicitly** dead rather than accidentally so. It was already unreachable — `edit_or_drill`'s `CellId::Create` arm returned `None`, and `o` is declined at its `add-record-row` step, so nothing opened `RecordsEditing` in a browser at all — but the deadness was an accident of two unrelated arms rather than a decision. Reviving `create_column` for real means opening the editor on `CellId::Create` while still refusing the other columns, which is a change worth making on its own evidence and not as a side effect of closing a silence (improvise-x0xd). **The `CellId::Create` arm is unreachable, and saying otherwise was the third thing review caught.** It now declines rather than returning `None`, and a first draft of this paragraph justified that as "if a creating column ever reaches this function from outside records mode". It cannot: `project.rs::cell_id` emits `CellId::Create` only inside `if layout.is_records_mode()`, so the entrance guard takes every such cell and the arm change is a **functional no-op** with no test behind it. It is kept because an arm that returns `None` is a black hole if the producer ever widens, and deleted reasoning is cheaper than a wrong one — but it must not be read as covered. `doctrine(a_careful_comment_is_weak_evidence_that_anything_checks_it)` applies to a justification as much as to a claim. **The `toggle-records-mode` decline is still not true, and that is the honest reading.** A first draft of this paragraph said the fix made it so. It did not: the wording is "records mode *is not available* in the browser yet", and the tile bar still puts a browser in `RecordsNormal` with a records grid drawn. What the fix closed is the **silence**, not the route, and what is now true is that *editing* there is unavailable. So the rule stated above about `stranding` — "no path opens a room this client cannot commit" — is still violated by the tile bar; this guards the **exit** rather than the door, deliberately, because the room is worth being in (a browser can read a records grid, scroll it and page it) and only the writes are missing. improvise-cfly's lesson still lands from the other direction — there a decline outlived its reason, here a decline was never as wide as its wording — but a decline whose *sentence* remains an overclaim is not a decline made true, and writing that it was would have been the same defect one layer up. #### The three improvise-e24v listed as free, and what each really costs improvise-4bwa. Each of the three is short exactly one fact, and the three answers are different in kind — which is the point, because "needs enrichment" read as one bucket is what let them be listed as free. **`view-back` was declined, and the decline outlived its reason by one wave — improvise-cfly.** What the passage below argued, kept because the argument is still the right shape and only its conclusion moved: `ViewNavigate { forward: false }` emits `ApplyAndClearDrill` *before* `ViewBack`, and `ApplyAndClearDrill` is deliberately off the wire — its operand is `drill_state.pending_edits`, session-local residue belonging to whoever staged it, and a `Command::Model` is routed with no `SessionId` (gotcha 38, improvise-3x3u). A client that mirrored `view-back` by **reconstructing** the drill commit would commit somebody else's uncommitted typing. The sharper reading still holds: `mirror_the_cmd_line_for_line` is a rule about *resolvers*, and a `Cmd` whose first effect is unaddressable is not one a thin client may mirror whole. What changed is that mirroring it whole was never the only option. PR #115 made `ViewBack` a `Command` in the **view half**, folded by `reduce_view` into the asker's own slice and nobody else's — so the half that matters is composable, and the half that is not is *unrepresentable* rather than merely forbidden: the resolver could not emit `ApplyAndClearDrill` if it tried. Nor is it owed one. No browser path stages a drill at all: `drill-into-cell` is declined, and `edit_or_drill`'s `CellId::Aggregate` arm answers out loud. So the divergence from the `Cmd` is confined to a state this client cannot enter, which is the narrowest form this doctrine's exceptions come in and is stated rather than hidden. The *real* asymmetry to mirror turned out to be elsewhere — `stepped` is silent on an empty stack where the `Cmd` speaks — and that is what `step_history` answers. Evidence: `context/test-evidence/clients.md`. **`toggle-prune-empty` needs one bool on a per-view projection.** `TogglePruneEmpty::execute` reads `ctx.view.prune_empty` only to word its status — "Showing all rows/columns" against "Hiding empty rows/columns". The `ModelCommand` itself is composable today; it is the sentence beside it that a client cannot write, so mirroring it would put the two ends out of step on the status line in exactly half of all presses. `View::prune_empty` is on the `View` inside the `Workbook`, in no widget cache and not in `ViewState`. **`delete-formula-at-cursor` needs an element-type change, and the shape of the change is the whole decision.** The effect is `RemoveFormula { target, target_category }`; `FormulaCache` carries the formula's *raw text* (`"Profit = Revenue - Cost"`), from which neither field is recoverable without reimplementing `parse_formula` in the client. Adding a `targets` vector **beside** `formulas` would be two answers to "what is in the formula panel", which is improvise-edp's disease and the mistake improvise-anr9 had to pay a type change to undo. So the element type grows the fields; it does not acquire a parallel array. Both of those are ordinary projection additions and both live in `improvise-protocol` (`render.rs`, `resolve.rs`) and `improvise-command` (`ui/project.rs`) — not in this crate, which is why this section records the design and the work lands with whoever owns those files. Whatever lands, the named list `the_command_names_that_still_need_the_host_are_these` shrinks by exactly what closed. #### What a client that could parse would cost: measured improvise-cl7y asks a client to stop claiming "Formula added" for text only the host can parse. Its own note says the two routes are a dependency decision or a status addressed back to the composer, and leaves the cost of the first unmeasured. It is measured now, by adding `improvise-formula` to this crate, calling `parse_formula` from an exported method so nothing is stripped, and building the bundle both ways: | | `improvise_wasm_client_bg.wasm` | gzipped | |---|---|---| | today | 503,854 | 191,613 | | with `improvise-formula` | 634,515 | 238,973 | | cost | **+130,661 (+25.9%)** | **+47,360 (+24.7%)** | The raw column is exact and byte-verified; the gzipped one is a `gzip -9` **proxy** and not what a visitor downloads, since GitHub Pages serves brotli. The conclusion is unaffected — the raw figure alone carries it — but do not quote "+47,360 gzipped" as a transfer size. A quarter of the download, for a status string. That is the evidence for the other route — the host addressing a status back to the composer, the way `ClientFrame::Save`'s rejection already does — and it costs no dependency. Note what the measurement does **not** settle, because it is the more interesting half: a client that could parse would be a **second derivation** of whether a formula is well formed, agreeing with the host only for as long as both call the same function. The status line is not the only thing that wants it, so the number above is a floor rather than the price of a feature. ### `stranding`: a prompt this client can enter but cannot commit `enter-mode` is trivially resolvable here, and that is the trap: the user reaches the prompt, types a path, presses Enter — and the commit resolves to nothing, because `Host::handle` answers a `Key` frame with an empty vec. **A dead room is worse than a locked door**: the door costs one inert key, the room swallows work the user believes was done. `SavePrompt` and `ExportPrompt` are host-only **by construction**, not by omission: `effect::Save`, `SaveAs` and `ExportCsv` are all `EffectKind::ViewOnly`, so the derivation that builds `ModelCommand` from the model effects cannot name them. Closing this is not a resolver to add; it is a wire operation that does not exist. In a browser it is also close to meaningless: the worker persists on its own behalf whenever the document is dirty, so the document *is* the OPFS entry and there is no Ctrl-S semantics to offer. It returns the **subject** rather than a bool (improvise-rmvx): the refusal is right and the silence was not, so `:` now says the command line is unavailable instead of doing nothing at all. The predicate is named as a property rather than matched at the call site (`doctrine(two_questions_one_enum)`): "can this client finish what entering the mode starts" is not the same question as "is this a text-entry mode", and a third such prompt belongs here. Found by Copilot on PR #27 against the newly added `SavePrompt`; `ExportPrompt` had the identical trap already and was fixed with it — fixing one arm of a rule with two is the shape `doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)` names. **`CommandMode` is dead by omission, and listed anyway.** Copilot found it on the next round, and it was a false negative in the predicate's *first* version — which mattered more than its severity, because `a_prompt_this_client_cannot_commit_is_never_entered` used `:` as the negative arm that stops "refuse every mode" from passing. An arm asserting something false does not merely fail to guard; it **certifies the behaviour it was added to rule out.** `:` resolves locally into `CommandMode` and typing appends locally, but Enter is `[execute-command, clear-buffer]` and `step` has no `execute-command` arm, so `resolve` returns `Upstream` and the frame is dropped. Every ex-command behind `:` is inert for the same reason, so refusing the mode removes **no working surface**, only a room that swallows typing. The distinction from the two prompts is worth keeping visible, because it says who removes that line. They are dead **by construction**: no `ModelCommand` can ever name `Save`/`SaveAs`/`ExportCsv`, so no resolver would help. `CommandMode` is dead **by omission**: `execute-command` is implementable client-side and improvise-e24v is scheduled to do it. Listing it is therefore also the named gap gotcha 47 asks for — closing e24v has to come to that line and delete it. ### Per-arm notes that would otherwise be re-derived wrongly - **`enter-tile-select`.** `EnterTileSelect` gates on `model.category_names().len() > 0`, which counts the **virtual** categories too — `_Index`, `_Dim` and `_Measure` always exist — so the gate is invariantly true and there is nothing here to mirror it against. Re-deriving it from `CategoryTreeCache`, which carries the panel's rows rather than `category_names()`, would be a second derivation that disagrees on exactly the empty document. - **`transpose`.** It used to be emitted alongside a `mark_dirty` this client had no wire variant for; since improvise-gv9r dirtiness is raised by the host off the effect's own `EffectKind`, so there is nothing to mirror and nothing missing. The cursor reset is not this client's to send either: the effect calls `App::reset_cursor`, and `adopt_cursor_epoch` broadcasts `Command::ResetCursor` to every client on a view the batch reshaped (improvise-nynk) — so sending one here would be a second, differently-addressed copy. - **`toggle-panel-and-focus`.** The interactive arm of `TogglePanelAndFocus`: open-and-focus unless this panel is already both, in which case close and unfocus. The decision is made in `registry.rs` on the host and here on the client, from the same two `ViewState` facts. - **`edit-or-drill`.** The host asks whether a regular category sits on `Axis::None` and whether the cursor is on a synthetic records cell. This client asks neither: the server already published the answer as the `CellId` variant, and `doctrine(a_demo_is_checked_against_the_type_not_the_axes)` says to read the type rather than re-derive the rule. It reads **`edit_text`, never `text`**: the drawn text has been through `format_f64`, so a stored 1200 reads `"1,200"` under a comma-grouping format and committing it would store the *string* `"1,200"`. `tests/lockstep.rs` found exactly that. - **Drill does not go upstream — by *either* door.** Every `Upstream` is a black hole, and a key that visibly does nothing is the worst of the three answers. Drill is session-local residue and reaching it needs a `CellKey` this client cannot build, so the honest answer is to say so: a `SetStatus`, which is a view-half command — `Sessions::apply` folds it into the asker's own mirror and broadcasts nothing, so it reaches this user and no one else. This argument arrived as `edit_or_drill`'s, i.e. about `i`/`a` on an aggregate, and for a while it read as doctrine for `>` while `>` was still silent. Both doors now refuse, and **they refuse with different sentences on purpose**: "this key is not built yet" and "this cell is not yours to write" are different facts. - **The commit's write is not spelled again here.** It is `Client::typed_cell`'s, the same one click-to-edit uses, so a browser keyboard commit and a browser mouse commit cannot disagree about what `""` or `"42"` means. - **`cell_at_cursor` returns `None` when the selection is scrolled out of the window** — not a state the cursor keys can produce, since they scroll to follow, but one a projection race can. - **`grid_move`'s two empty shapes** are kept apart from "not ours": an empty grid has nowhere to move to, and a client that has not announced a viewport has been sent no grid either. Neither is a key the host could answer better. - **`Quit` has no keymap**, on purpose: `ModeKey::from_app_mode` is exhaustive so that a new mode without bindings is a compile error rather than a mode that swallows every key. ### Browser key → improvise key `to_key_event` returns `None` for two cases the host must never see. A bare modifier press (`"Shift"`, `"Control"`, …) is not a keypress. And anything held with the platform meta key is left to the browser: `KeyModifiers` has no bit for Meta, so forwarding `Cmd-R` would arrive as a plain `r` and type into a cell instead of reloading the page. Two conversions are not obvious. A terminal reports **Shift-Tab** as its own code and the keymaps bind that code; the browser reports Tab plus a shift flag. And **shift is deliberately dropped for character keys**: the browser already applied it (`"A"`, not shift+`"a"`), and the shared keymap's `lookup_local` retries character keys without modifiers for that very reason, so keeping the flag would only make an exact binding miss. ### The pointer: `Click`, and the four rules it obeys A click is resolved here for the same reason a keypress is — a front end paints and wires, it does not decide (design-principles §5). `resolve::click` takes a `Click` naming a widget and an index, and returns the `Command`s. `web/app.js` attaches listeners and knows nothing else; there is one entry point per widget on `Client`, which was the user's ruling on improvise-7ybz. **Rule 1 — a click navigates to what was clicked, and activates it where activation is something the client can express.** Every arm composes the same shape: `focus` the panel, `SetPanelCursor` to the row, then whatever `Enter` does on that row. The activation is resolved **against the clicked index, never against the cursor**, because the `SetPanelCursor` ahead of it has not been folded when the resolver runs — the trap that reads as "the cursor lands on `Plan` and the document switches to `Default`", silent and wrong in the half a user does not watch. The second clause of rule 1 is what makes the formula panel look inconsistent and is in fact the rule holding. `ModeKey::ViewPanel` binds `Enter` to `switch-view-at-cursor`; `ModeKey::FormulaPanel` binds `a`/`n`/`o` and `d`/`Delete` and **no `Enter` at all**. So a view-row click switches and a formula-row click only navigates. Opening the editor on a formula click would be an activation *invented for the pointer that the keyboard does not have* — a second derivation of what a formula row means, which is the disease §5 names. **The guard for this must be aimed at the keymap, not at the click**: asserting that the click emits no `FormulaEdit` only restates the author's reading, and a reviewer demonstrated the gap by adding `Enter -> enter-mode formula-edit` to `FormulaPanel` and watching the whole workspace stay green while the click and the keyboard disagreed. `the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does` asserts the binding's absence and names `click_formula_row` as what must change if it ever appears. **The browser assertion that PR #60 dropped stays dropped, and improvise-qf57 is the decision rather than the oversight.** `#grid input` `toHaveCount(0)` was argued to be redundant with the `#mode` assertion on the grounds that `toHaveText("FORMULAS")` is exact and `AppMode::FormulaEdit` labels as `"FORMULA"` — a coincidence of two strings differing by one character, which would be a thin reason to leave it out. Measuring it gives a better one: it is not redundant, it is **unreachable**. `web/app.js` creates a grid `` in exactly one place, the grid cell's own `mousedown`, gated on `cell.editable`; no repaint creates one and no panel handler can reach it. And an invented formula-row activation opens the **minibuffer**, not a grid editor. Injecting `Command::ChangeMode(AppMode::formula_edit())` into `click_formula_row` and running the spec: `#grid input` `toHaveCount(0)` **passes**, and the run fails on the `#mode` assertion below it. Restoring it would add an assertion that cannot fail — the same defect this file already records one line down, where an explanatory `assert!` was removed for never being able to be the failing one. **Rule 2 — a click is all of its intents or none of them** (improvise-hkr8). Each `click_*` returns `Option>` and `click` does the single `unwrap_or_default`. The case that matters is an activation the client cannot compose: `filter_to_item_at` goes through `model`, which goes through `BasisCache::claim`, which returns `None` until the server has published a basis (gotcha 43). Before the fix the `None` was swallowed per-intent, so the click kept its navigation prefix, dropped its activation, and reported success — while the *keyboard* handled the identical `None` correctly by answering `KeyOutcome::Upstream`. Note that `None` at the `click` level means something different and benign: the whole click composed nothing, which is the invariant rather than a breach of it. **Rule 3 — a click on an *unselected* tile never mutates the document.** The user's ruling on the tile bar. The cost accepted is two clicks for the common action and a **deliberate divergence from the keyboard**, where `Enter` cycles directly — record it as chosen, not as an inconsistency to tidy, or someone will read the binding and "fix" the pointer to match. What it buys is worth more than the keystroke: a tile cycle is a `LayoutGeometry` change, which bumps `cursor_epoch` and therefore resets **every connected client's cursor** (gotcha 24), so a stray click used to cost every other viewer their position. **State the rule this precisely, because the looser wordings are false.** The implemented rule is "clicking the already-selected tile cycles it; clicking any other selects it", where *already selected* is `mode == TileSelect` **and** `tile_cat_idx == index`. The tempting summaries — "a click never mutates on its own", "a **second** click cycles" — both have the same counterexample, found on review: `T` enters `TileSelect` leaving `tile_cat_idx` at **0**, so a click on the **leftmost** tile is a click on the already-selected one and cycles on the *first* gesture. Two adjacent tiles therefore answer the identical first click differently, and that is correct rather than a hole — a click does what `Enter` does there, and `Enter` in `TileSelect` cycles the tile under the cursor. The resolver cannot see *how* the state arose and must not try to. The mode conjunct is still load-bearing in the other direction: without it, `tile_cat_idx == 0` in **Normal** mode would read tile 0 as already selected, and the very first click of a session on the leftmost tile would cycle it. **Rule 4 — the shared helper is the idiom, and breaking it is how the two arms drift.** `click_category_row` calls `filter_to_item_at`, `click_tile` calls `tile_axis_at`, `click_view_row` calls `switch_view_at` — each is `foo_at(index)` with the keyboard's `foo_at_cursor` calling `foo_at(cursor)`. `click_view_row` originally re-spelled `SwitchView + ChangeMode(Normal)` inline, so a host-side change to `SwitchViewAtCursor` was forced into the keyboard arm by `tests/lockstep.rs` and left the click behind **silently**. A helper that returns only the *name* rather than the whole activation is what invites that. ### The category panel opens at launch, in both front ends `ViewState::default()` sets `category_panel_open: true`. The user ruled this twice: first that the sidebar be visible at launch, then — asked whether the terminal was collateral — that it applies to **both**. `improvise model.improv` opening with the sidebar showing is intended, not a side effect of sharing a default. It is the **shared** default for the reason wsaw gives: a browser-only default would be "the page overrides the flag", which is the same defect as "the page ignores the flag" wearing different clothes. **A launch default disarms every test that asserted the thing it now supplies**, and that is the expensive part. Three tests asserted `category_panel_open` was *true* after doing something to make it true; all three then passed without the code under them working. The worst was `set_panel_open_and_cursor`: with it vacuous, `effect::SetPanelOpen`'s Category arm could be replaced by a **no-op** — `C` doing nothing in the terminal — with the whole workspace green. The rule this leaves: **assert the direction the default cannot supply.** Closing the panel is the discriminating move; opening it proves nothing. Note the two mappings that had to be repaired separately. `effect::SetPanelOpen` and `reduce_view`'s `Command::SetPanelOpen` are two three-way matches in two crates — the TUI folds one and the browser the other — so a guard on either says nothing about the other. That is a derivation done twice (§5), pre-existing and filed rather than fixed here. Two tests, because either alone passes against the bug wsaw fixed: the panel is visible with no keypress, *and* `C` still hides it. Injecting "the page ignores the flag" fails the second; injecting "the default is false" fails the first. A test that only asserts the sidebar is visible cannot tell the flag being honoured from the flag being ignored. ### A black hole that leaves the keyboard in a mutating mode (improvise-koh6) This file's list of what a client cannot compose used to read as a list of keys that *do nothing*. `N` was on it, and it is worse than nothing. `N` is advertised on the browser's **own** help overlay ("Quick-add a new category, from anywhere"), it resolved `Upstream`, `Host::handle` answers a `Key` frame with an explicit empty vec — so the DOM was byte-identical — and the user then typed the category name into NORMAL, where `i` is bound to `edit-or-drill` and opens the grid editor **pre-filled with the cell's current value**. Measured on `web/demo.improv`: `N R e g i o n Enter` turned `18,400` into the text `18400on`, with no category created. A dead key in a *modal* editor is a data loss, not a papercut, and that distinction now belongs on the list. **The decision it forced.** `SearchOrCategoryAdd::execute` is a conditional: a non-empty `search_query` delegates to `SearchNavigate`, which a browser cannot compose (it would have to search a grid it holds only a viewport of); an empty one emits two commands this client already has. Composing the second arm and returning `None` for the first would make **whether the host is needed** depend on client state, which `enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream` exists to forbid. The resolution is that the rule is about `Upstream`, not about outcomes. The name is now answered in `composed` in **both** arms — the composable one builds the pair, the other declines through `not_yet` with its own true subject — so the host-needed list stays a property of the *name*. The outcome varying with state is what `cycle-axis-at-cursor` and `paste` already do a few arms above; what must not vary is whether an answer exists at all. `declined()` could not express this, being keyed on the name alone, which is why the sentence moved into `not_yet` and the arm lives in `composed`: `step` reaches `declined` first, so the two lists still cannot overlap and `nothing_declined_is_also_composed` still holds. Note the arm mirrors the `Cmd` line for line, including that it uses a plain mode change rather than `EnterMode` and so does **not** clear the minibuffer on the way in. Tidying that would be a divergence, not a tidy. ### A cursor that does not follow a shrinking list (improvise-uej8) `d` on the last item of an expanded category shrank the tree under a cursor that stayed put, so the row beneath it became the **next category's header** — and one more `d` removes a whole category, which is `LayoutGeometry` and rewrites every view's axis map. The same `Cmd` backs the same key in the TUI; this was never browser-specific. The rival fix is the one `delete-view-at-cursor` and `delete-formula-at-cursor` already use — step back whenever the cursor is above zero — and on this list it is *worse than the bug*: the first item of a category would retreat onto that category's own header, the same escalation one row earlier, and holding `d` would walk backwards instead of clearing the category out. Those two panels are right to do it because their lists are **homogeneous**; a cursor that does not move on a one-kind list cannot escalate. That is the property to check before copying a cursor rule between panels, not the panel's name. **The first fix was the adjacent-only one — step back when the row below is not a sibling — and a ChatGPT Codex review of PR #114 found it wrong for a category with exactly ONE item.** That is the configuration in which the conditional rule degenerates into the unconditional one: no sibling below, so it steps back onto the category's own header, and the escalation returns. The paragraph above had already named that shape as the reason not to copy the other panels' rule; it just did not notice that the singleton reaches it too. `CatTreeEntry` has two variants and `flatten_cat_tree` emits a header then its items, so the enumeration is short. After deleting the item at `i`: if the row below is a sibling the list closes up and the cursor stays; if the row above is a sibling it steps back; **and if neither, the category is now empty and BOTH neighbours are `Category` rows** — there is no safe adjacent landing at all. That third case is structural rather than an oversight, and it is why the rule could not be patched in place. So the landing is the nearest **`Item`** row, preferring above, then below. `d` there deletes an item, never a category, which is the criterion the review asked for. The cost is real and is not hidden: the cursor may cross into another category, and a held `d` then deletes one item per press — each with its own status line — walking upward rather than escalating. Landing on the emptied category's own header was rejected even though `RemoveCategory` on an empty category is cheap (no cells, and a non-measure formula target *is* an item, so no formulas): that is a harm-magnitude argument, and this repo prefers making the bad state unreachable. One residual, stated rather than guarded: if the deleted item was the only item in the whole tree there is no `Item` row anywhere, so the cursor does not move. Every category is empty at that point, so the escalation it leaves is the cheap one, and no landing exists that would avoid it. ### One operation, two routes, one sentence (improvise-25bf) `cycle-axis-at-cursor` from the category panel emitted no status where the tile route emitted `Product → None`, so the same operation told the user only down one of its two paths — design-principles §5 in miniature. The wording now has one home, `viewmodel::axis_change_status`, called by both routes here and by both in the TUI. This also pays for improvise-z336's third finding, which is the more interesting half: the `Filter: X = Y` status persisted after the axis had moved to None, Row and Col, so the one piece of feedback about a filter was wrong exactly while the user was trying to be rid of it. A user who *had* cleared it correctly was told for the rest of the session that it was still on — a mechanism that manufactures a bug report out of a working code path. It needed no separate fix: a route that writes a fresh status writes over the stale one. --- ## `improvise-wasm-client` — the crate, the fold, the render model ### What the crate is, structurally One `wasm32` artifact, reused literally in both deployments: the thin client talking to a server over a WebSocket and the standalone app talking to a worker that hosts the `App` over the worker's own port. Only the JS bootstrap differs — nothing in the crate knows what the transport is. It is a **fold and a key classifier**, and nothing else. It holds a `ViewState` plus one widget cache each — there is no aggregate render cache — and it moves them by folding the same `Command` stream the host folds. The fold is `state.rs`; the key classifier is `keys.rs`. The argument that shaped it: a keymap binding names an **intent** resolved against a `CmdContext`; a `Command` is the **resolved mutation**. A thin client holds no `Model` and no `GridLayout`, so it cannot do that resolution. (Since improvise-bpq2 the *table* is shared and the resolution of the composable subset happens here; what is left upstream is the subset `keys.rs` names.) That is why the command crate is a **dev**-dependency and not a dependency: its keymap could not be used here even if the bundle could afford `App`, `Workbook`, persistence and the import wizard. What the dev-dependency buys is `tests/lockstep.rs`, which drives the authoritative host and this client over the same keypress and asserts the two `ViewState`s agree — the correspondence is checked rather than asserted in prose. **The boundary is JSON**, so the JS side needs no generated structural bindings and the same strings go on a socket unchanged. No `std::fs` (it compiles for wasm and fails in a browser) and no clock: nothing here calls `Instant::now()`, so the `web-time` shim the command crate needs is not needed. Both are structural, not disciplined — this crate depends only on `improvise-protocol`, `serde`, `serde_json` and `wasm-bindgen`, none of which reaches a filesystem or a clock. **Ordering.** The JS bootstrap must deliver frames to `Client::on_frame` in arrival order, one at a time. The grid fold is deliberately not commutative — a `GridUpdate::Cells` that overtakes the labels framing it is dropped — so batching or reordering silently loses cells. For the same reason a locally-resolved command and a forwarded keypress must go out on the *same* ordered channel, in the order `Client::on_key_event` returned them. **Why the `wasm_bindgen` surface is a thin wrapper over a plain-Rust one.** `wasm_bindgen` methods cannot take or return the protocol's types, so there is one code path serialized at the edge. The split is not only ergonomic: `JsError::new` panics off wasm, so every fallible path has to be *reachable* without it or its failure arm cannot be tested at all. **`Client::viewport` vs `Client::set_viewport`.** `viewport` is associated rather than a method, because a page must send the handshake *before* it has anything to fold. But it builds the frame and forgets it, so a client driven only through it never learns its own window and therefore resolves no cursor key locally — a truthful subset (those keys go upstream and the host answers nothing) but a whole feature quietly off. `set_viewport` is the one a page should call. The window is **not derivable** from anything the host sends: the row frame's own length is `min(window, grid − offset)`, so it agrees with the window except near the bottom edge, which is precisely where the scroll arithmetic matters. `viewport` is kept because `web/app.js` already spells it and retiring it is a co-ordinated change. ### `improvise-protocol/src/client.rs` — the two properties this dispatch owes Both are tested in `state.rs` rather than assumed from the protocol crate's own tests, because the dispatch between them is this crate's. - **The stream is not reordered.** Every fold path is a `for` over arrival order; there is no batching, sorting or coalescing. - **Bootstrap is a compaction, not a replay.** `ClientState::bootstrap` folds `Snapshot::seed` exactly as the steady stream is folded, so there is no second code path that could disagree with the incremental one. `GridCache::seed` puts the labels before the cells for the same ordering reason. **`window` is deliberately not reset by `bootstrap`**: the browser window did not change size because the document was replaced, and clearing it would silently stop this client resolving its own cursor keys until the next resize. **`last_rejection` is kept as a value, not a sentence.** A rejection is the input to *force*, and force is a re-composition against the republished basis — which needs to know **which** record and **which** kind. `RecordGone` cannot be forced at all; `RecordMoved` can be forced with one keystroke. A string cannot carry that. It is session-local: it belongs to whoever composed the refused batch, so it is not part of the portable slice and does not survive a bootstrap. `window` is session-local and not portable for the same family of reasons: it is a fact about a browser window, which is why no `Command` carries it and why it is not on `ViewState`. `(0, 0)` means this client has not announced one yet, and a client in that state resolves no cursor key — it has been sent no grid either. ### `socket.rs` — the frames, the wording, and the one composition **Where the frames live: not here.** `ClientFrame`/`ServerFrame` are `improvise-protocol`'s, and the same pair frames both deployments. This crate used to carry two envelope pairs of its own: one *mirroring* `improvise-ws-server`, because a native crate cannot be a dependency of a wasm bundle, and one written for the worker before the projection architecture landed. The mirror was held honest by two tests round-tripping every variant through the server's own types; with one definition there is nothing left for them to compare, which is what improvise-xks6 was for. **`rejection_message` is spelled here rather than on `Rejection`** for the reason `AxisColor` is not a ratatui colour: the protocol carries the *fact*, and each front end words it. The wording tracks ADR 001's force table, because "try again" and "this cannot be forced" are the two different things a user must be able to tell apart — a `RecordGone` has nothing to write to, and offering force there would promise something the design refuses to deliver (**force never creates structure**). Two wordings are load-bearing: `DocumentReplaced` must not say "reloading", because nothing here reloads, and the two save failures are not staleness so neither offers a retry — retrying changes nothing about the host's storage. **`typed` is a rule that genuinely lives in two places.** It is the same three-way split `commit_regular_cell_value` makes — empty clears, a parseable number is a number, anything else is text. It is duplicated here for the reason the whole client exists: the browser has no `App` to ask, and `ModelCommand::SetCell` takes a `CellValue`, so *somebody* on this side must decide. Putting it in Rust rather than in the page's JavaScript is what makes it testable and keeps one spelling per language boundary instead of two. This is the **only** answer the *client* gives to "what does a typed string mean" (improvise-r4lo), and it holds on every deployment including the worker: a host's `App` sits behind the frame boundary and is handed a value, not a string, so no host ever classifies a string that arrived *over the wire*. The careful half: a host does classify its own **local** keystrokes, which is why this is a duplicate of that split rather than a call to it — the command crate is deliberately not a dependency, and reaching it would drag `App`, `Workbook` and persistence into the browser bundle. So `a_typed_string_is_classified_as_the_host_classifies_it` **states the obligation without discharging it**: it asserts the three outcomes by hand rather than comparing with the host's function, so the two could drift and stay green. That is `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` still open, one layer below what improvise-r4lo closed — the real fix is a shared split in `improvise-protocol`, which both crates already depend on. Filed rather than done. `typed`'s `None` means "clear this cell", which is a *different command*, not a different value — hence the `Option` rather than a `CellValue::Text("")`. **`typed_cell_frame` is the composition improvise-q08 found a thin client could not do.** It holds no `Model`, so it cannot build a `CellKey` — but it does not need to: the id on the cell it is already drawing *is* the address, and it is echoed back unread (ADR 002). It takes the user's **string**, not a `CellValue`, for the reason above; there is no deployment for which a pre-classified entry point would be right. A sibling taking a serialized `CellValue` used to sit beside it and claim the opposite; it was deleted rather than re-documented, because it could not express `ClearCell` and was therefore a strictly weaker duplicate. `None` on **four** shapes, each a different fact the client already holds: the position is outside the grid; the position holds no settable cell (`id: None` — padding, or a records-mode column that renames a coordinate rather than writing one); the cell is an **aggregate**, which is the pattern failing to bind rather than a check; or this client has not been told the document's basis and so has nothing truthful to say about what it composed against (`BasisCache::claim` withholds rather than invents). **Nothing is folded there.** The new value comes back as a `GridUpdate::Cells` from the host, which is the side that formats it; guessing it locally is how the two ends drift apart. `typed_cell_command` is the one composition shared by the mouse path (`typed_cell_frame`) and the keyboard path (`keys::commit`). Writing it twice is how a browser commit by Enter and a browser commit by click would come to disagree about what an empty string means. ### `render.rs` — the four decisions that are silent when wrong The DOM half of this client is JavaScript. The split is drawn to make that acceptable rather than comfortable: **every question with a wrong answer is answered here, in Rust, under test**, and what crosses to the page is a list of rows of cells that each already know whether they are editable, numeric, erroneous and selected. The page's job is `document.createElement`. - **Editable is a fact about the id, not about the text.** Only `CellId::Record` may be written (ADR 002), and there are now **four** kinds, each naming a permission. `CellId::Aggregate` is a *drill* target and `Drill` is not on the wire yet, so it renders as non-editable rather than as a control that does nothing. `CellId::Derived { target }` is a cell a formula owns (improvise-ir7l): `i` on one names the formula rather than opening an editor, because a write there lands and is then shadowed forever by `formula_cache`. `CellId::Create(kind)` is a records-mode `+`/`+cat` column (improvise-poia), where what the user types is a **name**, not a value. And `None` is **not** any of those — it is "no projected cell here": a padded slot, a blanked one, or a records-mode column that renames a coordinate across the whole row rather than writing one. - **The selection is absolute and the grid is not.** `GridCache` is indexed by position *within the viewport*, while `selected` and `row_offset`/`col_offset` are positions within the whole grid. Reconciling the offset is the entire conversion, and the trap is that forgetting it looks right at the top of a document and wrong after the first scroll. `render.rs` does it by *adding* — `r + cursor.row_offset == sel_row` — so there is no underflow path and a selection above the window simply never matches. - **The category panel is `flatten_cat_tree`**, not a second walk of the cache. That function exists in `improvise-protocol` precisely so this end and `move-panel-cursor` share one flattening (improvise-edp); the page never sees `CategoryTreeCache` at all. - **The grid's frame is `AxisFrame`, and every header decision is made here** (improvise-anr9). The multi-level header levels, the repeat suppression, the group runs and their collapse state come off the same `AxisFrame` methods the TUI widget calls, so the page receives labels already blanked where they repeat and group runs already spanned. Until this landed the protocol carried `GridLayout::row_label`'s slash-joined tuple and the page drew `Food/Alice` where the TUI drew two header columns — a derivation the TUI's own test says is wrong, shipped to the other front end. Nothing in `RenderModel` is on the wire. It is a *projection of a projection* — the client's own caches shaped for one renderer — so it may change freely without a protocol conversation. Four smaller decisions that would be re-derived wrongly: - **`RenderRow` is an enum**, not a `label` plus an `is_group` flag, so a group header cannot carry cells and a data row cannot carry a collapse state. It is serialized with a `kind` tag because the consumer is JavaScript. `labels()` is **empty for a group header**, which carries its label in its own variant rather than in a one-element tuple — so a caller walking `labels()` over the whole model sees the data rows and nothing else, which is exactly what `neither_front_end_produces_a_slash_joined_label` wants. A caller that means "every label on screen" has to match on the variant. - **`RenderColGroup::label` is an `Option`, not an empty string.** An empty string is a legal group *name* — `Category::add_item_in_group` accepts one, and the TUI draws its indicator like any other — so a sentinel would serialize that group identically to an ungrouped run and the page would drop its indicator (raised by Copilot on PR #23). `None` for an ungrouped run also keeps the runs a *partition* of the columns: the spans sum to the column count, so a page laying them out as `colspan` cannot leave a hole. - **Column group runs coalesce by NAME alone**, which is what `src/ui/grid.rs` does (`group_name != prev_group`), and the two must agree. Collapse is category-scoped, so two adjacent groups from different categories may legally share a name and differ in it; keying on the pair would start a second run the TUI suppresses. The run's indicator is its FIRST group's, again as the TUI paints it. `col_groups` returns nothing at all when no column is in a group, because "this grid has no column groups" and "every column's group is blank" are different statements and the page draws a line for only one of them — the same asymmetry `GridCache::totals` has. - **`hint` and `editing` are decided here, not in the page.** `hint` is `AppMode::hint_text()`, the same string the TUI's footer draws, so the page spells no keys of its own — which is what keeps a second front end from growing its own idea of the bindings, and makes the modes this client can *enter* but not yet *draw* (`Help` above all) state what they are instead of looking like a broken grid. `editing` is `AppMode::is_editing()` plus the `edit` buffer: without it, a browser that resolved `i` locally would change its mode indicator and then swallow every keystroke invisibly. - **`minibuffer` is the same argument, for the other six text-entry modes** (improvise-fdt6). `editing` drives the *inline cell editor*, so it is scoped to the two cell-editing modes and reads the `"edit"` buffer by name — which left FormulaEdit, CategoryAdd, ItemAdd, SavePrompt, ExportPrompt and CommandMode opening a prompt and swallowing every character typed into it. `minibuffer` is `AppMode::minibuffer()` plus **the buffer that config names**, read with `improvise_protocol::read_buffer` so the `"search"` special case keeps its one home (gotcha 11); `None` is "no text-entry mode is open", so the page asks nothing about the mode. The colour stays semantic — `MinibufColor` crosses and the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26). It is drawn as a surface of its own rather than *in place of* `#status` as the TUI's bottom bar does, because `#status` also carries the transport's failures and a repaint must not erase one (gotcha 48). Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks **above** it, so the prompt is neither covered nor displaced. `RenderCell::edit_text` is the stored value, not the formatted one: seeding an editor from `text` and committing turns `1200` into the string `"1,200"`. The `numeric`/`error` flags are the server's answers, echoed — the client must not re-derive them from the text, which is how the two ends drift. `render_model` builds `header_labels(0)` **once for the whole frame**, because the per-position accessors scan and asking them inside the row loop is quadratic. --- ### `render.rs` and the help overlay (improvise-ktv1) `RenderModel` carries `help`, `help_page` and `help_titles`, and the decision the page must not make is *whether the overlay is open*: an **empty `help` is what says it is closed**. That is the shape `col_groups` already uses for "this grid has no column groups" — an absent list rather than a flag the page has to interpret alongside a mode it would then have to understand. The rows themselves are `improvise_protocol::help::pages()`, the same ones `src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string (`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can style a heading without knowing what makes a line a heading. Where the content comes from and what is derived from the keymap is `context/code/protocol.md` §8. ## `web/app.js` — the page The browser half of improvise-cr3, kept as thin as it can be made, now talking to either of two peers (improvise-djm). **The boundary is JSON strings in and out, so this file holds no protocol knowledge at all**: it never inspects a `Command`, never builds a `CellId`, never decides whether a cell is editable or whether a typed string is a number. Five calls are the whole harness: | call | what it is | |---|---| | `client.set_viewport(rows, cols)` | the handshake frame, and every resize; also records the window so keys resolve here | | `client.on_frame(json)` | fold one frame, in arrival order | | `client.render_model()` | everything to paint, already decided | | `client.typed_cell(r, c, s)` | the frame for "the user typed s here" | | `client.on_key_event(k,c,a,s,m)` | the frame for a keypress, folded locally first where this client may answer it | **The two rules this file must not break are ordering and framing**: frames are folded one at a time in the order they arrive (a cell update that overtakes its labels is dropped, not replayed), and the *first* frame out must be the viewport, because the server builds the snapshot at it. Everything in this file that is a *decision* rather than a paint now lives in `web/shell.js` — `socketUrl`, the two status sinks, the toast's identity rule, the watchdog, the extent arithmetic and `gridPlan` — so that `node --test` can reach it without a browser. The sections below describe those rules; the code is one file over. ### Two transports, one client The wasm bundle is the SAME artifact either way (improvise-xks6: one envelope pair, two deployments). Only where the strings come from differs, so the difference is confined to `openTransport` and nothing below it knows which peer it has: - **default** — an in-page Web Worker hosting the whole `App` over OPFS (improvise-9x6). No server process; this is what makes a GitHub Pages copy of the directory a working application. - **`?ws=host:port`** — a websocket to `improvise-ws-server`. That process is how a document opened in the TUI is shared, so the path is kept; **the presence of the parameter is the whole selection mechanism**, because a second knob naming the same fact is a second thing to get wrong. **Neither is reachable silently.** Every transport writes what it is doing to `#status` — "starting", "connected", "cannot reach …", "disconnected" — so a peer that never arrives reads differently from one that went away. "disconnected" means exactly the second: a socket that opened and then closed. A socket that never opened keeps saying "cannot reach …", because the close event there is the tail of the failure and not news. There are **two independent facts** here — what the transport is doing and what the document last said — and until improvise-n6t8 `#status` composed them with a `·` so that a repaint could not quietly erase a connection error. They now go to **two elements**: `#status` is the transport's alone and never auto-hides, and the document's is a toast. The rule is unchanged and the mechanism is stronger — a repaint reaches `onModel` and cannot address `onShell` at all. See *The bar is reserved, not fitted* below. ### `socketUrl`: `wss://` is the default A bare `host:port` gets `wss://`, because the address that works from where this page is actually served is the one it should be easiest to write. The page ships to GitHub Pages, which is https, and an https origin refuses a `ws://` address as mixed content — so a `ws://` default would hand the browser a URL that cannot work and report it as a connection failure, which reads like "the server is not running". An explicit `ws://` is honoured only where it can function: a page served over plain http, which is the local `python3 -m http.server` case that pairs with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS, so reaching it from https means terminating TLS in front of it). From an https page an explicit `ws://` is **upgraded** rather than passed through, since passing it through can only produce a failure the user cannot act on. ### `set_viewport`, and the twin that is gone Same frame, but it also **records** the window. A client learns its window only from what it announced — the row frame's own length is the window clipped to the grid, so the two differ exactly at the bottom edge, which is where the scroll arithmetic matters. Without it the client cannot clamp, so every cursor key goes upstream into `Host::handle`'s empty vec and the arrow keys silently do nothing (improvise-bpq2). There used to be a frame-only twin, `Client::viewport`, exported beside it and called by nobody. Its doc said it was kept because `web/app.js` already spelled it — and `web/app.js` called `set_viewport` in the same wave, so the reason was false before it was read (improvise-seik). That matters more than an ordinary unused function, because **the two are one letter apart and the wrong one fails silently**: it sends an identical frame and simply forgets the window, which turns every cursor key into the black hole above. It is deleted, along with the exported `init()` free function that duplicated `new Client()`. A deletion is not the guard, though — the next export with no caller arrives the same way. `web/tests/bundle/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 an `UNCALLED` table with its reason. `free` and `initSync` are wasm-bindgen's own; `revision` is there because `on_frame` returns it. Proved by injecting a spare `static viewport(...)` declaration into the built `.d.ts` and watching it fail by name. ### `frameCost`: what the grid's own frame costs, in whole cells One header row and one header column was right until improvise-anr9: the column header is now one row PER LEVEL plus an optional group row, the row header is `row_levels` columns wide, and inline group headers take a row each. Reserving one of each announces a viewport the page cannot show, and `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of its own while the client's cursor offsets were computed from the larger window — putting the selected cell off screen. It is **measured off the painted model rather than predicted**, and defaults to the old one-and-one before the first paint, which is the only moment there is nothing to measure. **The totals strip IS reserved**, unlike the inline group rows below, and the difference is what makes it countable: it is chrome on the same terms as a header level — present exactly when `totals` is non-empty, once, never interleaved with the data — so counting it is neither a feedback loop nor a ratchet. The page bar is not reserved and needs no rule, because `#pages` sits *outside* `#grid` and so is already absent from the box `extentOf` measures. **Inline group ROWS are deliberately NOT reserved.** They take real height, but how many are in view depends on how many rows the window asked for, so every way of counting them is either a *feedback loop* (count the current ones: a smaller window crosses fewer groups and asks for a larger one) or a *ratchet* (keep the maximum: a group-dense grid stores an oversized cost on its first oversized projection and never lets it fall — one group header per row clamps the grid to a single row, permanently). Both were tried. What is left is an over-request bounded by the groups in view: a grouped grid asks for a few rows more than the table shows, which matters only for a selection near the bottom edge — and that gap is not new and not specific to groups, since nothing in this page calls `scrollIntoView` at all. Filed as its own ticket rather than answered with a heuristic in a file the harness only partly executes. ### The watchdog, and the two `close` arms **The handshake watchdog is about the *first frame*, not about either peer's own failure modes.** The socket has an `error` event for a peer that never answers; the worker has none that fires, because `worker.js` catches its own `start()` rejection — so a browser with no OPFS, or a worker bundle that failed to load, would leave the page saying "in-page worker" over an empty grid forever. "The peer never answered the handshake" is the one condition that means the same thing in both deployments, and a check aimed at each peer's own failure modes would have missed exactly this one. **A peer that failed *loudly* has already said something more precise, so the watchdog must be DISARMED and not merely pre-empted**: it fires later and would overwrite the better message with "did not answer" (PR #13 review). The watchdog is for silence only, and `setShellFailure` clears the timer. **Did this socket ever open?** The page has to record that itself, because nothing on the close event carries it: `readyState` is already CLOSED by the time a close listener runs, whichever happened, and a refused connection and a mid-session network drop are BOTH code 1006 with `wasClean: false`. So "never connected" and "was connected and went away" are distinguishable only by remembering — an `opened` flag is the only discriminator. A socket that never opened did not "disconnect": it failed to connect, and the close event is the tail of that failure rather than a second, vaguer thing that happened (improvise-taxx). Writing "disconnected" there unconditionally replaced the `error` listener's precise message with the least informative of the two facts. **That is the same family as the watchdog bug and NOT the same fix.** The watchdog is disarmed because it is for silence only, whereas suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for. Hence the split rather than a guard. **Both arms go through `setShellFailure`, which disarms the watchdog** — and that half is the one that keeps getting lost. It matters on the opened path: the watchdog is armed after `transport.ready` resolves, i.e. on `open`, so a socket that opened and then dropped before its first frame reaches the close listener with the timer still pending, and "disconnected" is more precise than the guess it would otherwise fire 8s later. On the never-opened path there is no timer yet and the clear is a no-op. Neither arm leaves one armed. ### Painting - The **column-group line** is drawn only when there is one: an empty `col_groups` means this grid has no column groups at all, not that every group is blank. `label` is `null` for a run in no group, and an empty STRING is a legal group name that still gets its indicator — which is why the page does a null check and not a truthiness test. - **One header line per level**, and a blank entry is *repeat suppression* decided in Rust, never an absent label. - **The cell coordinates a click sends are DATA row indices**, and a group header is not a data row, so the page counts rows rather than trusting the position in `model.rows`. - `r` is one binding shared by every closure in the row loop, so a click handler that captured it would fire with the LAST row's index. It is snapshotted. - **A keyboard edit is open when `model.editing` is a string**, and it holds what has been typed so far. Both facts are decided in Rust — the page does not know which modes are editing modes or which buffer they type into. Without it the mode indicator would say INSERT while every keystroke vanished into a buffer nobody drew. - **`editable` is the server's answer relayed through Rust**: a stored cell. An aggregate is a drill target and drill is not on the wire yet, so it is simply not offered. - The inline editor seeds from `RenderCell::edit_text` — the STORED value, not the drawn one: `td.textContent` has been through the number format, so a stored 1200 reads `"1,200"` and committing that stores the *string* `"1,200"`. Enter removes the focused ``, which fires `blur`, which calls `close` again: harmless, but only by accident. - **The title bar is three fields and one joining.** `doc_name`, `doc_dirty` and `doc_stored_as` come off the cache `src/draw.rs::draw_title` folds; the joining — `name (stored_as) [+]`, in that order — is `shell.js::documentTitle`, which is the TUI's own spelling minus the `improvise ·` chrome and the right-hand key strip. The three were projected by improvise-z754 and painted by nothing for a wave, and `render_surface.test.js` could not see it: that check is a **substring search**, so a field named by a function nothing calls reads as painted. What sees it is `contract.spec.js`, which loads the page and asserts the text (improvise-5jna). The marker itself is a node test rather than a harness one, because the standalone deployment persists on every accepted batch and clears the flag in the same round trip, so `[+]` is transient there. ### The selection scrolls itself into view `web/app.js` called `scrollIntoView` nowhere at all, so a selection driven past the bottom of `#grid` stayed there, invisible, with no way back but scrolling by hand (improvise-jq8l). Two things about it are easy to get wrong. **It is not a projection bug and not a `row_offset` bug.** The row had been sent: `#grid` has `overflow: auto` and the page announces an extent from `ROW_PX`/ `COL_PX`, which **under-estimate** — a real row is 28.6px against the assumed 26 — and inline group rows are deliberately not charged for at all. So the page renders more rows than its own box can show, and the overflow is the DOM's, not the window's. Measured on `harness/fixtures/tall.improv`: 105px of overflow, the selected cell 96px below the visible bottom, `scrollTop` still 0. **`block: "nearest"` is the whole option.** It scrolls only when the element is actually out of view, and it scrolls the nearest scroll container rather than the document, which is what keeps the page itself from moving sideways. The reason this was untestable until now is the fixture: `web/demo.improv` is four rows and `harness/fixtures/grouped.improv` is eight, and neither overflows a 484px box. `tall.improv` is thirty rows in three groups, and the spec asserts the overflow exists **before** asserting anything about the scroll — without that line the whole test passes on a page that never scrolls. ### The pointer takes cells on `mousedown` A cell's handler is `mousedown` with `preventDefault()`, not `click`, and the reason is an ordering trap worth the paragraph (improvise-0x9u). From an **editing** state a `click` never arrived at all: the mousedown moved focus, the ``'s `blur` ran `close`, `close` repainted, `replaceChildren` destroyed the `` the mousedown had landed on, and no `click` was ever dispatched. The pointer was dead until the user pressed Escape or clicked outside the grid. Three things had to move together, and each is load-bearing: - **`mousedown`, so the handler runs before any focus change.** - **`preventDefault()`, so no focus change happens at all.** Otherwise the default action focuses `#grid` *after* the handler has called `input.focus()` on the new editor, and blurs it immediately. The keyboard listener is on `window`, so nothing needs `#grid` focused. - **`close` no longer repaints, and `blur` checks `closed` first.** Removing a focused `` fires `blur` **synchronously**, so a `close` that repainted from inside itself detached the very `` the handler was about to open an editor on. This is the same repair one level down, and it is why the first attempt at the fix still failed: the `blur` arm re-entered and rebuilt the table. `close` restores the **drawn** text rather than the seed, since nothing repaints behind it any more. The state the old spec could not see is the whole point: it pressed Escape before clicking the second cell, so it only ever clicked from a non-editing page — this repo's characteristic defect, a test that passes while the thing it names is broken in a neighbouring state. ### One handler per surface, and the node carries the answer improvise-b8ot, on the user's ruling: *one handler outside the area that gets redrawn, and dispatch events there*. The five clickable surfaces — the grid, the category tree, the two side panels and the tile bar — had one listener per **node**, created inside the render loops and reallocated on every repaint. The roots were already stable and that is what made it cheap: `replaceChildren` replaces an element's *children*, not the element, so every `el.*` entry survives every repaint and needed no new wrapper. **Not a performance change, and it must not be argued as one.** The cell count is bounded by the announced viewport rather than by the document, and nothing leaked — `replaceChildren` drops the nodes and their listeners together. The argument is design-principles §5: paint and wire, applied to the dispatch as well as to the paint. **The operands are carried on the node, never re-derived from the DOM.** `gridPlan` already decides `row`, `col`, `editable` and the class map, and its `row` counts **data** rows because a group header is not one. A delegated handler that walked up the table counting ``s would be a second derivation of exactly that, and it would disagree the moment a group header appeared. So the plan writes `data-row`/`data-col`/`data-edit`, `cellAction` reads them back, and the handler is a lookup. The split follows the bead's own division: **data attributes for operands**, the **existing `editable` class** for the predicate, and **ids only for the surfaces**, which they already were — a row or cell id would have to be unique per document and these are rebuilt per frame. Four behaviours a naive rewrite loses, all silent, all asserted in `harness/tests/contract.spec.js`: `mousedown` rather than `click` (above), `preventDefault()` **and only over a cell** — a header carried no listener before, so a blanket handler on the root is a behaviour change — the repaint that is **skipped** when an editor opens, and closing a live editor **first**, before the click is composed. **The editor's own `keydown` and `blur` stay direct**, deliberately: one transient node, one at a time, attached at creation and gone with it. `blur` does not bubble, so delegating it would need a root that does not own the input. ### The page bar and the totals strip improvise-p7lh, and the shape is worth more than the fix. `GridCache` has carried `page_coords` and `totals` since improvise-n10; the server projected both to every session, every client folded both correctly, the TUI painted both — and `RenderModel` had no field for either, so the browser painted neither. The shipped front page is `Quarter: page, Q1`, so it drew a grid filtered to Q1 with nothing saying so, while `[`/`]` — which resolve *locally* off that same `page_coords` — changed every number in silence. **Why the parity guard did not see it.** `web/tests/render_surface.test.js` asks that every field `RenderModel` exposes is painted. Its domain is `RenderModel`, which is the thing under test: a fact that never reaches it is outside the domain, so the check was closed under the defect. The honest question is one level up — every field of every widget cache reaches a front end, or is named as debt — and that is now asserted too. It would have fired when the fields were added. Two rules the painting itself owes. The **totals are relayed, never summed here**: the total runs over every row including ones a viewport scrolled past, which is why it is projected at all (gotcha 44), and an empty strip means "this grid has no totals" rather than a row of blanks (gotcha 33). And the **Page/Filter split is asked of `PageAxis::cycle`**, the one home of it: the plausible alternative, `items.is_some()`, agrees on both common cases and disagrees on an empty list, which is how a rule gets quietly respelled (`invariant(the_page_filter_split_lives_in_the_type)`). **Two residuals, named rather than left to be discovered.** The word `Total` is now spelled twice — `src/ui/grid.rs` and `web/shell.js::gridPlan` — which fails §5's own test, *would a third front end write this again*. It is kept local on the `indicator()` precedent (the collapse glyph is spelled in both ends while the *fact* is shared) and because the alternative is a protocol-level string table for one word; `invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key)` is where it belongs if a second such string ever appears. ### The page bar is a control (improvise-rw6l) It was display-only until improvise-rw6l, and this file said so, naming the missing `click_page` on the wasm surface as the reason. That is now built, and it cost less than the note implied: `PageAxis::items` was already projected and `ModelCommand::SetPageSelection` was already on the wire. The only gap was that `RenderPageAxis` **dropped the names** — it carried a position and a count, which is everything improvise-p1ym needed for `[`/`]` and nothing a menu needs. **The widening puts `items` inside `cycle`, not beside it.** That is the whole design: one predicate, not two. A Filter axis has no `cycle`, so it has no items, no down-arrow, is painted as a `` rather than a `