Files
improvise/context/code/clients.md
T
Claude (agent-faces) 92f8ac2113 docs(faces): where faces live and what the first two steps settled (improvise-nn4s)
context/code/protocol.md gains section 10 and tui.md's colour section now
describes face_style; the pointers to axis_fg, minibuf_fg and AxisColor
elsewhere follow. Evidence for the new tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 22:05:24 -07:00

206 KiB
Raw Blame History

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<Addressed>. 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 Commands, 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 Commands 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.

A read-only session refuses edits; it does not accept them and fail to save (improvise-j4qt)

A second tab on the same document cannot take the exclusive OPFS handle, so it boots read-only (improvise-i0v9). It reads the bytes, paints the grid, and navigates. Until improvise-j4qt, "read-only" described only the storage. The session was writable: an edit was applied to the tab's own in-memory App and projected, so the grid painted a value the document did not have (18,400.00 became 184,004,242.00). Only then did the automatic persist fail, with NOT SAVED replacing the read-only reason in the status. The first tab never saw the edit.

The fix sets the session's access at boot: Worker::read_only versus Worker::new, as a private Access. A read-only worker answers any ClientFrame::Commands that contains a Command::Model with Rejected(Rejection::ReadOnly) before the host sees it. So nothing is applied, nothing is projected, and nothing is persisted. Three things make this the right place:

  • It is decided up front, not observed afterwards. The persist failure is an observation, and it arrives after the paint. By the time a failed save can be seen, the user has already been shown the number.
  • The client's rollback is already correct for it (improvise-nxah). Rejection::ReadOnly answers refused_an_edit() == true, so the client pops that frame's entry and replays ViewState from its settled slice. No cache needs rolling back: Command::Model is a no-op in reduce_view, so the optimistic fold never painted the value. The paint came from the host's projection, and that projection is exactly what the refusal prevents.
  • Only the document is refused. A frame of view commands, such as a cursor move, a panel or a help page, is answered as before. Read-only is a claim about the document, not about this client's own slice.

It is a property of the boot, not the backend. The backend in both cases is a vfs::ReadOnly, and worker.rs's a_persist_that_fails_raises_a_notice… still uses one as a disk-full injection over a writable session. Reading access off the storage would have made those two indistinguishable. ClientFrame::Save is unchanged: a read-only tab that asks to save is still answered SaveFailed by the backend.

What this does not do is i0v9's gap 2. There is still no upgrade path when the first tab closes, and the tab stays read-only until it is reloaded.

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<Addressed> 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 Hosts 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<dyn Storage>), 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 → Commands

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 needed (target, target_category) while FormulaCache carried only the formula's raw text. Closed by improvise-tej1: since km71 the cache carries a FormulaId per formula, and ModelCommand::RemoveFormula now takes that id.
  • 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 HashMaps, 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<RecordHash>, 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.

The re-entry is reopened_at, and it declines where the host's EnterEditAtCursor declines (improvise-enz6). The host asks core::uneditable of its layout. The browser asks the landing cell's projected CellId, which is the same answer, because cell_id is what fills it. Asking the cache has two consequences:

  • The wording differs on a fold. The host can tell a fold from any other aggregate and names the fold. The browser sees only CellId::Aggregate, so it gives the host's generic aggregate text. lockstep.rs compares the mode, the cursor and the buffers here, never the status text.

  • A landing past the window is decided when the host answers (improvise-5m9z). The cache holds only the announced window, so a Down from the window's last row lands on a cell with no projected id yet. reopened_at opens the editor there provisionally, and the fold settles it: ClientState::settle_declined_editor runs after every fold. It closes an untouched editor whose cursor cell the host has now projected as one i declines, and says why. The client then ends where the TUI does. Both ends ask the same resolve::declined_at, so the immediate decline and the late one cannot disagree.

    The decline waits for the host's answer, as #227's awaiting seed does. It does not choose a seed, which stays seed_at's and #227/#272's.

    A touched editor is kept. If the user typed before the answer came, the editor stays open with their text. Closing it would throw input away. Its commit is then refused out loud (improvise-ik6l): refused_here declines the commit-cell-edit or commit-and-advance-right step, which takes the whole binding with it. That includes the welded clear-buffer, as the TUI's failed committable_cell_key does, so the status is said and the mode and the typed text stay. Before this fix the commit composed nothing, and Enter went Upstream, which no host answers: a silent dead key.

    declined_at reads the cache through the frame's own origin (AxisFrame::first), never the cursor's offsets (improvise-y5eb). The settle also runs when the client folds its own outgoing frame. By then the offsets have moved but the cache still holds the old window. Indexing it with the new offsets read the wrong cell. A Down that wraps from the bottom row to the next column's top read the old window's top row, and closed an editor on Revenue/Feb with Profit's formula refusal. Read against the frame's origin, a stale cache answers None ("not projected yet"), and the host's own reply decides. The earlier version of this section called that aliasing unreachable. It is reachable, and the wrap is the case that reaches it.

    The settle is not specific to advancing. An untouched editor also closes when the host re-projects its cell as declined for any other reason, such as another client adding a formula over it.

    Every cache read goes through the frame's origin, not only declined_at (improvise-7mro). cell_at_cursor and seed_at read through projected_at too, and the cursor-offset read cell_at is gone. Before this, every other read had the y5eb aliasing. A client folds its own scroll at once, but its cache holds the old window until the host answers, so a key resolved in that gap read another cell. Pipelined $ i 7 Enter in a one-column window wrote Jan/Revenue where the TUI wrote Feb/Revenue: the seed and the write's target both came from the cell at the old window's index. G i and gg i refused an editable cell with a formula's refusal.

    The aliasing had a second form, inside the held window: after k k j the cursor is on a cell the cache holds, but the offsets no longer match the frame's origin, so index arithmetic through the offsets named the next row. Reading at the frame's origin answers both forms with one rule.

    A cell of the grid outside the held window is unprojected: unknown, never "the cell at the same index". Inside the grid's extent but outside either frame's range. An empty grid has nothing to wait for, so it is not unprojected. What each key does on such a cell:

    • i opens an editor awaiting its seed (OpenedEditor::awaiting()). The fold settles it once the host projects that cell (settle_awaited_seed), and never over typing. The settle waits for a cell with an id. Between a frame and its cells, a relabel leaves a blank CellDisplay at the position, and settling on that seeded "" before the cell arrived. A declined cell closes through settle_declined_editor, which runs first.
    • A commit, x, yy and p wait out loud. refused_here declines the step with "waiting for the host to show this cell", which keeps the mode and the typed text, as ik6l's refusal does. Nothing is composed: the write target is unknown, and guessing it is the bug. Pressing the key again after the host answers does the write. This is the Codex finding on #282.
    • An advance's reopen keeps #282's shape. It reads seed_at, which is "" for an unprojected landing. On this base the keymap's welded clear-buffer empties the reopened buffer anyway, in the TUI too, so an awaited seed there would be written into a buffer the TUI has cleared.

    What a held reply still costs. With every reply held, the browser cannot know a seed the TUI shows, so G i 7 types 7 into an empty editor, where the TUI types 47. The write lands on the TUI's record once the host has answered, never on another. Matching the TUI exactly would need the client to defer keys until the projection arrives. That means on_frame handing queued frames back to web/app.js, which is a change to the page's surface and is not made here.

    Merging with #227/#272 and #274. #272 adds the same OpenedEditor::awaiting field and an awaiting_seed for the editor an advance reopens after a write, settled once no write is unanswered. Merged, the settle must require both: no write unanswered, and the cell projected (projected_seed). With #227's commit-owned clear, the reopen then awaits for an unprojected landing too. #274 captures the committed cell in settle_opened through opened_at_cursor, which reads cell_at_cursor. Without this fix that capture aliases as well: a horizontal wrap with replies delivered writes Jan/Revenue on #274's head. With cell_at_cursor reading at the origin, the capture waits for the projected cell, because settle_opened retries on every fold.

    When CellId::NonItem lands (#265), both declined_at and core::uneditable need an arm for it. Both matches are exhaustive, so the compiler will ask. The trap is answering in the None arm: a non-item cell would then open an editor whose commit writes a ghost record.

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<Command>) — 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 Effects: 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

resolve::NEEDS_THE_HOST names them, and the_command_names_that_still_need_the_host_are_these drives each through the key that reaches it. 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)).

improvise-fo0w took four names off it and left three (exit-search-mode, force-quit, toggle-group-under-cursor). Two of the four now resolve, and two now decline.

  • enter-advance (Normal Enter) resolves. It is cursor arithmetic over GridExtent: down, then to the top of the next column. The host's bottom-right AbortChain becomes Ignored on the client. Nothing is lost, because Enter is bound alone and never inside a Sequence, so there is no later step for the abort to cut.
  • hide-selected-row-item (H) resolves to the one ModelCommand::HideItem the host's effect is, over row_frame.cats[0] and the selected tuple's [0]. That is the outer category and its item, exactly as the Cmd reads row_cats.first() and items[0]. On a fold the client refuses in the host's own words. It knows a fold from AxisFrame::folds, the layout's own per-item flag carried on the frame, never from the nearest group header: under two row categories that header can be the inner one's (improvise-4k1j, context/code/protocol.md). That is why FOLD_REFUSAL moved from the command layer into improvise-protocol: one string, not two copies.
  • search (/) and search-navigate (n) decline out loud. n scans the whole model, and a client holds one window. / alone would open a box that can find nothing. Shipping / without n is the trap the ticket names, so both decline until search can find.

Declining is not silence, so the two search rows are no longer on the list, and the help page stops marking them. They are in the same class as ZZ: pressed, they say why.

The help overlay marks them, and it marks rather than omits (improvise-uln5). The overlay is shared content, and before this the browser painted every row plainly, including six that do nothing there. That is worse than a gap: improvise-koh6 showed that the keys typed after a dead one land on live Normal bindings, and i opens an editor. The browser's RenderHelpRow now draws a row whose command is on the list as dim, and appends "(not in the browser yet)" to its description. It does the same for every ex-command row while : opens a command line this client cannot run (resolve::runs_the_command_line, which is stranding's answer, not a second one).

Marking was chosen over omitting for three reasons:

  • Both front ends draw the same rows. A page with rows missing is a second layout of the same content.
  • The feature stays findable. The row still says it exists in the terminal.
  • It needs no change in web/. dim is a kind the page already paints.

The TUI is unaffected, because the marking lives in the browser's renderer.

One list, held from three sides. NEEDS_THE_HOST is production data because the renderer needs it. Three tests hold it to what resolve actually does:

  • the_command_names_that_still_need_the_host_are_these fails if a name on it resolves locally.
  • every_help_row_the_browser_cannot_run_is_one_it_marks fails if a help row is silent in the browser without being on the list, or on the list without being silent. It also fails if a hint line ever names a listed command, because the status bar is never marked.
  • every_silent_binding_is_on_the_list fails if any binding in any mode a browser can reach is silent and unlisted (improvise-oyaa, s2gb). It walks the keymaps, parents and prefixes included, because the help rows are only a subset: no help row names Ctrl-Q, so before it force-quit could leave the list with every test still passing. Which modes are reachable is derived by pressing, starting from both halves of Normal. A door that declined shuts therefore drops out with no list to keep.

every_advertised_command_answers_in_its_own_mode therefore no longer restates the black holes. It skips a silent row whose command is on the list, because the other test owns it.

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) gives ToggleGroup's (category, group) as (cats[level], name) (improvise-e265). 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 left this list with improvise-tej1: the position it names is in the formula panel, and the id the panel's cache now carries is enough to resolve it.
  • 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 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. fdk1 has since closed it: the coordinate is now the one records cell the browser edits. See "A coordinate rename" below.

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 name the same missing feature in the same words — not_yet("drill") — because they reach the same one. In the TUI, EditOrDrill hands an aggregate straight to DrillIntoCell, so an aggregate is writable there, by drilling; i on it is drill by another door. i's sentence adds only why it drilled rather than edited ("an aggregate is edited by drilling in — …"), and > still says nothing about aggregates, because it is refused whatever the cell is.

    This reverses what this bullet said until improvise-s8h7: that the two refused with different sentences on purpose, "this key is not built yet" against "this cell is not yours to write". The second half was false about the application — nothing refuses a write to an aggregate; the TUI drills — and the separate wording hid from a browser user that i and > were one missing feature. The help page's edit-or-drill caption carried the same error, calling i only "Edit current cell"; it now says it drills into an aggregate, which is true in both front ends because the browser's refusal names drill. Delivering drill is improvise-obtu's, not this bullet's.

  • The commit's write is not spelled again here. It is typed_cell_command's, the same one click-to-edit reaches through typed_target_frame, 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 Commands. 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 why a formula-row click opens the editor. Until improvise-km71, ModeKey::FormulaPanel bound a/n/o and d/Delete and no Enter at all, so a formula-row click only navigated, and opening the editor on a click would have been an activation invented for the pointer that the keyboard did not have. km71 bound Enter (and e) to edit-formula-at-cursor, so the rule now yields the editor on the clicked row, and click_formula_row ends with edit_formula_at(index) exactly as click_view_row ends with switch_view_at(index). The guard is aimed at what Enter resolves to, not at the keymap and not at the click: a_formula_row_click_ends_with_what_enter_does_on_that_row resolves Enter at cursor 1 and asserts the click on row 1, made with the cursor at 0, ends with those commands. The earlier guard asserted only that Enter was unbound, which tied the two by a comment; this one fails if either side changes without the other, and it separates "reads the clicked index" from "reads the cursor", because the two point at different rows. The superseded test was the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does.

Editing needs the client to name which formula, and FormulaCache used to carry only the raw text, from which neither the target nor its category is recoverable without reimplementing parse_formula on the client — the gap decision(improvise-e24v) recorded for delete-formula-at-cursor. km71 closed it the gotcha 47 way, by enriching the projection: FormulaCache::formulas is Vec<FormulaEntry { id: FormulaId, raw }>. The id is opaque on purpose (context/code/core.md §18). improvise-tej1 then composed delete-formula-at-cursor in the browser on the same id. RemoveFormula names its formula by FormulaId on the wire and in the effect, and a delete whose id no longer resolves is refused with the batch stopped, where it was a silent no-op: a frame that named a formula someone else removed is rolled back whole (Host::mutating_frame) and answered Rejected(MutationRefused). The script command remove-formula <category> <target> resolves its names to an id in execute and refuses out loud when nothing matches.

One divergence is pinned rather than closed: a rename. The host's ReplaceFormula rewrites the status to say what was renamed and which formulas still name the old target; the client cannot know the old target (the id is opaque) and folds the optimistic "Formula updated". renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host asserts both sides of that, as a_formula_the_client_cannot_parse_diverges_on_the_status_line does for a parse error.

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 <input> 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. Since improvise-km71 the activation is real rather than invented, so the spec's #mode assertion reads "FORMULA" and it also asserts that #minibuffer holds the clicked formula's text; the argument about #grid input is unchanged.

Rule 2 — a click is all of its intents or none of them (improvise-hkr8). Each click_* returns Option<Vec<Command>> 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 with tile_cat_idx at the tile opening_tile picks (the first Regular category, improvise-oplp; see context/code/command.md), so a click on that tile is a click on the already-selected one and cycles on the first gesture. Before improvise-oplp this was the leftmost tile, _Index. 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 ViewStates 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.

The optimistic fold is put back by the fold, not by a saved cache (improvise-nxah)

Client::key folds a locally-resolved batch before sending it, so the page redraws on the keystroke rather than on the round trip. If the host then refuses the frame — ADR 001 clause 4 means it applied none of it, and restores every mirror to the pre-frame state — the client has moved and the host has not. Measured against a real Host: after a refused commit the client sat one row down in Normal-ish re-entry with its edit buffer already cleared for the next cell, while the host's mirror of that same client was still at the origin holding the user's typing.

Three things decide the shape, and the first is the one that is easy to get wrong. What rolls back is ViewState, the thing the fold produces — never a cache. Restoring a cache would be a second answer to "what does this client hold" beside the fold's, which is the defect design-principles §5 names and improvise-edp is the worked example of. The caches need nothing anyway: a Command::Model is a no-op in reduce_view, so the optimistic fold never touched them, and everything a successful frame does to them arrives as projections. Note the corollary that makes the bug report's own evidence wrong: page_coords cannot diverge this way, because it moves only under Projection::Grid. What diverges is the mode, the cursor, the status and the buffers — the view commands that ride along with the model command.

The whole slice goes back, and that is what serves improvise-3al1 rather than fighting it. The 2026-09-21 wave recorded these two tickets as in direct contradiction, on the ground that restoring a snapshot would wipe ViewState::buffers — the user's typing that 3al1 requires stay staged. That is measurably not so, and the reason is the first paragraph of §4 in context/code/protocol.md: only a frame carrying a Command::Model can be refused. The typing frames are SetBuffers, carry no model command, are never refused, and have already settled. Rolling back the commit therefore restores the buffer to its pre-commit value — which is the typing, intact. The test asserts that explicitly rather than leaving it as an inference.

A refusal drops one frame and replays the rest. ClientState keeps a settled slice — everything the host has answered — and an in_flight queue of the frames sent since, each marked awaited exactly when the host will answer it. A refusal pops the head and rebuilds view from settled plus what remains. Restoring a single saved snapshot instead is unsound under pipelining: two mutating frames may be outstanding over a socket, and a snapshot taken before the first discards the second, which the host did apply. A frame the host will not answer settles as soon as nothing older is waiting — only from the front, because settling it out of turn would lose the order.

What the host relays is settled too, and it settles after the frame it arrived during (improvise-jpba). settled used to take only this client's own frames. Relays never reached it: a ResetCursor, the SwitchView sent when the view a client is on is deleted, and the placement echoes after a reframe (improvise-wdmp). The host has already applied each of them to this client's mirror, so the next refusal rebuilt view from a settled slice that had never heard them. A client whose reframe was accepted, and whose next frame was then refused, went back to its pre-reset cursor while its mirror stayed at the origin. That is the same client/mirror split wdmp closed, reopened by the rollback. fold is the door for what the host sends (fold_outgoing is the door for what this client sends), so fold now records every non-projection command. It goes straight into settled when nothing is in flight. Otherwise it is queued as an unawaited Sent { relayed: true } directly behind the head frame, after any relays already queued there. accepted and settle_sent then settle it in the right order, and replay replays it. Every answer settles, the refusal included (improvise-3kfc). refused pops the head and then calls settle_sent before it replays. Without that call, an unawaited entry behind a refused head became the front while still unsettled, and the next answer popped it instead of its own frame. That entry can be a relay or this client's own view-only frame, and the view-only case predated relays.

A relay that queues also rebases view (improvise-xd3q). Recording a relay in settled fixed only the refusal. On the accepted path, fold still applied the relay straight onto view, on top of the frames this client had already sent behind the one being answered. A pruning commit is answered with ResetCursor and an echo of its own placement (improvise-wdmp). A j sent before that answer arrived had already been folded, and the reset clobbered it. The echo re-sent only the pruning frame's placement, because the host's asker_reset is per frame. So the client sat at (1,0) while its mirror sat at (2,0), and the next write landed on West where the TUI writes East. The queue already held the right order, [head, reset, echo, j], so fold now rebuilds view with the same replay a refusal uses whenever a relay is queued. That is the settled slice plus everything still in flight, in order. With nothing in flight, the relay is applied directly, as before.

It is a replay from settled, not "apply the relay, then re-apply what queued behind it". Re-applying reruns every frame behind the relay a second time. That looks the same for idempotent setters like SetSelected, but ViewBack pops a stack, and applying it twice stepped a client back two views while its mirror stepped back one. The host could not have fixed this. It cannot know whether a frame was composed before or after the client heard the reset, and only the client knows what it sent.

What the client says to itself queues too (improvise-n0pb). A refusal's sentence ("…your edit was not applied") is written by the client, not sent by the host. It used to go straight into view.status_msg, after refused had rolled back. That put it in neither settled nor in_flight, so the xd3q rebase erased it the next time a relay queued behind a frame still in flight. A frame in flight that carried its own SetStatus was replayed over it. The sentence is a local event that happened at a moment, so it takes its place in the queue at that moment. ClientState::notify queues it as a view-only entry at the tail, which is what fold_outgoing does with a frame the host never answers. This gives it the right order against everything else:

  • Every frame sent before it replays first, so none of them overwrites it.
  • A relay queues behind the head, so it lands before the sentence and cannot erase it either.
  • A frame sent after it lands after it, and may replace it. That is the same order in which the user saw them.
  • It settles once everything ahead of it is answered.

Two simpler fixes were rejected:

  • Writing the sentence into settled as well: the next rebase replays the in-flight frames over it, which puts the sentence before frames it followed.
  • A status channel outside ViewState: this would be a second answer to "what does the status line say", which every renderer would then have to merge.

Two simpler shapes were rejected, and each fails the Host-level test:

  • Advancing settled directly gets the order wrong. The head frame's own commands settle only when it is accepted, which is after the relays that arrived during it. So for [SetRowOffset(1), <reframe>, SetSelected], settled ends at row_offset 1 while the mirror is at 0, because the reset was settled before the offset it cancelled.
  • Appending the relay to the head frame's own commands loses it whenever that frame is refused. The frame is dropped, and the relay goes with it.

Why "behind the head" is right: the host answers frames in order, so a relay that arrives before the head's answer happened on the host before or during the head frame. A relay from the head frame itself (its own reset and echoes) is only ever sent when that frame is accepted, since undo_frame sends no relays, and it belongs after the frame's commands. A relay from another client's frame that the host processed before the head also lands behind the head, which is one position late. That is a pre-existing pipelining residue and is not addressed here. A reframe by someone else before the head frame usually makes the head stale anyway, which the host refuses, and on refusal the relative order of the two no longer matters.

What this does not do is force. A rejected batch now keeps its values and leaves the user where they were; re-composing the same intent against a current basis at one keystroke is still improvise-3al1, and it is unblocked rather than done.

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 a Face 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).

A fifth None is not a failure: the text is the seed the editor captured when it opened (improvise-ixxx). The seed is part of EditTarget, captured by edit_target_at from the cache's edit_text beside the record and its version, so the page passes no seed and cannot pass a wrong one. typed_target_frame asks resolve::untouched, the one home beside typed(), before anything else. The argument is in protocol.md §3, "An untouched editor writes nothing".

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.

The page commits by identity, never by position (improvise-2wu3). While an inline editor is open, repaint returns early, so the screen is frozen. But on_frame goes on folding, so the cache moves while the screen does not. The page used to keep the editor's viewport (row, col) and resolve it against the current cache at commit. If another client had hidden a row above, that position now held a different record. basis.claim was satisfied too, because the fresh Projection::Basis had been folded along with the rows. So the write landed on a row the user never looked at, and it was reported accepted. The basis was current for the client; the stale thing was the screen, and nothing measures that.

So the JS surface is now two calls. edit_target(row, col) returns an EditTarget as an opaque string, and the page takes it when the editor opens. typed_cell(target, text) composes the write against it (typed_target_frame). The position-taking composer is gone from the page's surface, so the confusion cannot be written in the page. typed_cell_frame(row, col, …) survives as Rust-only sugar over edit_target_at and typed_target_frame, for tests that compose at the moment they read.

The target carries the version the user saw, not only the identity (improvise-0j0f). The first version captured the RecordCell at open but claimed the basis at commit. The fold keeps running while the editor is open, so another client's write to that same record bumped the counter in the client's BasisCache. The commit then claimed the fresh counter and was accepted, and the other write was silently overwritten. If the other client cleared the cell instead, the commit re-created it. An EditTarget is { document_generation, cell, version, opened }: the document and the record's counter as the client had them when the editor opened, and the seed it opened on (improvise-ixxx, above). BasisCache::claim_as_read states both beside the current layout_version.

The two document tiers are not alike, and only the layout stays current. The rows renumbering under the editor, which was 2wu3's case, moves layout_version. Claiming that at open would refuse every such commit as LayoutMoved, although the identity already makes the commit safe. The generation is the opposite case (improvise-vndi). A RecordHash is derived from coordinates, so after another client's LoadModel the same record can resolve in the incoming document, and a fresh DataStore's counter can equal the one the editor read. Claiming the current generation then got the commit accepted: the loaded document's value, which this user never saw, was overwritten. Nothing composed against the outgoing document names anything in the incoming one, which is Rejection::DocumentReplaced's own contract, so the generation is captured at open and the swap refuses the commit. So the result is:

  • the document was replaced: DocumentReplaced;
  • the record moved: RecordMoved;
  • the record is gone: RecordGone;
  • only the rows moved: the commit is accepted.

Clearing the cell under an open editor is RecordMoved, not RecordGone (improvise-4img). The store no longer holds the record, but App::resolve_record falls back to the layout of the view the editor opened on, and that grid still draws the row. So the record resolves, and its counter has moved. RecordGone needs a record that neither the store nor that layout resolves.

EditTarget is the one object an editor captures at open, and it is a struct so it can grow. Three fields have joined cell and version since: the seed as opened (improvise-ixxx: a commit whose buffer equals the seed writes nothing), the generation (improvise-vndi), and the view whose grid drew the cell (improvise-wpxr: an empty record resolves only against that grid, so the commit names it, captured at open like the version). The page did not change for either, because the target is opaque to it. typed_target_frame asks the two in that order: an untouched editor writes nothing, so it is never refused.

typed_cell_command is the one composition shared by the mouse path (typed_target_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. (The TUI no longer reads it: it draws a face per text-entry mode, and improvise-nn4s.4 moves the page there.) 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.

An editor is seeded from edit_seed(target), the stored value the target captured, never from text: seeding from the formatted text and committing turns 1200 into the string "1,200". RenderCell carries no edit_text since improvise-ugj4, because a paint can be stale while an editor is open. 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.

That shape is only sound where "open" implies "non-empty", and help is the one place it does — its content is static. views and formulas borrowed it and were wrong: a document with no formulas opened a formula panel that built the same RenderModel as a closed one, so the page hid it under a mode reading FORMULAS (improvise-df2w). Both are now Option<Vec<_>> — None closed, Some(vec![]) open and empty — one field, so the two facts cannot disagree, where a formula_panel_open flag beside the rows (the category_panel_open shape) would have been two. The same mistake as improvise-xg1z: an absence spent on encoding a second fact. What the page does not yet draw is the TUI's empty-state line ((no formulas — press 'n' to add)); that string lives in src/ui/formula_panel.rs, and copying it into render.rs would be a second home for it.

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 Face → draw.rs::face_style 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. These 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.edit_target(r, c) what an editor opened here edits (the record, the version seen, and the seed it opened on): an opaque string the page holds, or null
client.edit_seed(target) the text the target's editor opens on: the seed it captured, never the paint (improvise-ugj4)
client.edited_target(target) the same target marked edited, so retyping the seed still writes (improvise-jibp)
client.typed_cell(target, s) the frame for "the user typed s into that cell", by identity (improvise-2wu3), or none if s is still the target's seed (improvise-ixxx)
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.

Success is claimed by the first frame, and only if nothing failed first (improvise-i0v9). The worker transport used to write in-page worker · <doc> the moment it spawned the worker — 13ms after navigation, before anything had answered — so a worker that could not start showed a success-looking status over a blank page. It now writes starting the worker · <doc>…, and createShell().arm(message, { answer }) holds the claim until sawFrame. The rule the claim obeys is the watchdog's own state: it is written only if the watchdog is still armed when the first frame lands. That is not a convenience. worker.js posts a ReadOnly (or SeedMissing) notice straight after start() and before the Snapshot that answers the handshake, so an unconditional "first frame ⇒ say we are up" would overwrite the precise reason the notice just wrote. fail already disarms the watchdog, so "armed" answers "has anything been said since arming" without a second flag to keep in step. fail disarms directly rather than through sawFrame, or it would briefly flash the success text on its way to the failure. The starting text deliberately does not contain in-page worker, so the older specs asserting that substring now need the first frame to pass. The socket path passes no answer: open is already a real fact, and its status was never premature.

One consequence changes what the watchdog guards. In the read-only case a Snapshot always follows the notice, and sawFrame disarms the watchdog either way, so a notice that forgot to disarm would not be overwritten eight seconds later. It is overwritten at once, by the claim. The eight-second overwrite survives only where no frame follows: BootFailed.

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 client.edit_seed(target) — the STORED value the target captured, 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 <input>, 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 <input>'s blur ran close, close repainted, replaceChildren destroyed the <td> 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 <input> fires blur synchronously, so a close that repainted from inside itself detached the very <td> 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 <tr>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 <span> rather than a <button>, and composes nothing if a click somehow reaches it — four answers off the one home gotcha 27 already named. The tempting alternative, a separate items field on RenderPageAxis, gives the page a second thing to test and a way to disagree with itself.

The arrow follows navigable, never "is this on the page bar". A Filter axis is on the bar and must get no arrow: offering a menu that cannot do anything is worse than offering none, because the user spends a click finding out. A fixture with only a Page axis cannot see that distinction, which is why every test of it — Rust, node --test, and the two in the harness — carries a Filter axis beside the Page one wherever the shape allows.

The menu lives outside #pages, in its own #page-menu element. #pages has its children replaced on every repaint, and a menu rebuilt under the pointer is a menu that closes itself mid-click. Being outside also means a repaint is free to close it explicitly, which is the honest answer: the chip it was anchored to has just been rebuilt, and re-anchoring would be a guess.

Two surfaces, one meaning. improvise-b8ot's ruling is one delegated handler per surface on a stable root, operands off data attributes, no structural inference — so the chip carries data-axis and a menu row carries data-axis and data-item, and shell.js::pageItemAction is one function over both. That single attribute is the entire disambiguation. The case that has to be got right is an empty data-item: Number("") is 0, so a naive read turns "no item" into "item zero", and clicking the chip of an axis already on its first item would silently re-pick it instead of opening anything.

The menu owns the keyboard while it is open, which is the third state of the kind editing already was. app.js's window keydown handler returns early for editing; it now returns early for pageMenu too, into menuKey. That is the same mechanism rather than a new one, and the reason is specific: the menu hangs over the grid, so letting hjkl through would scroll the thing it is pointing at out from under it. menuKey therefore answers every key, with ignore as the default — an unlisted key is swallowed, not passed on — and count: 0 closes rather than wrapping modulo zero, because a repaint can empty an axis under an open menu.

active and selected are two different facts and both are drawn: selected is what the document is fixed to (a check mark), active is where the keyboard is. They start together and part on the first arrow key.

The tile bar says "Page", not "Pag" (improvise-771e)

Axis::short_label returns three characters so the TUI's tile bar aligns in a fixed-width terminal (gotcha 26); "·" for None is an answer there and not a truncation. A browser has no column to fit, so the abbreviation buys nothing and reads to a user as a rendering defect — it was spotted in PR #120's screenshots as "Quarter Pag". render.rs now asks plain_label, which is the other vocabulary already on Axis: the decision stays in the shared layer and only the choice of vocabulary is local, exactly the Face → ratatui::Style shape §5 blesses. short_label is untouched, because the TUI's width is load-bearing.

Where the truncation was actually pinned is the useful part. The ticket says to check tests/grid_frame_agreement.rs first, in case it fixes the tile bar's vocabulary. It does not mention the tile bar at all. What held "Quarter Pag" in place was harness/tests/drill.spec.js, which asserts the tile's text exactly — a Playwright spec in a directory the ticket never names, asserting the bug as its expected value. A rendered string is pinned wherever anything reads the screen, so the search is for the string, not for the function that makes it.

The keyboard

Client::on_key_event classifies the event and folds it locally where this client may answer it — text entry, and since improvise-bpq2 the grid, panel and tile cursors too, which is why sendViewport must call set_viewport and not Client.viewport. Anything it cannot answer becomes a ClientFrame::Key, which Host::handle answers with an empty vec: those keys are sent and do nothing. That is settled rather than unfinished — the host must not know the keymap — and closing one means teaching the client, not that arm.

The page does not claim a key it did not answer: a frame means the page claimed it, so the browser must not also act on it — an arrow that both moves the cursor and scrolls the page is the worse of the two errors. Combinations under Ctrl or Alt are left alone regardless: those are the browser's own shortcuts, and the client resolves nothing under them. BrowserKey drops anything held with the platform meta key precisely so Cmd-R does not arrive as a bare r; the page does not claim it either.

The deployment notice, and the one thing this page remembers

#backend-notice is a <details>. The collapsing is therefore the browser's — keyboard operable, and correct with app.js absent, still loading, or thrown — and the only thing the page adds is remembering the answer. That split is the whole design: a static notice is what it was, and a notice whose open state is decided in JavaScript would be a fourth thing that can be wrong on a page whose job is not to be wrong about its own peer.

It starts collapsed for a visitor who has never answered, and the argument is that collapsing withholds nothing. The one fact a first-time visitor needs — the document lives in this tab, is saved to this browser's private storage, and never leaves the machine — is the <summary>, which never collapses. What is behind the disclosure is ?doc=, ?seed=, ?ws=, the wss:// default and the reverse proxy a plain-ws:// server needs: configuration reference, looked up rather than read, and irrelevant until a visitor has decided they want a second document or a shared one. Expanded, it cost about 230px of an 820px window and pushed the tile bar, the category tree and the grid below the fold on every visit. The page is an application; it should open as one.

The state is read through shell.js and stored in app.js, on the usual split: what a stored value means is a decision and is noticeOpen, while touching localStorage is a browser API and stays in the page. localStorage throws in a private window and in a context with site data blocked, and returns null having simply forgotten, so every access is wrapped and there are exactly two recognised spellings. Everything else — absent, empty, a value an older page wrote — is unanswered, which is the default rather than a third state: a page that treated forgetting as its own case would have to decide what to do about it, and there is nothing to decide.

What is stored is per-viewer and per-origin by construction. It never reaches Rust, never crosses to a peer, and is not state the application has any opinion about — which is exactly the class browser storage is for, and exactly why it is not a ViewState field (gotcha 30).

The page's own keydown listener must not claim the summary's keys. Enter is claimed by the client (it is forwarded upstream), and the listener calls preventDefault() on anything claimed — so without an explicit guard the disclosure would open by pointer and be dead by keyboard, which is the whole reason for using <details> rather than a <button> and a class. The guard is the same shape as the inline editor's: the control that has focus owns its keys.

Where the minibuffer is drawn, and why that is not a second derivation

The TUI draws the minibuffer on the bottom line, instead of the status line. The browser has always drawn it in its own element, because #status carries the transport's failures and a repaint must not erase one (gotcha 48) — but it drew it as a 14px line in the page's bottom-left corner, which in a terminal is a convention with decades behind it and in a browser window is where nothing is read. With the grid scrolled, the cell being typed into can be 800px from the text showing what is being typed.

So it is now a raised bar: fixed, centred over the grid, larger, bordered in the mode's own colour and shadowed. Every part of that is paint. The content is RenderMinibuffer, built in render.rs from state.view.mode.minibuffer() — the same semantic value the TUI reads — and the colour is MinibufColor resolved to a concrete colour by style.css. (The TUI now draws a face per text-entry mode instead; improvise-nn4s.4 moves the page onto the same faces.) Nothing here asks which mode is open, which buffer is being typed into, or what the prompt says; a second answer to any of those is the disease design-principles §5 is about. The caret is drawn as a CSS ::after rather than appended to the text for the same reason: the text is the host's, and the page adds nothing to it.

pointer-events: none is load-bearing — the prompt floats over #grid, and one that ate a click meant for a cell would be a new bug introduced by paint. Since improvise-n6t8 it sits on #bottom-dock rather than on #minibuffer itself, because the dock is now what covers the grid; the property is unchanged and the declaration moved up one level rather than being duplicated.

The bar is reserved, not fitted, and the toast is out of flow (improvise-n6t8)

The user's report was two symptoms and one rule. The symptoms: "the top bar jumps around a lot" when a message arrives, and "the normal/categories/etc. pill changes sizes constantly". The rule, which is the deliverable: "there shouldn't be motion of layout elements I expect to be fixed."

Four things in <header> were sized by their own content, and they compounded, because they all change on the same event. #mode is help::mode_label, and NORMAL, CATEGORIES and NEW CATEGORY are 6, 10 and 12 characters. #hint is help::hint_line, one line per mode. #doc-name gains and loses [+] on every edit. #view-name had :empty { display: none }, so it appeared out of nothing on the first frame. So the bar reflowed on a mode change with no message at all, which is why a toast alone would not have fixed the complaint.

The answer is a reserve: every child that changes with the user's own keystrokes has an allocated width, and #hint is the single elastic child, so nothing is positioned by it.

The mode pill's reserve is a floor and deliberately not a cap, and that is the part worth carrying. min-width: 21ch holds sixteen characters where the longest label today is fourteen (RECORDS INSERT) — but a width or a max-width would absorb a seventeenth character silently, by overflowing inside a box that never grows. A floor lets an over-long label push the bar, which is the only thing a bounding-box measurement can see. A reserve that caps is a reserve that hides its own failure.

That matters because of where the seam is. AppMode's per-variant facts are exhaustive-match methods, so the compiler makes you write mode_label for a new variant — and nothing anywhere checks that the CSS still fits what you wrote. harness/tests/header.spec.js is that check, and it is the deliverable as much as the stylesheet is.

The status line was the compounding one, and it was not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out of #status, which wrapped to a second and then a third line and grew the whole header by a row. This was not predicted; the spec found it on its first run. The fix is flex: 1 1 0 on the hint — from a zero basis it absorbs the remainder and contributes nothing to the overflow — and white-space: nowrap plus flex: 0 0 auto on #status, because a second line there is a taller bar, which is the one thing this whole section exists to prevent. app.js puts the whole text on title, so the max-width backstop loses nothing.

The two status facts now go to two elements, and composeStatus is gone. #status carries the transport's fact alone — "in-page worker", "connected", "disconnected", "cannot reach …" — which is not transient and never auto-hides. ViewState::status_msg is the toast's. That is a stronger form of the rule gotcha 46 records than the · join was: a repaint reaches createShell's onModel and cannot address onShell at all, so a connection error cannot be erased by construction rather than by a convention the join kept. The join's own unit test was replaced rather than deleted — a separation with no guard is how one silently regresses into a single sink.

The toast is out of flow entirely. #bottom-dock is position: fixed, so nothing inside it can push the header or the grid, and it is anchored at its bottom and stacks upward. That is what answers "what happens when the minibuffer and a toast coincide": the minibuffer sits exactly where it sat, the toast appears above it, and the prompt is neither covered nor displaced. A top-anchored stack, or a toast positioned by an offset from the minibuffer's height, would both have moved the prompt.

Dismissal keys off message identity, and two identical messages show once

This is the trap, and a timer alone walks straight into it. ViewState::status_msg is state, not an event: repaint() calls shell.setModel(model.status) unconditionally, and it runs on every revision bump — which is every keypress that moves anything. Nothing in Rust clears the string; it is replaced only when a later command sets one. So a toast hidden by setTimeout resurrects the moment the user touches the keyboard, which is worse than a message that simply stays, because it reads as a new event.

createToasts therefore keeps what it last accepted, and keeps it after the toast hides. An offer equal to it is recognised as a repaint and dropped.

The cost is stated rather than hidden: two identical messages in a row show once. Pressing : twice re-shows nothing. That is a real miss, and it is forced, because the browser cannot distinguish the two cases — nothing in the projection stream carries a sequence or a timestamp beside status_msg, and model.revision moves for a cursor key as readily as for a re-issued command. Given an ambiguous offer there are two choices, and only one of them does not reintroduce the reported bug: showing it again is exactly the resurrection. Making both show is a protocol change — a sequence number beside the string — and that is the thing improvise-n6t8's scope deliberately forbids, because the TUI draws status_msg in a bottom bar where it costs no layout at all and nothing here forecloses the split later.

Note what the toast does not do when it hides: it does not empty itself. The element is the record of what was last said, and drill.spec.js's discrimination between two refusals — > must not talk about aggregates — reads that text. An emptied node cannot tell "nothing was said" from "something else was said", and the assertion would pass vacuously the moment the timer fired.

A message beside its open editor stands until that editor closes (improvise-1x9h)

The timer is wrong for one kind of message: a refusal the user is still fixing. Six seconds after it appears, the toast hid it while the minibuffer it was about stayed open. That is the TUI's su4u bug with a timer instead of a keypress, and the rule is the TUI's, so both front ends ask one question.

The rule: a message stands while the editor it was written in is still the open one. "The editor" is improvise_protocol::EditorSession, the value the TUI's press compares before and after a key (context/code/tui.md, "What 'the same editor' means"). It moved from src/draw.rs into the protocol crate so that the browser asks the same question rather than a second derivation of it (design principles §5).

The decision is Rust's, and the page only obeys it. ClientState observes the status wherever the view changes (settle_status, beside settle_mode and settle_editor). That means every applied command, and also every rebase(), refused() and bootstrap(), the three paths that rebuild the view wholesale:

  • a status that has changed since it was last observed is owned by the editor open at that moment, or by nothing outside a text-entry mode;
  • an unchanged status loses its owner, for good, as soon as a different editor (or none) is open.

RenderModel::status_stands is ClientState::status_stands(). The page passes it to createToasts.offer(text, stands). A standing message arms no timer. When it stops standing it hides at once, not six seconds later, because the TUI drops a refusal on the key that leaves its editor.

"For good" is the part that needs memory. A refusal in FormulaEdit, then Esc, then n again opens an editor equal to the first: the same mode, the same cursor, no seed. Comparing the current editor with nothing would make the old message stand again beside the new opening. The owner is therefore recorded when the message is written and cleared when that editor closes, not recomputed from what is open now.

That clearing has to happen on every path that moves the view, and a relay's rebase is one of them (improvise-0dbk). A relay heard while a frame is in flight rebuilds the view by rebase(). If rebase() does not settle the status, a rebase that moves the view off the editor leaves the owner in place. When the user returns to an equal editor, the old message stands again. a_rebase_off_the_editor_ends_the_message_standing is that case.

A status written outside a fold must be observed where it is written. socket.rs writes a rejection's sentence after refused(), so the sentence survives the rollback. It writes it through ClientState::notify, which queues the sentence at the tail of in_flight through fold_outgoing (improvise-n0pb). That path applies it at once and runs settle_status, so the editor open at that moment owns the sentence. Assigning status_msg directly leaves it unobserved until the next fold. By then the editor may have changed, and the message would be owned by the wrong one. A relay's rebase would also erase a direct write, which is why improvise-n0pb queues it.

What it inherits from the identity rule above. An identical message written again in a new editor is not a new write, so it neither stands nor re-shows. That is the "two identical messages show once" cost, and it has the same cause: nothing beside status_msg says that it was written again.

Where the rule is live. A formula refusal still never reaches a browser. The client folds CommitBuffer's optimistic "Formula added", and the host's error is a write to the host's own slice (a_formula_the_client_cannot_parse_diverges_on_the_status_line). What reaches a browser today is a host rejection that leaves the editor open, such as a refused save. The rule becomes fully live when a host-addressed status reaches its composer.

What the styling is, and what no test here can see

web/style.css stopped being "legibility only" and is now a small design system: layered surfaces, one accent, a filled badge for the mode (the single most consequential fact on the page, since every key means something different in each), chips for the two axis strips, and one selection treatment used everywhere a selection can occur. The palette is deliberately the landing page's (docs/index.html), so the two read as one product.

Two things in it are worth naming because they look like derivations and are not. The selected row's header lights up through tbody tr:has(td.selected), which is the selection already decided in Rust being read off the DOM by the stylesheet rather than the page working out which row is selected. And .notice-toggle's glyph is chosen by [open], which is the one piece of state on this page that genuinely is the browser's and that nothing else knows.

One rule in it is load-bearing and is the stylesheet's own failure mode. td.error is the only place on this page where colour carries meaning rather than emphasis — a circular reference or a mixed aggregate reading as an ordinary number is worse than it reading as nothing. It used to coexist with the dimming of unsettable cells because they set different properties (color against opacity); once both are color at the same specificity, only source order separates them, and nothing in any language fails when the order is wrong. So the dimming sits above the semantic colours, and a selection is a ring and a tint and never a colour. That is the class of defect a stylesheet has and a program does not, and it is why the one assertion this file makes about colour is about which rule wins rather than about a value.

The harness cannot see most of this, and that is the honest scope. Playwright asserts what a browser can answer: that the notice starts collapsed and remembers, that it opens from the keyboard, that the minibuffer is inside the viewport, overlaps #grid and is larger than body text, that an error cell wins the cascade — and, since improvise-n6t8, that the app bar holds still across every mode the browser can reach. That last one is the first assertion here about layout rather than about one element's placement, and it is the shape to imitate: it measures bounding boxes, which a browser can answer exactly, rather than asking whether the page "looks stable", which it cannot.

What it still cannot see: colour, contrast, spacing, weight, the grouped or multi-level header DOM, and either theme — prefers-color-scheme is not exercised anywhere, so the dark palette is read and looked at and never run. So the rest of the styling is argued and not tested, on the same terms as the rest of this section, and a change to it is reviewed by reading and by looking at the page.

A reserve is a floor, and below the width that holds it the bar must reflow (improvise-tg27)

The bug was reported as "a narrow window clips grid columns without usable horizontal scroll", and the suspicion in the ticket was a flex min-width:auto trap swallowing #grid's overflow. It was not that. #grid carries overflow: auto, which already zeroes a flex item's automatic minimum, so the grid could always shrink. What could not shrink was the app bar.

Measured at 22ab58f on the grouped harness fixture: the document was 1011px wide at every window narrower than 1011px — a hard floor, not a gradual overflow. The bar's reserved children (#build-version 14ch, #doc-name 34ch, #view-name 16ch, #mode 21ch) are all flex: none, and #status is flex: 0 0 auto; only #hint can shrink, and it bottoms out at zero. Below that floor the page grew a horizontal scrollbar, and what it scrolled to was the bar's own right-hand end — #status, #mode and #hint sitting outside the window — while main stayed correctly 520px wide and the grid slid off to the left as you scrolled. So the affordance a user reached for led somewhere useless, which is the whole of what "not obviously scrollable" meant.

Three things follow, and the first is the one to carry. The reserve (improvise-n6t8) is a floor, and a floor that exceeds the window is not a reserve, it is an overflow. header therefore takes flex-wrap: wrap. That is chosen over a @media breakpoint because wrap is conditional on the thing that actually matters: an item wraps exactly when the line cannot hold it, so the desktop bar is untouched by construction rather than by a number someone picked. Measured on fresh loads of the harness fixture at 1024, 1100 and 1280 the bar is still one line at 38px and #grid is unchanged.

The threshold is not a constant, and must not be asserted as one. #status composes the document's own name, so a longer name reflows a wider window: the same 1024px window is one line under the name shot and two under narrow-tg27. The spec therefore asserts no reflow at 1280 — where improvise-n6t8's stability spec runs, which is the bar this change must not disturb — and says nothing about where the first wrap falls.

Second, #hint is still squeezed, and the wrap does not fix it — which is worth writing down because the first draft of this section claimed it did. The hint is the one line naming the keys that reach a column the window cannot hold, and being the single elastic child (flex: 1 1 0) makes it the first thing any shortfall takes. After the wrap its width is non-monotonic in the window: measured settled, 26px at 360, 1px at 520, 81 at 600, 317 at 700, 385 at 768, 67 at 900, 6 at 1024 (fixture shot). It is whatever the line break happens to leave, because #view-name + #mode + #status can fill a wrapped line exactly. So no assertion is made about it, and the phone-width case is a real gap this ticket does not close. Making it legible means giving it a min-width so it forces its own line, which is a direct change to improvise-n6t8's "basis ZERO so it contributes nothing to overflow" and belongs in a ticket that argues it.

The measurement that caught the false claim is worth keeping too: an early version of the spec read the hint immediately after the grid painted and got 186px, because #status had not yet composed the transport's fact and was narrower. theGrouped now waits for #status before measuring — a bar that is still settling is a transient, and a test that measures one is flaky in the direction that reads as a pass.

Third, a fixed width is a fraction of a narrow window. #categories at 15rem is 240px of a 360px phone, leaving the grid 120px. max-width: 40vw on the three panels keeps the grid the larger share. It buys nothing in projected columns — COL_PX is 110 and the extent is floor(width/110) - frameCost, so one column is what fits either way — and a great deal in legibility.

What is not the bug, and must not be "fixed". At 360px the grid shows one data column and the other five are never projected: the browser's Viewport is a real window (gotcha 44/47), so no amount of scrolling can reveal a column the host was never asked for. The ticket's own "or another explicit navigation" is the answer, and it already worked — ArrowRight walks all six, because resolve.rs composes SetSelected plus the offsets viewport_effects returns. Over-announcing the extent so #grid has something to scroll was considered and declined: a fixed over-fetch reveals a fixed number of extra columns and so solves nothing general, while announcing the whole grid defeats the windowed viewport that exists for large documents. It would also be a change to the ResizeObserver arithmetic, which nothing tests.

harness/tests/narrow.spec.js is the instrument, and its assertion is documentElement.scrollWidth <= clientWidth — a question a browser answers exactly. Note what was not done: body { overflow-x: hidden } would have made every window pass that assertion by making scrollWidth equal clientWidth unconditionally, which is a check answering its neighbour's question. The bar has to genuinely fit.


web/worker.js — the worker half

It hosts the authoritative App. Everything it decides is decided in Rust — the file only moves strings between self.onmessage/self.postMessage and WorkerServer::on_message, which is the same shape web/app.js has on the main thread and for the same reason: the less JavaScript there is, the less can be silently wrong.

Two rules it must not break:

  1. Post every reply, in the order the Rust returned them, one message per frame. A GridUpdate::Cells that overtakes the labels framing it is DROPPED by the client's fold, not replayed. Never batch two calls' replies together and never reorder them.
  2. Await flush() after each message. Storage::delete is visible to read/list immediately but only reaches OPFS there.

Rule 1 is about frames within one call's replies AND about the calls themselves, which is why every message is chained onto one tail promise rather than handled by an async listener: an async listener does not serialize. The event loop is free to start the next message while an earlier one is suspended at an await, so two on_message/flush pairs interleave and a later frame's replies can be posted before an earlier frame's. The chain is also what makes "frames that arrived before start() finished" an ordinary case of the same rule instead of a second code path with its own drain loop — and that loop had the race in it twice over, since server was set before it ran.

The chaining itself is web/serial.js::serialize, which is promise plumbing and nothing else, so web/tests/serial.test.js can force the interleaving a browser cannot be asked to produce on demand — including the negative arm, which runs the same two handlers without the chain and shows them coming back swapped.

The document is named in the worker's own URL — new Worker('worker.js?doc=demo.improv') — the analogue of the socket server naming it in the ws:// path. It is deliberately not a ClientFrame: the envelope pair is shared with the socket deployment and stays exactly as it is (improvise-xks6). The seed is fetched and handed to Rust, which writes it through the VFS and then reads it back like any other document — not injected past storage. A missing seed is not an error: with none, the worker creates an empty model under DOC.

An unhandled rejection inside a message listener reaches the devtools console and nothing else, so it is caught there — naming which frame it was, which the main thread cannot see and has no way to ask — rather than left on the chain, where one bad frame would poison every later one.


docs/ — the landing page and its casts

The casts are recorded by a script, not by asciinema rec

docs/record-casts.py drives improvise on a pty it owns and writes asciicast v2 itself. asciinema rec records a shell, and the shell it records is the recorder's own: every previous cast opened and closed on (0) Mac:<hostname> ~gf/u/edwlan/improvise, on what improvise-kh8 had since made the site root, and formulas.cast and import.cast showed that prompt as their visible opening frame (improvise-2oxt).

The repair is structural rather than careful: improvise is the pty's only child, so there is no shell for a prompt to leak from — nothing to remember to clean, and nothing that comes back when somebody re-records on a different machine. Three other things stopped being accidents at the same time. The geometry is an input (120×37, the numbers docs/index.html already spelled, which is why they were not changed — a 30-row player over a 37-row cast clips the tile bar and the status line, exactly where the output is). The pacing is a per-step table rather than typing speed. And the header carries no env and no timestamp: the first held $SHELL, the second the recording machine's clock, and neither is anything a visitor is owed.

Two decisions inside the scripts that are not obvious and cost a re-record each:

  • No :q is typed at the end. The player loops, so the last frame is also the frame before the first, and a minibuffer reading :q is what it would show. The process is signalled instead.
  • Each scenario has to be arranged so the thing it names is visible. The drill cast drilled into a cell with no records behind it — a true frame about nothing — so it now moves Customer off the page axis first, which makes the cell an aggregate over nine customers and gives the records grid more than one row. The pivot cast pressed c on a category that was already on the column axis, which is a no-op with a status line. Neither was visible from the keystrokes; both were found by rendering the recording back and reading it.

That last point is the method, and it is reusable: a cast can be replayed through a small ANSI reconstruction and read as text, so "what does frame N actually show" is a question with an answer rather than a guess. It is how the +cat and + columns in drill.cast — improvise-2oxt's substantive complaint, since the old recording predated both — were confirmed present.

What the harness can check, and does: landing.spec.js asserts over the cast files that no header carries env or timestamp, that the geometry is the one the page configures, and that no chunk contains an OSC window-title sequence, which is the leak's signature — improvise emits none and a shell emits one per prompt, carrying the cwd. Asserted over the file rather than the rendering because that is where the leak lives: a player draws a prompt as ordinary text, and a screenshot assertion would have to know what the hostname had been.


harness/ — the browser instrument

Why it exists, and what belongs in it

Before harness/ there was no JavaScript test infrastructure in the repository at all — no package.json, no runner, nothing in CI running a single line of web/app.js or web/worker.js — so every bug in them was found by reading and the bug-first rule could not be honoured for a change to either. PR #21 declined to add regression tests for its close-listener fix on exactly that ground, and that decline is only honest because those assertions live here instead.

Playwright rather than a headless-DOM runner because what goes wrong in these files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which a DOM shim has. That still holds, and it is why no shim was added; what changed is the scope — see the section below, which is now the answer to "where does a change to web/*.js belong". It is deliberately small (improvise-vvl3): the point is to be able to run storage_contract against OpfsFs and to read #status out of a real page, not to grow a second test framework beside cargo test. The server root is harness/serve, built by build.sh — the assembled site plus the harness's own page; a module script will not load from file://, so an http server is not optional.

Engine coverage

improvise-xf1's acceptance criterion names Chrome, Firefox and Safari, and the OPFS spec is the reason: createSyncAccessHandle is the one API the whole design rests on, its sync read/write/truncate signatures were added later than the handle itself, and each engine implemented it separately. Chromium alone would be one engine's opinion.

Result today: Chromium and Firefox run the whole battery and pass. Playwright's WebKit provides no OPFS at all — getDirectory() itself rejects with UnknownError — so Safari is NOT covered and xf1's criterion is met for two engines of three. The project is kept rather than deleted so that it starts running the day that build gains OPFS; opfs.spec.js skips on a runtime probe, not on the browser name, so if Chromium or Firefox ever stops providing OPFS it fails rather than skipping. An unexpected skip on any other engine is a failure — a silently skipped check is exactly what this harness exists to prevent.

The shell spec's mocked-peer block is Chromium-only on purpose: it is about web/app.js's own logic, which is engine-independent, and routeWebSocket is slower and flakier to no benefit across three. That argument is about those four tests and was never made about the two beside them: the standalone block mocks nothing, it is the deployment improvise-bck's criterion (1) names in so many words ("Chrome and Firefox"), and the engine's OPFS is already exercised by opfs.spec.js, so the path is known to be there. It runs on Firefox too since improvise-zkkw — testMatch widened, grepInvert: /@mocked-peer/ — measured at 2.0s for the pair and 10/10 green under --repeat-each=5. The split is a tag rather than a third copy of the file, and widening the whole file and then quieting what flaked would be adopting a reason nobody gave.

Every other spec is Chromium-only, including the three added by improvise-plcx, 78v6 and bgtf: a fixture document, a help overlay and a landing page decide nothing per engine.

harness/opfs-contract

A harness crate, not part of the application: it ships in no bundle and is not a workspace member. OpfsFs had been compile-checked and never run; this is the instrument that changed that (improvise-vvl3, absorbing improvise-xf1).

Why a worker. FileSystemFileHandle.createSyncAccessHandle() exists only in a dedicated Web Worker, and it is the whole reason Storage could stay synchronous. A check that ran on the main thread would fail for a reason that has nothing to do with OpfsFs.

Why a report rather than a panic. Every check runs and its outcome is reported, so a failure names which step and the rest still run. More importantly it is what lets the spec check the checker: Playwright asserts the report contains exactly the checks it expects, so a worker that never started, or a build that dropped a check, is a failure rather than a silent pass. The list of expected check names is spelled in opfs.spec.js as well as in Rust on purpose: a check deleted on one side has to be deleted on both, which is what stops the battery quietly shrinking.

wipe uses only the backend's own vocabulary — refresh, delete, commit_deletes — rather than reaching for removeEntry directly, so a broken delete shows up there as well as in the contract. It therefore cannot clear a zero-byte entry, because list no longer names one. That is deliberate and costs nothing: attach recreates and write fills whatever a previous run left behind, and a leftover empty entry is invisible to every assertion for the same reason it is invisible to a document picker.

opfs_root/make_directory exist only in the harness: OpfsFs has no directory vocabulary at all — it stores one flat entry per document — so making a directory, to check that refresh skips one, has to happen outside it.


Where a change to web/*.js is tested

E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them. (User ruling, 2026-09-20.) So the question is not "can this assertion be made cheaper" but "which boundary is it about", and the browser half has seven:

boundary the contract where it is pinned
RenderModel → DOM every field the render model projects reaches an element web/tests/render_surface.test.js (statically) and harness/tests/contract.spec.js (on the screen)
JS → wasm the calls the page makes exist, with the arity the bundle declares, and their answers are painted web/tests/bundle/wasm_surface.test.js (the surface) and contract.spec.js's pointer test (the wiring)
page → peer the handshake goes out first, a frame is answered whole, a failure is named, and the page routes each failure through the call that disarms the watchdog harness/tests/shell.spec.js, harness/tests/drill.spec.js
key → screen a key the mode's hint line ADVERTISES produces something the user can see, and a text-entry mode paints what is typed into it harness/tests/keys.spec.js
help::pages() → DOM the overlay's rows are the shared content — the keys off the keymap and the words beside them — and not the right number of blank rows harness/tests/help.spec.js
worker → OPFS storage_contract, run against a real OpfsFs harness/tests/opfs.spec.js
AxisFrame → DOM the grid's frame — header levels, group spans, the collapse indicator and the group row that carries no cells — is drawn as the frame says, not slash-joined harness/tests/grouped.spec.js, over harness/fixtures/grouped.improv; web/tests/grid_plan.test.js pins the plan it draws from
the site → the app the landing page's casts are really fetched and really play, and its front door reaches ./app/ harness/tests/landing.spec.js

A unit test of a rule does not test the wiring that reaches it, and the sharpest case in this file is the watchdog. shell.test.js proves that createShell.fail() disarms rather than pre-empts; it cannot prove that app.js's socket close and error listeners call fail() rather than setShell(). That routing is PR #21's other half, the one gotcha 48 says keeps getting lost — and PR #61 lost it a third time by keeping the unit test and dropping the browser assertion, with 26/26 and 13/13 green. Restored, and the rule generalises: when a unit test replaces a boundary test, ask which of the two the regression would have been in. Here it is the routing, so the guard stays in Playwright.

The faked clock is faithful outside the window and silent inside it. shell.spec.js walks past the 8s handshake watchdog with page.clock, and the question that owes an answer is whether collapsing ten seconds into one synchronous step flattens an ordering rather than merely skipping the wait. Probed for the sharpest case — a peer that opens, stays silent past the window and is closed afterwards — the #status transcript is byte-identical under the fake clock and under a real nine-second wait: four states, same order, same text. What that does not cover is an event landing strictly inside the collapsed window, which a synchronous runFor gives no opportunity to deliver; that is a race whose outcome the page does not promise, so there is nothing to assert. State it rather than re-derive it.

A negative assertion must demonstrate the denied event is reachable on that exact code path — not on a sibling path, and not in the rule's own unit test. This file's worked example cost four review rounds. shell.arm runs only after await transport.ready, so on a socket that never opens the watchdog is never armed; a test denying "sent no document" there denies nothing, and passed against both the routing revert and the disarm regression. outlastTheWatchdog now asks the page timesArmed() > 0 first, which turns that review finding into a failure by name. Note ever armed and not armed now: a correct page has already disarmed by the time the disconnect test jumps — that is the property — so the obvious spelling would have denied the passing case.

And a constant a test must exceed has one home. HANDSHAKE_TIMEOUT_MS lived in web/app.js and again in the spec, under a comment saying a silent disagreement "would pass for the wrong reason". It did: the spec at 1000 against the page's 8000 made the suite green over the reverted routing, because the jump landed inside the window. It is exported from web/shell.js and imported by both, so the disagreement is unconstructible rather than discouraged.

Everything between those seams is node --test web/tests, and it may assume the boundary holds rather than re-establishing it: transport selection from ?ws=, the two sinks the status facts go to, the toast's identity rule (the one thing a repaint-driven surface must get right), the watchdog's arm and disarm, the frameCost/extent arithmetic, the collapse glyph, the grid's plan, and the worker's tail-promise ordering. None of it needs a DOM, so none of it is Playwright's.

The seam that makes this possible is web/shell.js and web/serial.js: pure modules that return a description rather than take a DOM double. gridPlan(model) answers every question gridTable used to answer while it was creating elements — which row index a click carries when group headers are interleaved, what a group header spans, whether a null group label is a name, whether an empty edit buffer means an editor is open — and app.js is then a loop that creates elements from it. Painting stays untested by Node; the decision about what to paint stopped being untestable.

Why the four widgets were one bug

The tile bar, the view list, the formula panel and the category sidebar ignoring category_panel_open were reported as four broken features and fixed as four. They were one failure at one boundary: a RenderModel field with no element painting it. Four per-feature specs cost four page loads and catch the fifth only if somebody writes a fifth spec; one assertion that every projected field is painted catches it by construction. That assertion is cheap enough to make twice, from different sides:

  • render_surface.test.js reads the pub fields off improvise-wasm-client/src/render.rs and requires each one to be read by app.js or shell.js — or named in UNPAINTED with a reason. It needs no browser, no bundle and no build, so it fails in seconds. The list is not an escape hatch: it is the debt, written down, and it currently holds doc_name/doc_dirty/doc_stored_as (improvise-5jna — the fifth instance, found by writing this test).
  • contract.spec.js drives the demo document into each state that fills a widget and asserts the element is on the screen. Node cannot see that, and the moment a reduction would have to weaken it, it stays here.

And the static half is weaker than it reads, which is worth knowing before leaning on it. render_surface.test.js asks whether the string model.<field> occurs anywhere in the page's code — so a field mentioned by a function nothing ever calls passes. That happened on improvise-fdt6: the new minibuffer field had a paintMinibuffer reading it, and the call site and the getElementById were both missing, so the node suite went green over a page that still painted nothing. keys.spec.js failed on its first run, in Chromium, naming the hidden element. The static check is a completeness check — it tells you a field was not forgotten — and never an execution check; only harness/ is that (gotcha 48).

What is deliberately not in Playwright any more is what each key or click means: improvise_protocol::resolve decides that and its tests are where the meaning is pinned. One instance of "a resolved change repaints" is kept, because the repaint is the boundary; the other seven were the same instance again.

Two numbers, and neither is the other

Measured on one machine, in one session, on a private IMPROVISE_HARNESS_PORT, with both trees clean — 1af51c9 reached by a full checkout rather than by restoring paths over a working branch (see the trap below). Two runs each; the pair is the variance.

suite chromium all projects execution whole job
Playwright, before (1af51c9) 23 26 passed + 3 skipped 36.2s / 36.3s ~4m30s in CI
Playwright, after 14 17 passed + 3 skipped 15.1s / 14.9s ~4m30s in CI
Playwright, + wave 4 21 26 passed + 3 skipped 34.4s / 33.7s ~4m30s in CI
node --test web/tests — 26 0.12s seconds

The last row is improvise-78v6/igxp/plcx/bgtf/zkkw: seven more Chromium tests and two Firefox ones. Most of the 19 seconds is one test — the landing page's four asciinema casts are played until each terminal draws, which is the only assertion that can tell a cast from a 404 (evidence file). The Firefox pair is 2.0s of it.

Two columns because one number was ambiguous three times. Firefox runs opfs.spec.js and the standalone half of shell.spec.js and every other spec is Chromium-only, so a count taken off a run is five larger than the chromium count, with three more skipped (WebKit's OPFS battery). The columns must also agree: chromium + 3 is the passed total, and a row where they do not is a row built from two different trees. npx playwright test --list prints the per-project truth; quote which one, and this is the same "two numbers, not one" the Rust suite learned in design-principles.md §7.

The trap that made the first before figure wrong. It was taken by git checkout 1af51c9 -- harness web inside a working branch — which restores tracked files and leaves untracked ones where they are, so the branch's own contract.spec.js stayed and the "baseline" ran main's 23 tests plus three of the branch's. A baseline taken by checking paths out over your own work measures a tree that never existed. The full checkout above is the repair.

What gave it away was the count, not the clock — and that is the lesson. The contaminated run read 29 passed where a clean 1af51c9 reads 26, because three extra tests really were there; but it read 36.9s against the clean tree's 36.2s, because those three cost under a second between them. A wall clock absorbs a contaminated tree without a trace; an arithmetic identity does not. The same error survived three separate corrections — it reached PR #61's body as "26 chromium" (23 + the 3), survived a fix that corrected the chromium column and left the run count beside it, and was caught each time only by someone checking that the columns add up.

Where the saving actually came from, since the obvious answer is wrong. The eleven per-widget tests that were deleted cost 2.7 seconds of test time between them — restored verbatim and re-measured — against 1.55s for the three contract tests replacing them. Those are the per-test sums, not the two wall clocks, which read 3.3s and 3.4s and flattered the replacements: they were separate npx playwright test invocations, so each paid its own cold start (~0.6s and ~1.9s) and a near-tie appeared between two things not measured the same way. Warm inside a full suite those three cost under a second, which is why the contaminated baseline's clock barely moved.

Eleven tests at 2.7s cannot buy a twenty-second saving, and they did not. Essentially all of 36.2s → 15s was two shell.spec.js tests that waited out the 8s watchdog in real time, and one of those two was the routing guard this section explains should never have gone. So the deletion of the eleven bought shape, not speed; the speed came from a deletion that cost coverage. Both are back at ~75ms each, because page.clock fakes the page's timers — the honest version of the trade: the wait was the cost, not the test.

Cutting Playwright tests barely moves the CI number in any case, because roughly four minutes of that job is three wasm-pack builds, npm ci and a browser download — not test execution. The feedback-time win is the js job, which needs no Rust toolchain and no wasm bundle at all, and that is a constraint rather than an accident: the moment a web/tests/*.test.js file needs web/pkg/, it inherits the four-minute build and the argument for it evaporates. That is why the one test that does need the bundle lives in web/tests/bundle/ and runs in the browser job instead.

Until improvise-4ktt the mechanism was a directory convention and a glob, and nothing checked it. bundle_freedom.test.js does now: it reads its own sibling files and refuses one that names the bundle directory, and its companion asserts web/tests/bundle/ still holds a test that does — a scoping rule is uninteresting if nothing falls outside it. Evidence in context/test-evidence/tooling.md.

A coordinate rename (improvise-fdk1)

The coordinate is the one records-mode cell the browser edits. edit_or_drill still declines a records grid, except on a CellId::Coordinate. There it opens the editor on the coordinate's edit_text, just as the host's EditOrDrill does. commit sends a records grid to rename_coordinate, which composes ModelCommand::RenameRecordCoordinate plus the mode change and the advance that CommitAndAdvance makes. Every other records cell is still declined out loud, and the_tile_bar_route_into_records_mode_does_not_strand_the_user walks the whole row to check that both answers hold.

The target is captured when the editor opens, not when it commits. The record, the category and the record's write counter are captured into ClientState::opened (session-local, never serialized). The commit renames that record, and its basis claims that counter through BasisCache::claim_as_read, the same door the inline editor's EditTarget uses (improvise-0j0f). So if another client writes the record while the editor is open, the rename is refused with RecordMoved, and the other client's value survives.

The capture waits for the host's answer, and that wait is the part easy to get wrong. A commit re-enters editing one row down in the same batch, and the client folds that batch optimistically. At that moment its grid cache still shows the rows as they were before its own rename. Records rows are sorted by coordinate, so a rename moves its row, and the row now under the cursor belongs to a different record on the host. So settle_opened captures only when no awaited frame is in flight. It runs after every fold and after accepted(). The host sends Accepted last, after the projections that describe the answer, so the capture reads the refreshed cache. A commit made before any capture exists (typing faster than the host answers) is declined out loud with "still waiting".

After a rename, the browser's cursor goes to the origin, not one row down. The rename reframes the grid, so the host broadcasts ResetCursor to every client, the asker included (gotcha 24). The TUI's key path stays one row down, because apply_effects observes the reframe and CommitAndAdvance restores the cursor afterwards. tests/lockstep.rs cannot see the difference: its twin host has no Sessions, so nothing broadcasts. The difference predates this ticket and applies to any browser write that reframes the grid.

An empty coordinate writes nothing, on both ends. The host's key path refuses it (RecordRewrite sets refused, and App::refuse does not abort), says RECORD_COORDS_CANNOT_BE_EMPTY, and still advances. Composing the rename would instead have the host undo the whole frame (MutationRefused) and show the generic sentence. So the client composes the host's answer itself: the same status, taken from the protocol's one copy of the message, plus the advance and no write.

A coordinate editor opens the way a value editor does, and an untouched one writes nothing. edit_or_drill opens both through opened_on, so both record SetOpenedEditor with the seed, as the host's EditOrDrill already did for every cell it opens. rename_coordinate then applies improvise-ixxx's untouched rule before anything else: i then Enter composes the mode change and the advance and no rename, which is what the host's CommitAndAdvance does. The untouched check comes before the "still waiting" decline and the Gone decline below. An untouched Enter writes nothing either way, so it needs no basis capture and cannot retarget.

The capture carries the document generation it was read in (improvise-vndi), and the rename claims it through claim_as_read, as the inline EditTarget does. It cannot be observed: a document swap's rebase Snapshot closes the rename editor and settle_opened drops the capture, so no rename crosses a generation. a_document_swap_closes_an_open_rename_editor pins that precondition.

Two captures, one editor. When an editor opens, two things are recorded, and they answer different questions:

  • ViewState::opened_editor (OpenedEditor { seed, touched }) answers what text it opened on, and whether the user has changed it. It is portable state. It is composed optimistically at the keypress and folded the same way on the host, where CommitAndAdvance reads it.
  • ClientState::opened (Opened::Target(OpenedCell { record, target, version, seen, document_generation, view }), or Opened::Retry/Opened::Gone after a refused commit, below) answers which cell the commit writes, and which record versions and document its basis claims. target is Coordinate { category } for a rename and Value { measure } for a stored value; seen is filled for a rename only. It is session-local and never serialized.

Neither answers "is an editor open": the mode does, for both. opened_editor is not cleared when the editor closes, only replaced when the next one opens.

The record capture cannot move into OpenedEditor, and the reason is the capture rule above. settle_opened waits for the host's answer, because a capture read from the optimistic cache names the row as it stood before this client's own rename. SetOpenedEditor is composed at exactly that optimistic moment, so an OpenedEditor carrying a RecordHash would reintroduce the bug the capture_unanswered rival demonstrates. It would also put a client-only basis read into state the host folds and tests/lockstep.rs compares, where the host's value would have no record in it. Gating the capture on opened_editor.is_some() was considered and rejected: an editor entered by enter-mode records no opening, yet the host still commits a rename from it, so the gate would strand that path on "still waiting".

A retry after a refusal may write only the cell it named (improvise-20ek, improvise-9ww8, improvise-8phw). A refusal replays the view slice, so the editor reopens with the typed text over whatever cell the cursor now shows. The rule is about that cell, and it holds for a rename and for a value alike:

  • refused() pins ClientState::opened to Opened::Retry(record, target), naming the cell the refused frame wrote: a rename's record and category, or a SetCell/ClearCell's record and measure (written_to). The refusal's kind plays no part.
  • Once the host has answered, settle_opened captures what is under the cursor. If that is the same record and the same OpenedTarget, it becomes the target at its current version, so a second Enter goes through. That is the one-keystroke force RecordMoved promises (gotcha 43), and the user has just been told someone else changed the row.
  • If it is anything else, or nothing, opened becomes Opened::Gone, and Enter answers with a status and no write. Esc leaves the editor, and opening it again captures afresh.

The whole target is compared, not only the record, because a record is a row and a value editor names a cell in it: a crossing HideItem on a measure leaves the same record under the cursor in another column, and a record-only comparison forces the retry onto a measure the user never typed for. seen stays rename-only, because only a rename has a destination to claim.

Why the rule names the record rather than a rejection. The first version (20ek) pinned only on RecordGone of the renamed record. But stale_basis checks the layout tier before the record tier, and another client's rename bumps layout_version. So when A's Enter crosses B's rename in flight, which takes nothing more than ordinary latency, the refusal is LayoutMoved, not RecordGone. The retry then renamed whatever record had moved under the cursor. That happened both when B had renamed A's own record, and when B's rename merely re-sorted the rows so that a different, still-living record sat under the cursor. So "is the source gone" is not the discriminator either. The only question that separates a force from a retarget is whether the capture is the record the user typed for. With that rule the explicit RecordGone pin is subsumed: after RecordGone(r) the capture can never be r. It was removed once a rival that dropped it failed no test. The reviewer's rival on PR #221, which kept the opened target through every refusal, still breaks the reorder case: after a successful rename, the next editor must capture the row it now shows.

The destination is a record too (improvise-milr). A rename onto an existing coordinate overwrites that record (improvise-kndq), so it is a write to the destination as well, and the destination has to answer to the basis. The server decides that (records_at_stake, context/code/command.md). The client cannot name the destination: it would need the hash of coordinates it has never been sent, which ADR 002 forbids. So it claims every record counter it held when the editor opened, captured into OpenedCell::seen at the same moment as the target. Two consequences follow:

  • If another client writes the destination while the editor is open, the rename is refused with RecordMoved. This holds even when the write has already reached this client's cache, because the claim is the version at open time.
  • If the destination is a record this client was never shown (outside its window, say), the rename is refused with RecordUnstated. Overwriting a record you have not seen is exactly the lost update the check exists to stop.

A value editor commits to the cell it opened on, too (improvise-8xq0). The keyboard commit over a plain grid cell used to read the cursor at Enter: the record from record_at_cursor, the measure from the CellId under it, and the basis from claim, which reads the version as it is now. Another client's HideItem renumbers the rows under an open editor, the cursor then names a different cell, and the write landed there with the text typed for the first. The inline EditTarget fixed this for the mouse editor (improvise-2wu3), but it lives in the page, so the keyboard path never saw it. The fix reuses the rename capture rather than adding a second one: settle_opened captures a Record cell as OpenedTarget::Value, and written builds the RecordCell and the claim_as_read basis from it. Nothing about the write is read at Enter except the text. The capture reads the cell through the frame's own origin (improvise-7mro). captured() asks opened_at_cursor, which asks cell_at_cursor, and since #286 that reads through projected_at (AxisFrame::first), never through the cursor's offsets. That matters more here than for any other reader, because the capture sticks: settle_opened takes it as soon as no awaited write is in flight, which includes the fold of the client's own outgoing frame. An untouched Enter that scrolls moves the offsets before the host has sent the new window, so reading through the offsets captured the old window's cell at the same cache index (Jan/Revenue where the TUI writes Feb/Revenue, with every reply delivered), and every later Enter wrote it. Read through the origin, a cell outside the held window is None, settle_opened simply tries again on the next fold, and the capture lands once the host projects the cell. #274 took #286 by merge rather than porting the reader; the PR body says why.

The capture includes the view that drew the cell (improvise-wpxr). A SetCell/ClearCell names the view the host resolves it in, because a record with no stored cell exists only as a position in some view's grid. The keyboard commit therefore names OpenedCell::view, taken by captured() from ViewState::active_view at the moment the record, the version and the generation are taken. It is not the view current at Enter. The reason is the one this whole section rests on: everything a commit writes is fixed when the editor opens, and Enter contributes only the text. A view read at Enter and a record read at open could name two different grids, and the host would resolve the record in one that never drew it. It is also the rule the mouse editor's EditTarget::view already follows, so the two paths answer this the same way (every_composed_cell_write_names_the_view_the_client_is_on and socket::tests::a_mouse_commit_names_the_view_its_editor_opened_on). A rename names no view: its record holds cells, so the host finds it in the store (context/code/protocol.md).

A captured commit is not judged by the cursor's cell either (improvise-3h9w). #286's refused_here declines a commit key whose cursor cell i would decline, and waits on one that is unprojected. It runs in step() before commit(), so it must skip an editor that captured its target: that commit writes, or basis-refuses, the captured cell, whatever now sits under the cursor. The guard is kept for an editor that captured nothing, which is the case it was written for.

What the commit is follows the capture too. Whether Enter writes a value or renames a coordinate is read off opened.target(), not off the cell under the cursor (ChatGPT Codex on PR #274): a relayed reframe can leave the cursor on no cell, on a creating column, or on a records grid, and dispatching on that sent a captured value editor's Enter upstream or down the wrong path. Only an editor that captured nothing (uncaptured) is dispatched by the cursor, and that is the creating columns plus the waiting cases. A records grid's measure cell is deliberately not captured as a value, since the browser still declines editing it (gotcha 1); every_advertised_command_answers_in_its_own_mode fails if it is.

Two things come with it. The version is the one read at open, so a write by another client while the editor is open is refused RecordMoved on this path as well: improvise-0j0f's rule, which the keyboard path had not had (improvise-xz2z). And a value commit can now wait: the capture waits for the host's answer to this client's own last write, so Enter pressed again inside one round trip has no target yet. It declines with the rename's "still waiting" status and writes nothing. With no write in flight and no capture, the cursor is on no cell an editor writes, and the commit answers None as before.

A waiting commit is a decline, so it keeps the typed text (improvise-joed). Enter in Editing is the sequence [commit-cell-edit, clear-buffer edit], and a sequence runs every step unless one is Step::Declined. The waiting answer used to come back from composed as an ordinary Local step, so the welded clear ran after it and emptied the editor it had just left open. The two cell commits are therefore answered by committed, ahead of composed, and still_waiting is a Step::Declined. That is the same channel declined uses for commands the browser cannot compose, and the same rule gotcha 21 gives an abort: what follows a refusal in one binding does not run. #251's other decline, a retry whose cell is Opened::Gone, keeps its text the same way: renamed_away() is a Step::Declined too, and it serves both the value and the rename path.

This also fits improvise-fy9o (PR #227), which drops the weld and has the commit clear its own buffer at its head. There the binding is one command, so Declined and Local answer the same, and the text survives only if the waiting return comes before that clear. A merge of the two must keep that order. The joed tests fail if it does not.