92f8ac2113
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
3366 lines
206 KiB
Markdown
3366 lines
206 KiB
Markdown
# 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.
|
||
|