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

3366 lines
206 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `Command`s, and a keypress resolved against the `App` moves the
*`App`'s* slice — its mode, its cursor, its minibuffer — with no `Command`
produced that any client could fold. `ClientSession::project` emits document
projections, a grid diff and a basis; none of the three is a mode change.
So a half-wired `Key` arm does not under-deliver, it **diverges**: the host
would believe it is in `AppMode::Editing` while the client draws Normal, and
`ClientSession::viewport()` reads the *mirror's* cursor, so the grid would not
even scroll to follow.
improvise-bpq2 answered it, and **not** by deriving `Command`s from a
`ViewState` delta. The user's ruling is that a keymap is UI and the server must
not know it, so resolution moves to the *client*: a key the client can answer
arrives here already resolved, as a `ClientFrame::Commands` batch it has folded
itself, and this arm's emptiness stops being a gap and becomes the statement.
What remains upstream is the set of bindings a client still cannot compose —
prefix keys, anything naming a row's category or a group, anything touching
`Storage` — and those are closed by enriching the projection, one at a time,
not by wiring this arm up. The list lives in `keys.rs` (see below).
### Persistence is the worker's, whether or not anybody asks
There is no `ModelCommand::Save`, and that is not an oversight: `effect::Save`
is `EffectKind::ViewOnly` (it writes the document to *this host's* file path
and clears *this host's* dirty flag; it mutates no document state), so the
derivation that produces `ModelCommand` from the model effects cannot reach it
(`decision(improvise-cqi, command_set_derived_not_invented)`). Saving was
therefore unreachable from any client on any transport until improvise-368h
added `ClientFrame::Save` — a *request to the host*, framed rather than
commanded precisely because it mutates nothing shared.
**A browser still never sends it**, and that is the right answer rather than a
gap: there is no file the user owns and no Ctrl-S semantics to honour, so
asking would be the wrong question — the document *is* the OPFS entry, and a
page that loses an edit because nobody pressed a key has simply lost it. So the
worker persists on its own behalf: `Worker::persist` runs the one existing
`Save` effect through `App::apply_effects`, so effects remain the only mutation
site and the save path is not duplicated. A worker with no `file_path` silently
does nothing, which is what `Save` already does; a failed save leaves its
message in the status and aborts the batch.
**The trigger is an observation of the document, not a table of command
variants.** It used to be the latter, and the reason was honest: a wire
`ModelCommand::SetCell` reached `effect::SetCell`, which did not raise
`model_state.dirty` — that was a separate `MarkDirty` effect only the key path
composed — so "save if dirty" was a trigger that never fired (improvise-gv9r).
With dirtiness raised by `App::apply_effects` off the same `EffectKind` the
`Dirty` is, the flag *is* the observation, and asking it is better than
enumerating variants for the same reason the cursor reset watches `cursor_epoch`
rather than matching on `CycleAxis`, and the rebase watches
`document_generation` rather than matching on `LoadModel`: a table of variants
is a second description of which commands change the document, free to disagree
with the first. It is also strictly tighter — a refused batch changes nothing,
so it raises nothing, and the "was anything rejected" special case that used to
guard this went with it.
One cost remains and is stated rather than hidden: `Storage` is whole-document
by construction, so OPFS rewrites the file per accepted model command —
correct, not yet cheap. And a failed write leaves the flag raised, so the next
frame retries: right for a transient failure, and harmless for a permanent one
(a worker with no `file_path`) because the only thing a failed `Save` produces
is a status line, and nothing in a worker renders one.
The one frame `on_frame` does **not** auto-persist after is `ClientFrame::Save`:
a client that asked to save has already been answered, and a failed write leaves
`dirty` set — so retrying would repeat the I/O and, on a transient failure, tell
the client `SaveFailed` about a document that is now on disk. The automatic
write exists for edits *nobody asked* to save.
So this is the browser's own answer, and `ClientFrame::Save` is the other one —
for a socket client editing a TUI-hosted document, where there *is* a file
somebody owns.
### 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 `Host`s keyed by document id, the
lifecycle rules, and the one place a per-document `SessionId` becomes a
server-wide address. Socket-free. It stayed on the server because a worker
hosts exactly one document.
- **`transport`** — tokio and tungstenite: framing, JSON, the handshake rule,
and reading the requested document off the URL path. No decisions about what
a client is told.
Native-only: tokio's networking does not build for `wasm32-unknown-unknown`,
and it should not.
### The registry's grain
Sessions editing *independent* documents are trivially non-conflicting, which
is why ADR 001's basis tiers — `document_generation`, `layout_version`,
`record_versions` — live on the `App` and scope per document **for free**. A
registry that let two documents share any of that would throw the free scoping
away, so the registry owns no state of its own beyond the map: there is nothing
here for two documents to share.
**A `SessionId` is not unique server-wide.** `Sessions` mints ids from a counter
it owns, so every document's first client is `SessionId(0)`. The server-wide
address is therefore the **pair**, `ClientId`, and `Registry::route` is the one
place a `Host`'s `SessionId` acquires the document it came out of. That is the
whole cross-document isolation argument, and it is structural rather than
checked: a `Host` can only name its own sessions, and the only tag `route` can
apply is the document whose `Host` produced the frames. In particular an
`Applied::Rebased` fans a fresh `Snapshot` out to every client of *that*
document and cannot reach further, because the ids it returns are meaningless
anywhere else.
### Lifecycle
**Open** happens at startup, from the documents the host names. A client cannot
ask the server to open a path it was not told about, and that is not an
oversight: deciding which paths a client may open is *access control*, which
improvise-j31x puts explicitly out of scope. Serving only what was named leaves
the question unanswered rather than answering it badly. A stem collision is
fatal rather than resolved: two files with the same stem are two documents a
client could not tell apart, and silently serving one is worse than saying so.
**Close does not happen when the last session leaves.** A document stays
resident for the life of the server. Evicting it was considered and rejected
twice over:
- Unsaved edits would vanish. The server has no autosave obligation, so a
reconnect would silently roll the document back to what is on disk.
- It would rewind the basis tiers. `document_generation` and `record_versions`
start at zero in a fresh `App`, so a client that reconnected holding a *stale*
basis would have it accepted as current — a correctness hole in the exact
mechanism ADR 001 is building, paid for with a memory saving nobody asked for.
An idle document therefore costs its `App`, and `Registry::is_idle` exists so a
future eviction policy has somewhere to ask the question from — nothing calls it
to evict, because nothing evicts.
### The transport's two rules
**A client names its document in the URL** (improvise-j31x):
`ws://host:port/quarterly` asks for `quarterly`, and `ws://host:port/` asks for
whichever one there is when there is exactly one. The path was chosen over a new
`ClientFrame` variant for two reasons: a document is a *resource*, which is what
a URL path is for, and `ClientFrame` stays exactly as it was, so every client
written against the single-document server keeps working unchanged. Which
document a name resolves to is `Registry::resolve`'s decision, not the
transport's; all that happens there is `path -> Option<&str>`. It is not a path
walker — everything after the leading slashes is the name.
The other rule is that **the first frame of a connection reports the extent**,
because the bootstrap `Snapshot` cannot be built without it.
A refused connection is closed with a websocket close frame carrying the reason.
No `ServerFrame::Error` was added: the frame vocabulary is derived from the
mutations that need it, and "this connection is not happening" is a fact about
the connection, which the protocol already has a frame for.
A `Registry` is not `Send` (a `Host`'s `App` owns a `Box<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 → `Command`s
### It is not the browser's module any more (improvise-0yq8)
`resolve`/`step` and `ClientState` were `improvise-wasm-client`'s, and nothing
about either was a browser fact: both depend on `ViewState`, the protocol
widget caches and the announced window, every one of which is already a
protocol type. Being in the wasm crate meant a second front end could reach
them only by depending on a `cdylib` built for a browser, so the move cost no
new dependency and bought the rule design-principles §5 states — **a new front
end needs exactly two things**: its own event → `KeyEvent` mapping, and
painting. `BrowserKey` is the first of those two and is the only thing left in
`improvise-wasm-client/src/keys.rs`; `ClientState` is now
`improvise_protocol::client`, and `typed_cell_command` went with `step`, which
calls it.
What did **not** become trivial is `tests/lockstep.rs`. It drives
`improvise_command::ui::reduce::reduce_key` — the host's `Cmd`/`Effect` path —
against `resolve`, and those are still two derivations of the same answer, in
two crates, one of which cannot be reached from a browser. Moving `resolve`
changed which crate it is spelled in, not that it is a mirror, so
`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
does not fire and the test stays.
### The keymap is not this module's
It used to be. improvise-bpq2 gave this file a hand-written classifier that
decided, per key and per mode, what a keypress meant — a second spelling of
`default_keymaps()`, kept in step by a lockstep test over a hand-written list.
The user's ruling is that the web front end uses the **same** keymap as the TUI,
not a mirror of it, and
`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says
why: two definitions held honest by a compatibility test are one definition
waiting to be made.
So the table moved to `improvise_protocol::keymap` and this module reads it.
Which key means what is now correct **by construction**: a rebinding in
`default_keymaps()` reaches the browser with no change here, and a new binding
for a command this module can already execute simply works.
### What is left here, and why it is the better boundary
A lookup yields a command **name and arguments**. Turning that into something
that runs needs a `CmdContext` — a `Model`, a `GridLayout`, an `App` — which
this client does not have. So *key → intent* is shared and *intent → `Command`*
is ours, for the subset we can compose. `step` is that subset, and it is the
list that replaces the old list of keys. That is a list of names rather than of
keys, which is strictly better in two ways: it is enumerable, and it does not go
stale when a binding moves.
What this client can execute, and what each one needed:
- **Text entry** (`append-char`, `pop-char`, `clear-buffer`). The buffer is the
binding's own argument now — no longer read off `AppMode::minibuffer()` and
hoped to agree with what the host's keymap named. `TextEntry::new` derives one
from the other, so the two were equal; they are now the *same string*. The
pair `read_buffer`/`write_buffer` mirrors `cmd::core`'s, which are the only
code on the host that knows a runtime-named buffer may not be an entry in
`buffers` at all (improvise-gdl): the name `"search"` is
`ViewState::search_query`. The shared keymap binds `append-char ["search"]` in
`SearchMode`, so this client needs the same pair — and having it is what lets
search typing resolve here for the first time.
- **Mode entry** (`enter-mode`), decoded through `AppMode::from_name`, which
moved next to `AppMode` for the same reason the keymap did.
- **Grid, panel and tile cursors**, which needed `GridExtent` and the window
this client announced.
- **Entering an edit** (`edit-or-drill`), which needed ADR 002: the branch
`EditOrDrill` takes on "is this cell aggregated" is exactly the `CellId`
variant the server already projects.
- **Committing one** (`commit-cell-edit`, `commit-and-advance-right`), which
needed the record surrogate — again ADR 002 — and nothing else for the
plain-grid case.
### The second tranche, and the three it could not reach (improvise-e24v)
Fifteen more command names resolve here: `cycle-panel-focus`; the category
panel's `cycle-axis-at-cursor`, `open-item-add-at-cursor`, `filter-to-item`,
`toggle-cat-expand` and `delete-category-at-cursor`; the view panel's
`switch-view-at-cursor`, `create-and-switch-view` and `delete-view-at-cursor`;
tile select's `set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's
`help-page-next`/`help-page-prev`; `commit-buffer` in all three of its kinds;
and the two cell writes `clear-cell` and `paste`.
Each is `Cmd`-for-`Cmd`, including the parts that look like noise: the status
sentence a command writes beside its mutation is *part of the answer*, and a
client that emitted the mutation alone would diverge on the status line and
nowhere else — which is precisely the sort of divergence nobody notices. Two
orderings are load-bearing and copied rather than rationalised.
`CommitBuffer` writes its optimistic status **first** so the add's own failure
message wins (improvise-6t3), and `TileAxisOp` writes the axis **first** and
the status after.
**Three of the tranche's own list turned out to need enrichment after all**
(improvise-4bwa), and the reasons are worth more than the code:
- `toggle-prune-empty` words its status off `View::prune_empty`, which is in
the `Workbook`, in no cache, and not in `ViewState`.
- `delete-formula-at-cursor` 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 `HashMap`s, so two byte-identical documents
print differently per process (gotcha 18), and the first version failed on
nothing but hash order. And it applies the client's composed commands to a
**second authoritative host** rather than eyeballing them, restating the basis
against that host with `caught_up` — the claim was published by the first host,
and the basis is the only thing that legitimately differs between two hosts
holding the same document.
**How the client claims, and why there is nothing here to keep in step.** A
client must answer the same question the server does — which records does this
`ModelCommand` write — to build its `Basis`. The first version of this answered
it *again*, in two helpers (`structural`, claiming nothing, and `record_write`,
claiming the cell's own record), and the adversarial review of PR #50 showed
that nothing tested them: `lockstep.rs`'s `apply_on` restates the basis against
the twin with `caught_up`, which re-derives it with the **server's**
`records_named` and throws the client's claim away before anything is compared.
Emptying `record_write`'s claim left the workspace green.
The fix is not a test. `records_named` is pure — `&ModelCommand -> Vec<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 `Effect`s: a cursor move is `SetSelected` plus whichever
scroll offsets follow, and a `Binding::Sequence` is several commands to begin
with. **Never empty** — a keypress this client can answer with nothing is
`Ignored`, and `KeyOutcome::of` is the one constructor so that contract is a
property of the type's users rather than a rule each resolver remembers.
`Request(Request)` — resolved here, but as a **request to the host** rather than
as state this client can compose. Today the one member is `Request::Save`.
`Upstream(KeyEvent)` — bound to a command this client cannot compose.
`Ignored` — nothing bound, or what is bound resolves to no change. **Nothing
bound is not "the host might know better"**: the host looks the key up in this
same table. That is new; it used to go upstream into an empty vec.
### Why `Request` is its own enum and not a `ClientFrame` (improvise-jz6w)
`ClientFrame::Save` and a `Host::handle` arm for it had existed since
improvise-368h, and **no key could reach either**: `KeyOutcome` had no shape
that said "send this frame", so `save` sat on the host-needed list, resolved to
`Upstream`, and landed in the black hole. This is the gap a vocabulary audit
cannot see — nothing was missing from `Command` or `ModelCommand`, and the
enumeration that would have found it is over **`KeyOutcome`'s variants**, which
are as much of the protocol surface as the frames are. Enumerate them too.
The fourth variant could have carried a whole `ClientFrame`. It does not, and
the reason is not tidiness: a resolver handing out arbitrary frames could
compose a `Viewport`, whose extent is the *page's* fact and not the keymap's
(gotcha 47), or a `Commands` batch — which would be **indistinguishable on the
wire from a `Local`** while silently skipping the local fold that `Local`'s
whole contract is. The narrow enum costs one type and one `frame()` mapping, and
in exchange a request is exactly "ask the host to do a thing". `Request::frame`
is the one place a resolver's decision becomes an envelope.
Two consequences. `requested` is checked **first** in `step`, before `declined`
and `composed`, so no name can be in two lists — the same layering the other two
already have, and `composed` has no `"save"` arm to go stale. And a request
**cannot travel inside a `Binding::Sequence`**: a sequence is one frame and a
request is a frame of its own, so a sequence naming one is refused as
`Upstream`. Nothing binds one there; `no_binding_puts_a_request_inside_a_
sequence` is what keeps that arm unreachable rather than merely untested.
What `Request` deliberately does **not** do is become a `Command`. Gotcha 45's
argument stands: a `ClientFrame::Commands` batch promises all-or-nothing, and a
runtime-fallible write inside one reported a failed save and then applied the
edit that followed it.
A `Binding::Sequence` is **all or nothing**: half of `Esc` — clearing the buffer
without leaving the mode — is worse than none of it. Each step is resolved
against the *unchanged* state, because the host resolves every step against one
immutable `CmdContext`; the commands are then folded in order.
### What still needs the host
`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 `Command`s. `web/app.js`
attaches listeners and knows nothing else; there is one entry point per widget
on `Client`, which was the user's ruling on improvise-7ybz.
**Rule 1 — a click navigates to what was clicked, and activates it where
activation is something the client can express.** Every arm composes the same
shape: `focus` the panel, `SetPanelCursor` to the row, then whatever `Enter`
does on that row. The activation is resolved **against the clicked index, never
against the cursor**, because the `SetPanelCursor` ahead of it has not been
folded when the resolver runs — the trap that reads as "the cursor lands on
`Plan` and the document switches to `Default`", silent and wrong in the half a
user does not watch.
The second clause of rule 1 is 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 `ViewState`s agree — the correspondence is
checked rather than asserted in prose.
**The boundary is JSON**, so the JS side needs no generated structural bindings
and the same strings go on a socket unchanged. No `std::fs` (it compiles for
wasm and fails in a browser) and no clock: nothing here calls `Instant::now()`,
so the `web-time` shim the command crate needs is not needed. Both are
structural, not disciplined — this crate depends only on `improvise-protocol`,
`serde`, `serde_json` and `wasm-bindgen`, none of which reaches a filesystem or
a clock.
**Ordering.** The JS bootstrap must deliver frames to `Client::on_frame` in
arrival order, one at a time. The grid fold is deliberately not commutative — a
`GridUpdate::Cells` that overtakes the labels framing it is dropped — so batching
or reordering silently loses cells. For the same reason a locally-resolved
command and a forwarded keypress must go out on the *same* ordered channel, in
the order `Client::on_key_event` returned them.
**Why the `wasm_bindgen` surface is a thin wrapper over a plain-Rust one.**
`wasm_bindgen` methods cannot take or return the protocol's types, so there is
one code path serialized at the edge. The split is not only ergonomic:
`JsError::new` panics off wasm, so every fallible path has to be *reachable*
without it or its failure arm cannot be tested at all.
**`Client::viewport` vs `Client::set_viewport`.** `viewport` is associated
rather than a method, because a page must send the handshake *before* it has
anything to fold. But it builds the frame and forgets it, so a client driven
only through it never learns its own window and therefore resolves no cursor key
locally — a truthful subset (those keys go upstream and the host answers
nothing) but a whole feature quietly off. `set_viewport` is the one a page should
call. The window is **not derivable** from anything the host sends: the row
frame's own length is `min(window, grid − offset)`, so it agrees with the window
except near the bottom edge, which is precisely where the scroll arithmetic
matters. `viewport` is kept because `web/app.js` already spells it and retiring
it is a co-ordinated change.
### `improvise-protocol/src/client.rs` — the two properties this dispatch owes
Both are tested in `state.rs` rather than assumed from the protocol crate's own
tests, because the dispatch between them is this crate's.
- **The stream is not reordered.** Every fold path is a `for` over arrival
order; there is no batching, sorting or coalescing.
- **Bootstrap is a compaction, not a replay.** `ClientState::bootstrap` folds
`Snapshot::seed` exactly as the steady stream is folded, so there is no second
code path that could disagree with the incremental one. `GridCache::seed` puts
the labels before the cells for the same ordering reason.
**`window` is deliberately not reset by `bootstrap`**: the browser window did
not change size because the document was replaced, and clearing it would
silently stop this client resolving its own cursor keys until the next resize.
**`last_rejection` is kept as a value, not a sentence.** A rejection is the
input to *force*, and force is a re-composition against the republished basis —
which needs to know **which** record and **which** kind. `RecordGone` cannot be
forced at all; `RecordMoved` can be forced with one keystroke. A string cannot
carry that. It is session-local: it belongs to whoever composed the refused
batch, so it is not part of the portable slice and does not survive a bootstrap.
`window` is session-local and not portable for the same family of reasons: it is
a fact about a browser window, which is why no `Command` carries it and why it is
not on `ViewState`. `(0, 0)` means this client has not announced one yet, and a
client in that state resolves no cursor key — it has been sent no grid either.
### 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 `SetBuffer`s, 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.