web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two ordering rules — including the one nothing here can test, that an `async` message listener does not serialize — are now prose in context/code/clients.md. The harness keeps its own section: why a worker, why a report, the engine coverage and what `wipe` deliberately cannot clear. harness/tests/*.spec.js's comments were the record of what each assertion proves; they are in context/test-evidence/clients.md under the exact test titles, including the `test.fail` measurement that could NOT become a CI guard because its trigger is the host's network stack, and the closed-invalid improvise-xps8 report that a simulated fix appeared to confirm. ws-server/src/main.rs's ```text fence was prose claiming the doctest exemption, not a runnable doctest; the synopsis is now two lines and the full form is in the prose file. No fences remain in this unit. Verified: node --check on every JS file touched; the non-comment diff for both '*.rs' and '*.js' is empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
61 KiB
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
MessagePortis point to point, so theSessionIdis not an address the transport has to carry — it is a field.Addressedis still honoured rather than assumed away (Worker::frames_for), because aSharedWorkerwould make it matter again and the honest filter costs one line.Host::relayaddresses 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::Viewportmust come first, for the reason it must on the socket: aSnapshotseeds the visible cells, so one built at extent zero is an empty grid. A socket enforces that by closing the connection; a worker has nothing to close, so a frame that arrives before the handshake is dropped, which is the same outcome without a mechanism that does not exist here.
ClientFrame::Key is answered with nothing, and that is not laziness
A worker holds the authoritative App, so ui::reduce::reduce_key really is
callable here where it is not on a socket. Calling it would still be wrong, and
the reason is not about transports at all: nothing in the projection stream
carries the host's own ViewState. A client's view slice moves only by
folding Commands, and a keypress resolved against the App moves the
App's slice — its mode, its cursor, its minibuffer — with no Command
produced that any client could fold. ClientSession::project emits document
projections, a grid diff and a basis; none of the three is a mode change.
So a half-wired Key arm does not under-deliver, it diverges: the host
would believe it is in AppMode::Editing while the client draws Normal, and
ClientSession::viewport() reads the mirror's cursor, so the grid would not
even scroll to follow.
improvise-bpq2 answered it, and not by deriving Commands from a
ViewState delta. The user's ruling is that a keymap is UI and the server must
not know it, so resolution moves to the client: a key the client can answer
arrives here already resolved, as a ClientFrame::Commands batch it has folded
itself, and this arm's emptiness stops being a gap and becomes the statement.
What remains upstream is the set of bindings a client still cannot compose —
prefix keys, anything naming a row's category or a group, anything touching
Storage — and those are closed by enriching the projection, one at a time,
not by wiring this arm up. The list lives in keys.rs (see below).
Persistence is the worker's, whether or not anybody asks
There is no ModelCommand::Save, and that is not an oversight: effect::Save
is EffectKind::ViewOnly (it writes the document to this host's file path
and clears this host's dirty flag; it mutates no document state), so the
derivation that produces ModelCommand from the model effects cannot reach it
(decision(improvise-cqi, command_set_derived_not_invented)). Saving was
therefore unreachable from any client on any transport until improvise-368h
added ClientFrame::Save — a request to the host, framed rather than
commanded precisely because it mutates nothing shared.
A browser still never sends it, and that is the right answer rather than a
gap: there is no file the user owns and no Ctrl-S semantics to honour, so
asking would be the wrong question — the document is the OPFS entry, and a
page that loses an edit because nobody pressed a key has simply lost it. So the
worker persists on its own behalf: Worker::persist runs the one existing
Save effect through App::apply_effects, so effects remain the only mutation
site and the save path is not duplicated. A worker with no file_path silently
does nothing, which is what Save already does; a failed save leaves its
message in the status and aborts the batch.
The trigger is an observation of the document, not a table of command
variants. It used to be the latter, and the reason was honest: a wire
ModelCommand::SetCell reached effect::SetCell, which did not raise
model_state.dirty — that was a separate MarkDirty effect only the key path
composed — so "save if dirty" was a trigger that never fired (improvise-gv9r).
With dirtiness raised by App::apply_effects off the same EffectKind the
Dirty is, the flag is the observation, and asking it is better than
enumerating variants for the same reason the cursor reset watches cursor_epoch
rather than matching on CycleAxis, and the rebase watches
document_generation rather than matching on LoadModel: a table of variants
is a second description of which commands change the document, free to disagree
with the first. It is also strictly tighter — a refused batch changes nothing,
so it raises nothing, and the "was anything rejected" special case that used to
guard this went with it.
One cost remains and is stated rather than hidden: Storage is whole-document
by construction, so OPFS rewrites the file per accepted model command —
correct, not yet cheap. And a failed write leaves the flag raised, so the next
frame retries: right for a transient failure, and harmless for a permanent one
(a worker with no file_path) because the only thing a failed Save produces
is a status line, and nothing in a worker renders one.
The one frame on_frame does not auto-persist after is ClientFrame::Save:
a client that asked to save has already been answered, and a failed write leaves
dirty set — so retrying would repeat the I/O and, on a transient failure, tell
the client SaveFailed about a document that is now on disk. The automatic
write exists for edits nobody asked to save.
So this is the browser's own answer, and ClientFrame::Save is the other one —
for a socket client editing a TUI-hosted document, where there is a file
somebody owns.
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/ServerFrameareimprovise-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 authoritativeApp, oneSessions, and ahandle(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 aMessagePortand cannot depend on tokio.src/host.rsis a re-export ofimprovise_command::ui::host, kept rather than deleted soimprovise_ws_server::host::Hoststill resolves.Hostknowing 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 ofHosts keyed by document id, the lifecycle rules, and the one place a per-documentSessionIdbecomes 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_generationandrecord_versionsstart at zero in a freshApp, 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.
improvise-wasm-client/src/keys.rs — browser key → Command
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 offAppMode::minibuffer()and hoped to agree with what the host's keymap named.TextEntry::newderives one from the other, so the two were equal; they are now the same string. The pairread_buffer/write_buffermirrorscmd::core's, which are the only code on the host that knows a runtime-named buffer may not be an entry inbuffersat all (improvise-gdl): the name"search"isViewState::search_query. The shared keymap bindsappend-char ["search"]inSearchMode, 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 throughAppMode::from_name, which moved next toAppModefor the same reason the keymap did. - Grid, panel and tile cursors, which needed
GridExtentand the window this client announced. - Entering an edit (
edit-or-drill), which needed ADR 002: the branchEditOrDrilltakes on "is this cell aggregated" is exactly theCellIdvariant 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.
Mirror the Cmd line for line, including where siblings disagree
doctrine(mirror_the_cmd_line_for_line). Move emits SetSelected even when
it clamps and MovePanelCursor returns nothing when the cursor does not move;
MoveTileCursor emits regardless, like Move. Tidying that into one rule
would make this a second derivation, which is exactly what tests/lockstep.rs
exists to rule out. Which widgets say nothing about a move that goes nowhere is
not a rule of this module — it is whatever the Cmd being mirrored does, and
the three differ.
Two more places the mirroring is literal. panel_cursor clamps a cursor past
the end before applying the delta, and derives the category panel's length
from flatten_cat_tree — which depends on expanded_cats, per-client, which is
why it is derived rather than sent and why the derivation lives in
improvise-protocol where both ends reach it (improvise-edp). And commit
mirrors change_mode(exit_mode_for(edit_mode)), then EnterAdvance — which
aborts the rest of the batch at the bottom-right corner, so the re-entry is
skipped and the user lands in normal mode — then EnterEditAtCursor, which
seeds the edit buffer from the cell it landed on. The trailing clear-buffer
the keymap welds to every commit then empties that buffer again. That ordering
looks wrong and is exactly what the TUI does; mirroring it is the point.
GridMove is a hand-spelled mirror of navigation.rs's MoveKind, because the
command crate is not on a browser's dependency list and never will be. The
keys that reach it are no longer spelled twice. scrolled_to mirrors
navigation.rs::viewport_effects over the window this client announced.
The three outcomes
KeyOutcome::Local(Vec<Command>) — resolved here; the caller folds them locally
and sends them, as one frame and in this order (the asker's own fold is its
own; the host forwards only to the others). A Vec because one keypress is one
binding but several Effects: a cursor move is SetSelected plus whichever
scroll offsets follow, and a Binding::Sequence is several commands to begin
with. Never empty — a keypress this client can answer with nothing is
Ignored, and KeyOutcome::of is the one constructor so that contract is a
property of the type's users rather than a rule each resolver remembers.
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.
A Binding::Sequence is all or nothing: half of Esc — clearing the buffer
without leaving the mode — is worse than none of it. Each step is resolved
against the unchanged state, because the host resolves every step against one
immutable CmdContext; the commands are then folded in order.
What still needs the host
the_command_names_that_still_need_the_host_are_these names them, and
Host::handle answers a Key frame with an empty vec, so each one is a binding
that does nothing at all in a browser
(doctrine(an_upstream_key_is_a_black_hole)). The classes:
- Prefix keys.
Command::SetTransientPrefixexists and this client could arm one; what it cannot do is what the second key then runs, which isyank,wqandtoggle-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-anr9GridCachecarries anAxisFrameper axis, socats[0]+label(idx, 0)is exactly whatHideSelectedRowItemreads offGridLayout, andgroup_of(idx)is exactlyToggleGroup's(category, group). The one thing to get right is that the frame is windowed and the cursor is absolute, so a client must reconcile the scroll offset first, asrender.rsdoes for the selection.delete-formula-at-cursorstays: it names a position in the formula panel, not in the grid. - Anything touching storage or an arbitrary command line. The host's by definition.
- The records-mode commit. A wide-records row is one coordinate tuple and
editing a category column moves every measure cell in it;
commit_plain_records_editneedsGridLayout::record_colto know which. - Drill. Session-local residue, and the one case that does not go upstream silently — see below.
strands_the_user: 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.
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.EnterTileSelectgates onmodel.category_names().len() > 0, which counts the virtual categories too —_Index,_Dimand_Measurealways exist — so the gate is invariantly true and there is nothing here to mirror it against. Re-deriving it fromCategoryTreeCache, which carries the panel's rows rather thancategory_names(), would be a second derivation that disagrees on exactly the empty document.transpose. It used to be emitted alongside amark_dirtythis client had no wire variant for; since improvise-gv9r dirtiness is raised by the host off the effect's ownEffectKind, so there is nothing to mirror and nothing missing. The cursor reset is not this client's to send either: the effect callsApp::reset_cursor, andadopt_cursor_epochbroadcastsCommand::ResetCursorto everyone in answer — so sending one here would be a second, differently-addressed copy.toggle-panel-and-focus. The interactive arm ofTogglePanelAndFocus: open-and-focus unless this panel is already both, in which case close and unfocus. The decision is made inregistry.rson the host and here on the client, from the same twoViewStatefacts.edit-or-drill. The host asks whether a regular category sits onAxis::Noneand whether the cursor is on a synthetic records cell. This client asks neither: the server already published the answer as theCellIdvariant, anddoctrine(a_demo_is_checked_against_the_type_not_the_axes)says to read the type rather than re-derive the rule. It readsedit_text, nevertext: the drawn text has been throughformat_f64, so a stored 1200 reads"1,200"under a comma-grouping format and committing it would store the string"1,200".tests/lockstep.rsfound exactly that.- Drill does not go upstream. Every
Upstreamis 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 aCellKeythis client cannot build, so the honest answer is to say so: aSetStatus, which is a view-half command —Sessions::applyfolds it into the asker's own mirror and broadcasts nothing, so it reaches this user and no one else. - The commit's write is not spelled again here. It is
Client::typed_cell's, the same one click-to-edit uses, so a browser keyboard commit and a browser mouse commit cannot disagree about what""or"42"means. cell_at_cursorreturnsNonewhen 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.Quithas no keymap, on purpose:ModeKey::from_app_modeis 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.
improvise-wasm-client — the crate, the fold, the render model
What the crate is, structurally
One wasm32 artifact, reused literally in both deployments: the thin client
talking to a server over a WebSocket and the standalone app talking to a worker
that hosts the App over the worker's own port. Only the JS bootstrap differs —
nothing in the crate knows what the transport is.
It is a fold and a key classifier, and nothing else. It holds a ViewState
plus one widget cache each — there is no aggregate render cache — and it moves
them by folding the same Command stream the host folds. The fold is state.rs;
the key classifier is keys.rs.
The argument that shaped it: a keymap binding names an intent resolved
against a CmdContext; a Command is the resolved mutation. A thin client
holds no Model and no GridLayout, so it cannot do that resolution. (Since
improvise-bpq2 the table is shared and the resolution of the composable subset
happens here; what is left upstream is the subset keys.rs names.)
That is why the command crate is a dev-dependency and not a dependency: its
keymap could not be used here even if the bundle could afford App, Workbook,
persistence and the import wizard. What the dev-dependency buys is
tests/lockstep.rs, which drives the authoritative host and this client over the
same keypress and asserts the two ViewStates agree — the correspondence is
checked rather than asserted in prose.
The boundary is JSON, so the JS side needs no generated structural bindings
and the same strings go on a socket unchanged. No std::fs (it compiles for
wasm and fails in a browser) and no clock: nothing here calls Instant::now(),
so the web-time shim the command crate needs is not needed. Both are
structural, not disciplined — this crate depends only on improvise-protocol,
serde, serde_json and wasm-bindgen, none of which reaches a filesystem or
a clock.
Ordering. The JS bootstrap must deliver frames to Client::on_frame in
arrival order, one at a time. The grid fold is deliberately not commutative — a
GridUpdate::Cells that overtakes the labels framing it is dropped — so batching
or reordering silently loses cells. For the same reason a locally-resolved
command and a forwarded keypress must go out on the same ordered channel, in
the order Client::on_key_event returned them.
Why the wasm_bindgen surface is a thin wrapper over a plain-Rust one.
wasm_bindgen methods cannot take or return the protocol's types, so there is
one code path serialized at the edge. The split is not only ergonomic:
JsError::new panics off wasm, so every fallible path has to be reachable
without it or its failure arm cannot be tested at all.
Client::viewport vs Client::set_viewport. viewport is associated
rather than a method, because a page must send the handshake before it has
anything to fold. But it builds the frame and forgets it, so a client driven
only through it never learns its own window and therefore resolves no cursor key
locally — a truthful subset (those keys go upstream and the host answers
nothing) but a whole feature quietly off. set_viewport is the one a page should
call. The window is not derivable from anything the host sends: the row
frame's own length is min(window, grid − offset), so it agrees with the window
except near the bottom edge, which is precisely where the scroll arithmetic
matters. viewport is kept because web/app.js already spells it and retiring
it is a co-ordinated change.
state.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
forover arrival order; there is no batching, sorting or coalescing. - Bootstrap is a compaction, not a replay.
ClientState::bootstrapfoldsSnapshot::seedexactly as the steady stream is folded, so there is no second code path that could disagree with the incremental one.GridCache::seedputs the labels before the cells for the same ordering reason.
window is deliberately not reset by bootstrap: the browser window did
not change size because the document was replaced, and clearing it would
silently stop this client resolving its own cursor keys until the next resize.
last_rejection is kept as a value, not a sentence. A rejection is the
input to force, and force is a re-composition against the republished basis —
which needs to know which record and which kind. RecordGone cannot be
forced at all; RecordMoved can be forced with one keystroke. A string cannot
carry that. It is session-local: it belongs to whoever composed the refused
batch, so it is not part of the portable slice and does not survive a bootstrap.
window is session-local and not portable for the same family of reasons: it is
a fact about a browser window, which is why no Command carries it and why it is
not on ViewState. (0, 0) means this client has not announced one yet, and a
client in that state resolves no cursor key — it has been sent no grid either.
socket.rs — the frames, the wording, and the one composition
Where the frames live: not here. ClientFrame/ServerFrame are
improvise-protocol's, and the same pair frames both deployments. This crate
used to carry two envelope pairs of its own: one mirroring
improvise-ws-server, because a native crate cannot be a dependency of a wasm
bundle, and one written for the worker before the projection architecture
landed. The mirror was held honest by two tests round-tripping every variant
through the server's own types; with one definition there is nothing left for
them to compare, which is what improvise-xks6 was for.
rejection_message is spelled here rather than on Rejection for the
reason AxisColor is not a ratatui colour: the protocol carries the fact, and
each front end words it. The wording tracks ADR 001's force table, because "try
again" and "this cannot be forced" are the two different things a user must be
able to tell apart — a RecordGone has nothing to write to, and offering force
there would promise something the design refuses to deliver (force never
creates structure). Two wordings are load-bearing: DocumentReplaced must not
say "reloading", because nothing here reloads, and the two save failures are not
staleness so neither offers a retry — retrying changes nothing about the host's
storage.
typed is a rule that genuinely lives in two places. It is the same
three-way split commit_regular_cell_value makes — empty clears, a parseable
number is a number, anything else is text. It is duplicated here for the reason
the whole client exists: the browser has no App to ask, and
ModelCommand::SetCell takes a CellValue, so somebody on this side must
decide. Putting it in Rust rather than in the page's JavaScript is what makes it
testable and keeps one spelling per language boundary instead of two.
This is the only answer the client gives to "what does a typed string
mean" (improvise-r4lo), and it holds on every deployment including the worker: a
host's App sits behind the frame boundary and is handed a value, not a string,
so no host ever classifies a string that arrived over the wire. The careful
half: a host does classify its own local keystrokes, which is why this is a
duplicate of that split rather than a call to it — the command crate is
deliberately not a dependency, and reaching it would drag App, Workbook and
persistence into the browser bundle.
So a_typed_string_is_classified_as_the_host_classifies_it states the
obligation without discharging it: it asserts the three outcomes by hand
rather than comparing with the host's function, so the two could drift and stay
green. That is
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) still
open, one layer below what improvise-r4lo closed — the real fix is a shared
split in improvise-protocol, which both crates already depend on. Filed rather
than done.
typed's None means "clear this cell", which is a different command, not a
different value — hence the Option rather than a CellValue::Text("").
typed_cell_frame is the composition improvise-q08 found a thin client could
not do. It holds no Model, so it cannot build a CellKey — but it does not
need to: the id on the cell it is already drawing is the address, and it is
echoed back unread (ADR 002). It takes the user's string, not a CellValue,
for the reason above; there is no deployment for which a pre-classified entry
point would be right. A sibling taking a serialized CellValue used to sit
beside it and claim the opposite; it was deleted rather than re-documented,
because it could not express ClearCell and was therefore a strictly weaker
duplicate.
None on four shapes, each a different fact the client already holds: the
position is outside the grid; the position holds no settable cell (id: None —
padding, or a records-mode column that renames a coordinate rather than writing
one); the cell is an aggregate, which is the pattern failing to bind rather
than a check; or this client has not been told the document's basis and so has
nothing truthful to say about what it composed against (BasisCache::claim
withholds rather than invents).
Nothing is folded there. The new value comes back as a GridUpdate::Cells
from the host, which is the side that formats it; guessing it locally is how the
two ends drift apart.
typed_cell_command is the one composition shared by the mouse path
(typed_cell_frame) and the keyboard path (keys::commit). Writing it twice is
how a browser commit by Enter and a browser commit by click would come to
disagree about what an empty string means.
render.rs — the four decisions that are silent when wrong
The DOM half of this client is JavaScript. The split is drawn to make that
acceptable rather than comfortable: every question with a wrong answer is
answered here, in Rust, under test, and what crosses to the page is a list of
rows of cells that each already know whether they are editable, numeric,
erroneous and selected. The page's job is document.createElement.
- Editable is a fact about the id, not about the text. Only
CellId::Recordmay be written (ADR 002).CellId::Aggregateis a drill target andDrillis not on the wire yet, so it renders as non-editable rather than as a control that does nothing. AndNoneis not "aggregate" — 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.
GridCacheis indexed by position within the viewport, whileselectedandrow_offset/col_offsetare 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.rsdoes 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 inimprovise-protocolprecisely so this end andmove-panel-cursorshare one flattening (improvise-edp); the page never seesCategoryTreeCacheat 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 sameAxisFramemethods the TUI widget calls, so the page receives labels already blanked where they repeat and group runs already spanned. Until this landed the protocol carriedGridLayout::row_label's slash-joined tuple and the page drewFood/Alicewhere 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:
RenderRowis an enum, not alabelplus anis_groupflag, so a group header cannot carry cells and a data row cannot carry a collapse state. It is serialized with akindtag 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 walkinglabels()over the whole model sees the data rows and nothing else, which is exactly whatneither_front_end_produces_a_slash_joined_labelwants. A caller that means "every label on screen" has to match on the variant.RenderColGroup::labelis anOption, not an empty string. An empty string is a legal group name —Category::add_item_in_groupaccepts 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).Nonefor 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 ascolspancannot leave a hole.- Column group runs coalesce by NAME alone, which is what
src/ui/grid.rsdoes (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_groupsreturns 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 asymmetryGridCache::totalshas. hintandeditingare decided here, not in the page.hintisAppMode::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 (Helpabove all) state what they are instead of looking like a broken grid.editingisAppMode::is_editing()plus theeditbuffer: without it, a browser that resolvedilocally would change its mode indicator and then swallow every keystroke invisibly.
RenderCell::edit_text is the stored value, not the formatted one: seeding an
editor from text and committing turns 1200 into the string "1,200". The
numeric/error flags are the server's answers, echoed — the client must not
re-derive them from the text, which is how the two ends drift.
render_model builds header_labels(0) once for the whole frame, because
the per-position accessors scan and asking them inside the row loop is quadratic.
web/app.js — the page
The browser half of improvise-cr3, kept as thin as it can be made, now talking
to either of two peers (improvise-djm). The boundary is JSON strings in and
out, so this file holds no protocol knowledge at all: it never inspects a
Command, never builds a CellId, never decides whether a cell is editable or
whether a typed string is a number. Five calls are the whole harness:
| call | what it is |
|---|---|
client.set_viewport(rows, cols) |
the handshake frame, and every resize; also records the window so keys resolve here |
client.on_frame(json) |
fold one frame, in arrival order |
client.render_model() |
everything to paint, already decided |
client.typed_cell(r, c, s) |
the frame for "the user typed s here" |
client.on_key_event(k,c,a,s,m) |
the frame for a keypress, folded locally first where this client may answer it |
The two rules this file must not break are ordering and framing: frames are folded one at a time in the order they arrive (a cell update that overtakes its labels is dropped, not replayed), and the first frame out must be the viewport, because the server builds the snapshot at it.
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
Appover 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 toimprovise-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.
#status carries two independent facts — what the transport is doing and
what the document last said — so it composes them rather than letting a repaint
quietly erase a connection error.
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, not Client.viewport
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).
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.
Inline group ROWS are deliberately NOT reserved. They take real height, but
how many are in view depends on how many rows the window asked for, so every way
of counting them is either a feedback loop (count the current ones: a smaller
window crosses fewer groups and asks for a larger one) or a ratchet (keep the
maximum: a group-dense grid stores an oversized cost on its first oversized
projection and never lets it fall — one group header per row clamps the grid to a
single row, permanently). Both were tried. What is left is an over-request
bounded by the groups in view: a grouped grid asks for a few rows more than the
table shows, which matters only for a selection near the bottom edge — and that
gap is not new and not specific to groups, since nothing in this page calls
scrollIntoView at all. Filed as its own ticket rather than answered with a
heuristic in a file the harness only partly executes.
The watchdog, and the two close arms
The handshake watchdog is about the first frame, not about either peer's own
failure modes. The socket has an error event for a peer that never answers;
the worker has none that fires, because worker.js catches its own start()
rejection — so a browser with no OPFS, or a worker bundle that failed to load,
would leave the page saying "in-page worker" over an empty grid forever. "The
peer never answered the handshake" is the one condition that means the same thing
in both deployments, and a check aimed at each peer's own failure modes would have
missed exactly this one.
A peer that failed loudly has already said something more precise, so the
watchdog must be DISARMED and not merely pre-empted: it fires later and would
overwrite the better message with "did not answer" (PR #13 review). The watchdog
is for silence only, and setShellFailure clears the timer.
Did this socket ever open? The page has to record that itself, because
nothing on the close event carries it: readyState is already CLOSED by the time
a close listener runs, whichever happened, and a refused connection and a
mid-session network drop are BOTH code 1006 with wasClean: false. So "never
connected" and "was connected and went away" are distinguishable only by
remembering — an opened flag is the only discriminator.
A socket that never opened did not "disconnect": it failed to connect, and the
close event is the tail of that failure rather than a second, vaguer thing that
happened (improvise-taxx). Writing "disconnected" there unconditionally replaced
the error listener's precise message with the least informative of the two
facts.
That is the same family as the watchdog bug and NOT the same fix. The
watchdog is disarmed because it is for silence only, whereas suppressing close
outright would hide a genuine mid-session disconnect, which is the one thing that
listener is really for. Hence the split rather than a guard. Both arms go
through setShellFailure, which disarms the watchdog — and that half is the one
that keeps getting lost. It matters on the opened path: the watchdog is armed
after transport.ready resolves, i.e. on open, so a socket that opened and then
dropped before its first frame reaches the close listener with the timer still
pending, and "disconnected" is more precise than the guess it would otherwise fire
8s later. On the never-opened path there is no timer yet and the clear is a no-op.
Neither arm leaves one armed.
Painting
- The column-group line is drawn only when there is one: an empty
col_groupsmeans this grid has no column groups at all, not that every group is blank.labelisnullfor 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. ris 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.editingis 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. editableis the server's answer relayed through Rust: a stored cell. An aggregate is a drill target and drill is not on the wire yet, so it is simply not offered.- The inline editor seeds from
RenderCell::edit_text— the STORED value, not the drawn one:td.textContenthas 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 firesblur, which callscloseagain: harmless, but only by accident.
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.
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:
- Post every reply, in the order the Rust returned them, one message per
frame. A
GridUpdate::Cellsthat overtakes the labels framing it is DROPPED by the client's fold, not replayed. Never batch two calls' replies together and never reorder them. - Await
flush()after each message.Storage::deleteis visible toread/listimmediately 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 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.
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. So a change to web/*.js belongs in this spec directory, not in a
second ad-hoc surface beside it.
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. 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 is Chromium-only on purpose: it is about web/app.js's own
logic, which is engine-independent, and routeWebSocket plus the worker
handshake are slower and flakier to no benefit across three.
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.