2547 lines
144 KiB
Markdown
2547 lines
144 KiB
Markdown
# `improvise-protocol` — the wire vocabulary and the portable session slice
|
||
|
||
The arguments that used to live in this crate's comments (improvise-9c8c).
|
||
Written for a reader who has the code open. Nothing here restates what the
|
||
code plainly says; everything here is something somebody got wrong first.
|
||
|
||
The crate crosses a process boundary, so it depends on `improvise-core` and
|
||
`serde` and nothing else — no terminal backend, no command layer, no IO.
|
||
`improvise-command` depends on *this*, never the reverse, and that direction
|
||
is what lets a client hold the view half without pulling in the host.
|
||
`boundary_tests::the_wire_types_need_nothing_but_core_and_serde` is the part
|
||
the compiler can hold; the CI `wasm` job is the other half, since a crate
|
||
that pulled in crossterm would stop building for wasm32.
|
||
|
||
Three things live here and it is worth being precise about which travels:
|
||
|
||
- **`ViewState`** (`lib.rs`) is a *projection of the command stream*, not a
|
||
wire message. The server keeps a mirror of each client's view state by
|
||
applying the same commands the client applies, through the shared
|
||
`reduce_view`. It is serialized at exactly one point — the bootstrap
|
||
`Snapshot` (improvise-q08). Steady-state traffic is commands only.
|
||
- **`Command`** (`reduce.rs`) is the wire format, in both directions: user
|
||
intent client→server, `Projection`s server→client.
|
||
- **`ClientFrame`/`ServerFrame`** (`frame.rs`) is the *framing* rather than
|
||
the vocabulary: which of the above a given JSON blob is.
|
||
|
||
---
|
||
|
||
## 1. `lib.rs` — the portable slice
|
||
|
||
### What may live on `ViewState`
|
||
|
||
Two properties gate membership, and both are load-bearing (improvise-h0b):
|
||
|
||
- **portable** — plain data from the allowlisted crates, no shared-ownership
|
||
containers. The `ViewState: Send` static assertion is the compile-time
|
||
proxy (`Rc` is not `Send`); the `Deserialize` derive rejects it too, serde's
|
||
`rc` feature being off. `Send` does **not** catch the origin-crate or
|
||
snapshot-safety halves — check those by hand.
|
||
- **snapshot-safe** — a fresh client inheriting the value is *correct*.
|
||
Staged uncommitted writes and half-open dialogs are not.
|
||
|
||
State failing either test lives on `App` instead, under the *session-local
|
||
residue* tag: `wizard` (an `improvise-io` type, and a half-open dialog is not
|
||
something to inherit) and `drill_state` (holds an `Rc`, and `pending_edits`
|
||
are uncommitted writes belonging to whoever staged them).
|
||
|
||
The constraint this puts on new fields: **no borrowed-`'static` fields
|
||
anywhere `ViewState` can reach** — `Deserialize` cannot produce one, and the
|
||
error surfaces on every *enclosing variant* rather than at the offending
|
||
field. That is why `MinibufferConfig::buffer_key` is a `String` and not a
|
||
`&'static str` (improvise-a2x).
|
||
|
||
### `ViewCursor` and the `cursors` map
|
||
|
||
Per-client (improvise-8uz). It used to live on `View`, inside the shared
|
||
workbook, which meant one client scrolling moved everyone's viewport. `View`
|
||
no longer carries it at all — nothing persisted it, so there was no
|
||
saved-position behaviour to preserve. The map is keyed by view name so that
|
||
switching views restores where you were in each one; an absent key means the
|
||
origin.
|
||
|
||
### `ViewState::active_view` is the second copy, deliberately
|
||
|
||
`Workbook::active_view` is the document's; `ViewState::active_view` is the
|
||
client's, and it is what keys `cursors`. The second copy exists because
|
||
`reduce_view` runs on a client that holds no `Workbook` (improvise-cqi). The
|
||
host keeps the two in step; whether they are *allowed* to diverge — two
|
||
clients on different views — is improvise-g0f. See repo-map gotchas 29/36.
|
||
|
||
### `AppMode::from_name` — not every variant is nameable
|
||
|
||
The list is exactly what `improvise-command`'s registry accepted before the
|
||
move, carried across unchanged, because changing it here changes what
|
||
`enter-mode` accepts in a script. It lives beside `AppMode` rather than in
|
||
the registry because the *names* are part of the keymap vocabulary and the
|
||
keymap is shared: a browser resolving `enter-mode` must decode the same
|
||
spellings, and two tables would be the mirror that move existed to delete.
|
||
|
||
Three variants are absent, for two reasons. `ItemAdd` resolves its target
|
||
category from the runtime mode and never from a script line (gotcha 20), so
|
||
naming it would produce a mode with nothing to add to. `Quit` and
|
||
`ImportWizard` are entered by their own commands — `quit` and
|
||
`start-import-wizard` — which do work beyond changing the mode, so
|
||
`enter-mode` would be a way to reach half of each.
|
||
|
||
### `AppMode::FormulaEdit` carries what it will commit to (improvise-km71)
|
||
|
||
`FormulaEdit { minibuf, slot }` is one mode for two editors. `slot` is
|
||
`FormulaSlot::New` for `a`/`n`/`o` (`enter-mode formula-edit`, which
|
||
`from_name` still builds) or `FormulaSlot::Existing { id, was }` for
|
||
`edit-formula-at-cursor`. It is one variant and not a second `AppMode` because
|
||
the keys, the buffer and the panel are identical: a second variant would add a
|
||
`ModeKey`, a keymap and a hint for no difference in what the keys do. The slot
|
||
is `ItemAdd`'s `category` again, a capture made when the mode is entered. Like
|
||
`ItemAdd` it is not nameable from `from_name`, since a script line has no
|
||
formula to open. It must be portable and snapshot-safe (§1 above). `FormulaId`
|
||
is plain serde data, and a fresh client that inherits an open edit inherits
|
||
exactly the claim the editor made. The commit re-checks that claim.
|
||
|
||
### `Panel`
|
||
|
||
`Panel::ALL`/`name()`/`key()` drive the toggle keys for Normal *and* all
|
||
three panel keymaps, so a fourth panel reaches every keymap from one `ALL`
|
||
entry. `name()` must spell what `parse_panel` accepts. `Panel::mode()` is the
|
||
inverse of `AppMode::panel()` for the three top-level panel modes only — the
|
||
sub-modes (`FormulaEdit`, `CategoryAdd`, `ItemAdd`) map back to their panel
|
||
but not forwards, which is what keeps a panel's styling active while the user
|
||
types into its minibuffer.
|
||
|
||
`AppMode::hint_text()` is checked against the keymaps by
|
||
`every_hint_key_resolves_in_its_mode` in `improvise-command`; `Quit` alone
|
||
shows nothing, because it is never rendered.
|
||
|
||
### `all()` walks a cycle, and its totality is tested rather than structural
|
||
|
||
`AppMode::all()` and `Rejection::all()` walk a total, cyclic `after()`. Both
|
||
doc comments used to claim a new variant could not be omitted, because
|
||
`after()` has no `None` arm. **That is half true and the wrong half.**
|
||
Answering `after()` *for* a new variant is compiler-forced; pointing an
|
||
existing arm *at* it is not, so a variant nothing names is simply never
|
||
walked — `all().len()` came back 17 where the enum had 18, with the suite
|
||
green (improvise-5ubs). The general statement is
|
||
`doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness)`:
|
||
an `assert_eq!(cases.len(), 13)` catches a case being *deleted* from a list
|
||
and never one being *absent* from it, and the two are written identically.
|
||
|
||
Closing it needs an enumeration independent of `after()`, and without one the
|
||
ticket's own first option restates the problem. There is no such enumeration
|
||
in stable Rust for an enum with data — `variant_count` is nightly, a derive
|
||
macro means a new proc-macro crate, and `strum`'s `EnumIter` cannot construct
|
||
these variants at all, since `AppMode::ItemAdd` carries a category name and
|
||
`Editing` a whole `MinibufferConfig`. What there *is* is **serde**: the
|
||
`Deserialize` derive already builds the complete variant-name list, from the
|
||
enum definition, maintained by the compiler, and publishes it in its
|
||
`unknown variant … expected one of …` message. `variants.rs` reads it back
|
||
and diffs it against the names the cycle actually reached.
|
||
|
||
Three things this costs, and they are the reason the module is defensive
|
||
rather than three lines. The message is **parsed**, so a serde whose wording
|
||
changes must fail loudly instead of reporting an empty list that passes every
|
||
cycle — hence the `unknown variant` prefix assertion and the `names.len() > 1`
|
||
floor. Both live in `parse_expected`, a seam separated from the deserialization
|
||
**because a `should_panic` pointed at a real type reaches only one of the three
|
||
refusals** and all three then shared one panic message, so `expected = …` could
|
||
not say which had fired (PR #92 review). Each now carries its own wording and is
|
||
fed a hand-written mutated message; a happy-path case keeps the three refusals
|
||
from being the only evidence the parser runs. A variant is identified by **its own serialization** rather
|
||
than by `Debug`, because an externally tagged unit variant is a string and a
|
||
struct variant a one-key object, and reading a name off `Debug` would depend
|
||
on the field list. And it checks only that every declared variant is
|
||
*reached*: an `after()` that is not injective — two arms naming one successor
|
||
— is a different defect this does not see, and would show up as a shorter
|
||
cycle only if it also orphaned something.
|
||
|
||
What it does **not** reach: the fourth instance of this shape,
|
||
`layout_geometry_effects_actually_change_document_config` in
|
||
`improvise-command/src/ui/effect.rs`, whose `assert_eq!(cases.len(), 13)` is
|
||
over a hand-built list of *effects*, not enum variants, so serde has nothing
|
||
to say about it. Filed as improvise-26dm rather than bodged, because a
|
||
fourth hand-rolled
|
||
attempt in a fourth place is the disease.
|
||
|
||
---
|
||
|
||
## 2. `reduce.rs` — the wire vocabulary
|
||
|
||
### Derived, not invented
|
||
|
||
`Command` is not a fresh invention
|
||
(`doctrine(derive_the_enum_from_the_effects)`):
|
||
|
||
- The **view half** is every `EffectKind::ViewOnly` effect in
|
||
`improvise-command` whose target is the portable slice
|
||
(`decision(improvise-cqi, command_set_derived_not_invented)`). Effects
|
||
touching host-local state (the import wizard, staged drill edits, the
|
||
storage backend, the layout cache) are deliberately absent.
|
||
- The **model half** is `ModelCommand`: every `EffectKind::Model(..)` effect,
|
||
one variant apiece, carrying that effect's own fields (improvise-kmgs).
|
||
|
||
The count is 28 model effects, minus two: `ApplyAndClearDrill` and
|
||
`AcceptWizardModel`, which are **not addressable** (improvise-3x3u). That
|
||
leaves **26**: 24, plus `ReplaceFormula` (improvise-km71) and
|
||
`RenameRecordCoordinate` (improvise-fdk1). `SwitchView`, `ViewBack` and
|
||
`ViewForward` are no longer model effects at all (improvise-jytm), so they are
|
||
not subtracted. This paragraph said "29 minus three, 26" while the enum had 24,
|
||
and each of those two PRs then corrected it to 25 without seeing the other.
|
||
Count by brace-matching the `impl Effect` blocks, as below. A variant with
|
||
no data mirrors a nullary effect whose operand is document state the
|
||
authoritative `App` already holds.
|
||
|
||
Two derivation traps worth keeping:
|
||
|
||
- The obvious `awk` extraction reports **30** — it picks up `HelpPageSet`,
|
||
which is `ViewOnly`, because that is the last `pub struct` before
|
||
`#[cfg(test)]` and the test module mentions `ModelChange::`. Brace-match
|
||
the `impl Effect` blocks instead (`doctrine(derive_then_verify)`).
|
||
- improvise-kmgs derived the set **one effect at a time**, which was correct
|
||
per element and wrong for a set: it put `SetCell` on the wire without its
|
||
`MarkDirty` companion, so "save if dirty" never fired (improvise-gv9r).
|
||
`MarkDirty` is gone; `App::apply_effects` now raises the flag off
|
||
`EffectKind::Model(_)` directly.
|
||
|
||
### The derivation runs backwards too, and `ClearBuffer` was the proof
|
||
|
||
A derivation checked only forwards — *does every effect have a command* —
|
||
misses the inverse: **a command with no effect**. `Command::ClearBuffer(String)`
|
||
was one. Nothing in `improvise-command` is named `ClearBuffer`; the host's
|
||
`clear-buffer` command composes `core::write_buffer(name, String::new())`,
|
||
which is `effect::SetBuffer` and therefore **inserts**, and `resolve.rs`'s
|
||
`write_buffer` composes the same `Command::SetBuffer`. The wire variant
|
||
**removed** the key instead. So the one behaviour both ends actually implement
|
||
disagreed with the variant that names it (improvise-m0sx).
|
||
|
||
It was deleted rather than given an effect, for three reasons.
|
||
|
||
- It is not in the derivation's image. Adding a `ClearBuffer` *effect* to
|
||
justify the variant reverses the rule — the wire would become the source of
|
||
truth for host semantics, which is the direction
|
||
`decision(improvise-cqi, command_set_derived_not_invented)` exists to forbid.
|
||
- It bought nothing. Both readers of the map — `cmd::core::read_buffer` and
|
||
`resolve.rs`'s reader — end in `unwrap_or_default()`, so an absent key and an
|
||
empty one are **indistinguishable to every reader**. The only observable
|
||
difference was `ViewState` equality between a folded client and a
|
||
snapshot-seeded one, and deleting the variant settles that equally, by
|
||
leaving exactly one way to clear.
|
||
- Nothing composed it. Its only three occurrences were the definition, the
|
||
fold arm, and two of `reduce.rs`'s own tests — which **pinned the divergent
|
||
behaviour**, asserting the key was absent. A test can hold a defect in place.
|
||
|
||
**A variant nothing composes is either dead or a promise the protocol does not
|
||
keep**, and that is the hazard the deletion generalises:
|
||
`vocabulary_is_composed.rs` reads the variant list off serde's derive — the one
|
||
enumeration the compiler maintains — and requires each name to be constructed
|
||
somewhere in non-test source, or to appear in a short `NOT_COMPOSED` list with
|
||
its verdict. The list is checked in **both** directions, so an entry that starts
|
||
being composed fails just as loudly as one that stops
|
||
(`doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)`).
|
||
|
||
The check is a source scan, and **what counts as source has one home**:
|
||
`improvise_protocol::source_scan::production_code`, shared with the forward
|
||
guard below (improvise-c7mu). It removes three things a scan must not see.
|
||
|
||
- **Comments and literals.** Both are blanked, and newlines are kept. Before
|
||
this, a `// TODO: Command::SetYanked(...)` left where the real composer had
|
||
been deleted still passed all four tests. That is the *weaker* direction, the
|
||
one a scan weakness was supposed to be unable to go. It was live in a small
|
||
way: a `///` in `project.rs` named `Command::SwitchView`, which has real
|
||
composers, so no verdict was wrong yet. It also had a mirror: a comment
|
||
explaining that a `NOT_COMPOSED` variant is held back would have raised a
|
||
false "stale entry" alarm.
|
||
- **Test items inside a file.** Each test-only attribute's item is bounded by
|
||
**balanced brackets on the blanked code** (improvise-59q8). It ends at a `;`
|
||
at depth zero, or at the `}` closing its body unless `;`, `.`, `?` or `else`
|
||
follows. The first version was a line scan that relied on rustfmt putting an
|
||
item's closing brace at the attribute's own indentation. The #172 review
|
||
found three shapes it never bounded:
|
||
- an item ending in `];` or `);`;
|
||
- a `#[cfg(test)]` statement spanning lines;
|
||
- a one-line `#[cfg(test)] use x;`.
|
||
|
||
In each case the scan read on to the end of the file and **hid every line of
|
||
production code after it**, which is the silent direction for the forward
|
||
guard. An item the scan cannot bound is now a **panic**, never a scan to EOF.
|
||
|
||
**Which `cfg` is test-only is evaluated, not substring-matched.** The
|
||
predicate is read off the file's *text*, because a `feature` value is a
|
||
literal and blanked in the code. It is evaluated in three-valued logic for a
|
||
build without `test`: `test` is false, and so is `feature = "test-support"`,
|
||
which this repo never ships (improvise-26dm). Everything else a scan cannot
|
||
know, such as a target or an ordinary feature, is unknown. An item is
|
||
test-only when its predicate is **false**. So
|
||
`cfg(all(test, not(target_arch = "wasm32")))` is test code, which the old
|
||
"any line containing `not(`" rule got wrong. `cfg(any(test, feature =
|
||
"serde"))` and `cfg(not(test))` are production. An inner `#![cfg(test)]`
|
||
heading a file makes the whole file test code. One appearing anywhere else
|
||
is a panic, because this scan cannot bound an inline module's inner
|
||
attribute.
|
||
- **Test-only files.** A `#[cfg(test)] mod x;` makes `x.rs` and everything under
|
||
`x/` test code, even though nothing inside the file says so. Before
|
||
improvise-c7mu, this checker's own file and `improvise-io`'s
|
||
`roundtrip_tests.rs` were scanned as production.
|
||
|
||
One limit remains. A `Command::X` inside `matches!` or `if let` is read as a
|
||
construction rather than a pattern. That can only make the check *weaker* for
|
||
that variant; it never raises a false alarm. A variant nothing composes at all
|
||
is immune, since its only pattern occurrence is its own fold arm, which `=>`
|
||
identifies.
|
||
|
||
### And forwards: every effect is on the wire or has a verdict (improvise-h3y7)
|
||
|
||
`vocabulary_is_composed.rs` checks that the vocabulary does not name more than
|
||
production composes. The other direction is *does the vocabulary name
|
||
everything it should?* That was the direction `ViewBack` and `ViewForward`
|
||
failed: they were missing, and no client had a back history. Until improvise-h3y7
|
||
nothing checked it, and the audit that finally ran it by hand under-reported.
|
||
Brace-matching `impl Effect for` blocks misses the eight `simple_effect!`
|
||
expansions and `SetTransientPrefix` in `command/keymap.rs`. All nine are
|
||
`ViewOnly`, which is the half the `ViewBack` bug was in.
|
||
|
||
`improvise-command/src/ui/reduce/vocabulary_is_complete.rs` is that direction.
|
||
It scans the crate's `src/` for both ways an effect is declared, reading
|
||
each name past any attributes and doc comments before it. The macro accepts
|
||
those, and missing them hid effects (improvise-94x2). It requires
|
||
each name to be a `Command` or `ModelCommand` variant (read off serde's derive)
|
||
or to appear in `NOT_ON_THE_WIRE` with a verdict. A verdict is one of four
|
||
kinds, and the check verifies each kind:
|
||
|
||
- `Command(v)`: on the wire under another name (`HelpPageNext`/`Prev`/`Set` →
|
||
`Command::HelpPage`). `v` must be a real variant.
|
||
- `Frame(v)`: answered by a `ClientFrame` that is not a `Command` (`Save` →
|
||
`ClientFrame::Save`, gotcha 45). `v` must be a real frame.
|
||
- `Off(why)`: excluded on purpose, with the argument: session-local residue
|
||
(gotcha 30), host-local caches, or batch-relative control flow.
|
||
- `Gap(ticket)`: missing, and a ticket owns closing it (`SaveAs`, `ExportCsv` →
|
||
improvise-2mqz).
|
||
|
||
The list is also checked in reverse. An entry that is now on the wire fails,
|
||
and so does an entry that names no effect. The scan is `production_code`, so
|
||
the comments, strings and test fakes that name an effect do not count as
|
||
effects. If they did, a stale verdict could outlive the effect it names.
|
||
|
||
What it does **not** check is whether a same-named pair *agrees*: whether
|
||
`reduce_view`'s arm does what the effect does. The name match is the
|
||
completeness half. The semantic half stays a per-pair read (improvise-h3y7's
|
||
notes put it at about 21 pairs). The resolver lists in `resolve.rs`
|
||
(`declined()` and the host-only names) are also not checked against the
|
||
vocabulary yet. The audit found them stale in three places.
|
||
|
||
### No `ModelCommand` field is a position (improvise-hkbc)
|
||
|
||
improvise-6nsd closed "a stale layout tier misdirects a mutation" on one
|
||
condition: no `ModelCommand` carries a row or column index. `position_free`
|
||
in `reduce.rs`'s tests enforces that at compile time. It destructures every
|
||
variant with no `..`, down into `RecordCell`, and hands each field to a
|
||
function that accepts only a name, a path, a surrogate, a value or an axis. So
|
||
a new variant, a new field, or a field retyped to `usize` fails to build, and
|
||
the build failure forces the ADR conversation. It **cannot** see a position
|
||
spelled as a `String`. It also does not make the client position-free:
|
||
improvise-2wu3 resolves a painted `(row, col)` against the current cache before
|
||
it ever builds a command.
|
||
|
||
### Why two model effects are key-path only
|
||
|
||
A command on this wire is routed by `Sessions::apply` with **no
|
||
`SessionId`**, which is correct precisely when the change is addressed to no
|
||
one session (gotcha 36). `ApplyAndClearDrill` and `AcceptWizardModel` fail
|
||
that test: their operands — `drill_state.pending_edits` and the built wizard
|
||
workbook — are session-local residue, private to whoever staged them
|
||
(improvise-tom1). Unaddressed, client B's `ApplyAndClearDrill` commits client
|
||
A's uncommitted typing, and B's `AcceptWizardModel` replaces the document out
|
||
of a dialogue B never opened. Both were demonstrated against `Sessions::apply`
|
||
before removal.
|
||
|
||
*Portability of the command* was the criterion when they were kept;
|
||
**addressability** is the criterion that matters. Not permanent: multi-client
|
||
drill wants the addressed form (the variant carries a `SessionId`, the staging
|
||
map moves onto the session), which is where ADR 001 layer 2 goes anyway.
|
||
|
||
### The view stack is the client's, and the derivation already said so
|
||
|
||
Two user rulings, 2026-09-21: *"The state drill manipulates (the view stack)
|
||
should be local to the client"* and *"Drilling should be an ephemeral view, not
|
||
something that gets persisted."* The first one **closed a gap in the
|
||
derivation above rather than widening it** (improvise-gd28).
|
||
|
||
`effect::ViewBack` and `effect::ViewForward` are `EffectKind::ViewOnly`, and
|
||
every field they write — `active_view`, `view_back_stack`,
|
||
`view_forward_stack`, `mode` — is in the portable slice. By the view half's own
|
||
rule they were always `Command`s; they were simply missing. So are they now,
|
||
alongside `SwitchView`.
|
||
|
||
What that exposed is that the fold and the effect **disagreed**.
|
||
`effect::SwitchView` pushes the view it is leaving and clears the forward
|
||
stack; `reduce_view`'s arm set `active_view` and nothing else. A mirror
|
||
therefore never recorded any history, and neither did any client — which is
|
||
the real reason `view-back` was unavailable in a browser. The `declined()`
|
||
comment blamed the drill commit, and that was a *second*, separable reason: it
|
||
matters only for a client that stages drill edits, and the browser does not.
|
||
With the fold repaired, the decline had no reason left standing, and
|
||
improvise-cfly removed it together with the comment — leaving a false reason
|
||
behind being worse than leaving the refusal.
|
||
|
||
`reduce_view` now mirrors all three effects line for line
|
||
(`doctrine(mirror_the_cmd_line_for_line)`), with the direction carried as a
|
||
`Rewind` enum rather than a `bool` so neither arm can read the other's stack.
|
||
|
||
### A frame is somewhere you can go back to
|
||
|
||
The case a client-local stack creates. A `ViewFrame` names a view, and another
|
||
client can delete it — so `ViewState::forget_view` drops every frame naming a
|
||
view that has gone, and it is **one home**: `effect::DeleteView` calls it for
|
||
the host, and `Command::ForgetView` carries it to every client.
|
||
|
||
Which views vanished is **observed**, by diffing the document's view-name set
|
||
across `apply_command`, never predicted from the command variant — the reason
|
||
`adopt_cursor_epoch` watches a counter (gotcha 24) and `apply_model` watches
|
||
`document_generation` (gotcha 39). `DeleteView` is not the only effect that can
|
||
drop a view, and a table of variants would be a second description free to
|
||
disagree with the first.
|
||
|
||
The audience is `ToAll`. Any client may have *been* there, the asker included,
|
||
and a client that was not folds a no-op. Note this is a different question from
|
||
`adopt_deleted_views`, which moves the mirrors **sitting on** the deleted view:
|
||
a client that merely visited it is sitting somewhere else entirely, so neither
|
||
subsumes the other.
|
||
|
||
**A guard was written and then deleted, and that is the part to keep.** The
|
||
first attempt answered this in `Sessions::apply_step`, correcting a dead
|
||
landing the way `apply_switch` corrects a refused switch. It was wrong in a way
|
||
two passing injections did not show: the correction folds a `SwitchView`, which
|
||
**pushes the dead view back onto the stack**, so `<` popped it, was corrected,
|
||
and pushed it again — a back button that never progresses. Pruning makes the
|
||
dead frame unrepresentable instead, which left the guard no reachable input, so
|
||
it went: a guard whose fault cannot be injected is indistinguishable from one
|
||
that always passes (`doctrine(check_the_checker)`).
|
||
|
||
### `Command::SwitchView` has a foot in both halves
|
||
|
||
The `SwitchView` *effect* wears `EffectKind::Model(LayoutGeometry)`, so the
|
||
model-half derivation lands on it too — and `ModelCommand` deliberately does
|
||
**not** name it a second time (improvise-kmgs). `ModelCommand::ViewBack` and
|
||
`ViewForward` also move the active view and so carry the same document half.
|
||
|
||
### `Command::ResetCursor` is an invalidation, never a position
|
||
|
||
Derived from `App::reset_cursor`, the write `TransposeAxes` and `CycleAxis`
|
||
make after changing the grid's shape (gotcha 24). It is a variant rather than
|
||
a `SetSelected` plus two offsets for a reason that is not brevity: what the
|
||
server says is **"your cursor is no longer valid"**, not "be here". Where a
|
||
client then puts its cursor is its own business — the model is shared, the UI
|
||
is not — so broadcasting one client's position to everyone would be the wrong
|
||
message even though origin happens to be where each of them lands today
|
||
(improvise-gvnb).
|
||
|
||
### `Command::Model` is a struct variant
|
||
|
||
So the basis cannot be forgotten. ADR 001's check is worth nothing if a batch
|
||
may arrive without one, and the only enforceable version of "you must also
|
||
state X" is not being able to build the message without X. There is no
|
||
`Basis::default()` for the same reason — see §5.
|
||
|
||
Note what the struct variant does *not* buy: it closed omission **from the
|
||
message** and did nothing to close ignoring **on arrival**, which is the half
|
||
a struct variant is usually reached for. That hole needed a type —
|
||
`Checked`/`CheckedBatch` in `improvise-command` (improvise-ondj).
|
||
|
||
### `ModelCommand::SetCell` takes a `RecordCell` and the view that drew it
|
||
|
||
A `CellKey` here would require the client to know the coordinate space —
|
||
every row and column category plus the page and filter selections (gotcha
|
||
27), and in records mode the `_Dim` column-index convention (gotcha 1) —
|
||
which is precisely the knowledge the server exists to own. The surrogate is a
|
||
token to echo back; and because it is a *record* identity, a client holding
|
||
one for an aggregate cell cannot build this variant at all (ADR 002).
|
||
|
||
The `view` beside it is a view *name*, which the client already holds
|
||
(`ViewState::active_view`). It is not a coordinate. A record with no stored cell
|
||
exists only as a position in some grid, so the host resolves it against the grid
|
||
of the view the client composed in, not its own (improvise-wpxr,
|
||
`context/code/command.md`). This is the `view: String` convention every
|
||
view-scoped variant follows (improvise-lltt). `ClearCell` is a struct variant for
|
||
the same reason.
|
||
|
||
`named_records` is the exhaustive list of records a command writes, and
|
||
`records_named` is read off it. Each entry says where the host looks the record
|
||
up. A cell write is `NamedRecord::Drawn { view, record }`, resolved in the view
|
||
that drew it. `RenameRecordCoordinate` is `NamedRecord::Stored(record)`, and it
|
||
carries no view. Only a record that holds cells can be renamed: a coordinate
|
||
cell exists only in records mode, whose rows are the store's own records, and
|
||
a record with no cells has nothing for the rename to move. So the store is the
|
||
whole answer, and a view name would add a way to be wrong with nothing to
|
||
resolve. The destination the server adds for a rename (`records_at_stake`) is
|
||
`Stored` for the same reason.
|
||
|
||
`LoadModel` and `ImportJsonHeadless` name paths in the **host's** `Storage`,
|
||
never the client's filesystem (gotcha 2).
|
||
|
||
The variants are grouped by the `ModelChange` level their effect declares,
|
||
because that level is what `Dirty::of` turns into projections (gotcha 35).
|
||
Adding a variant here means classifying its effect there.
|
||
|
||
### `reduce_view`
|
||
|
||
The same function runs on both ends — the client folds it to draw, the server
|
||
folds it over its mirror — so the two cannot drift (improvise-cqq,
|
||
improvise-m91). Total and deterministic: no IO, no clock, no failure.
|
||
|
||
`Command::Project` and `Command::Model` are both no-ops in it, for different
|
||
reasons. A projection is routed to one widget's `WidgetCache` instead. A
|
||
model command is the *document's* business: what a client must fold arrives
|
||
as the projections it dirtied, plus — when the active view moved — the
|
||
`SwitchView` the host broadcasts.
|
||
|
||
A `ModelCommand` does not mutate anything by itself: `improvise-command`'s
|
||
`model_effects` maps it back to the effect it came from and runs it through
|
||
the same `App::apply_effects` the key path uses. Effects stay the only
|
||
mutation site, and the `Dirty` is the effect's own `EffectKind` — never a
|
||
second classification beside gotcha 35's table.
|
||
|
||
---
|
||
|
||
## 3. `render.rs` — the fold
|
||
|
||
### There is no aggregate render cache
|
||
|
||
Each widget owns its own cache type and folds its own stream of projections
|
||
(improvise-rkeq): the grid does not learn that a formula was deleted, and the
|
||
formula panel does not learn that a cell changed. A `Projection` is one such
|
||
update, addressed to exactly one widget, and `WidgetCache::apply` is the fold.
|
||
|
||
The overlap between `TileBarCache` and `CategoryTreeCache` — both carry
|
||
`(category, axis)` — is **deliberate**, so neither is woken by the other's
|
||
changes. Do not "fix" it by sharing a variant.
|
||
|
||
`WidgetCache::apply` is total and deterministic, like `reduce_view`: an
|
||
update naming something the cache does not have is **dropped, never a panic**.
|
||
Projections race on the wire, and a client that crashes on a stale index is a
|
||
client that crashes.
|
||
|
||
`Projection::Basis` is not a widget — nothing draws it — but it is folded on
|
||
exactly the same terms; see §5.
|
||
|
||
### `DocumentCache` — a widget, not a `ViewState` field (improvise-z754)
|
||
|
||
`draw_title` read `workbook.model.name`, `model_state.dirty` and
|
||
`model_state.file_path`. None was projected, so a browser could not draw a
|
||
title bar at all.
|
||
|
||
**A projection rather than a `ViewState` field**, and the argument is gotcha
|
||
30's membership test read backwards. `ViewState` is the *portable session
|
||
slice*: per-client UI a client folds its own commands into. These three are
|
||
facts about the **document**, shared by every client and writable only by the
|
||
server — on `ViewState` they would be document state wearing a session slice's
|
||
clothes, and `reduce_view` would need commands nothing composes. The
|
||
per-widget cache is exactly the shape for "a local copy of what a widget
|
||
draws, replaced by a `Projection`".
|
||
|
||
**It is the one widget no `EffectKind` can gate.** `dirty` is raised by any
|
||
`EffectKind::Model(_)` in `apply_effects` and cleared by `effect::Save`, which
|
||
is `ViewOnly` (gotcha 3) and correctly so. `SaveAs` moves `file_path` and is
|
||
also `ViewOnly`. So `Dirty::of` reports `NOTHING` for exactly the effects that
|
||
change this widget most visibly, and gating it on `Dirty` would leave every
|
||
client showing a `[+]` for a document that has been saved — the forever-wrong
|
||
half of gotcha 35. `ClientSession::document_update` therefore **observes**: it
|
||
builds the desired cache and diffs it against the mirror, the same answer
|
||
`record_versions` got for the same reason (gotcha 40), and the same reason
|
||
`cursor_epoch` beats matching on `CycleAxis`. The diff, not a flag, is what
|
||
keeps an ungated widget from projecting on every batch.
|
||
|
||
**A save is projected, not silent.** `Host::save` ends with `project_all()`
|
||
and the worker re-projects after its own `persist()`, because both clear
|
||
`model_state.dirty` *outside* any batch `Dirty` describes. gotcha 45's
|
||
"answered silently on success — nothing moved, so no widget is dirty" was true
|
||
until this cache existed and is now exactly wrong: the title bar moved. A test
|
||
that reaches for `Sessions::project_all` by hand proves nothing here, because
|
||
that is the call the production paths omitted.
|
||
|
||
**`stored_as` is a name, never a path.** The TUI already drew only
|
||
`file_name()`, and a socket server projecting `file_path` would hand a remote
|
||
client the host's directory layout — useless to it and a leak. What a client
|
||
can act on is *whether the document has a home at all*, which is the fact
|
||
`ClientFrame::Save` fails on (`NoDocumentPath`, gotcha 45), so `None` carries
|
||
it. A browser's answer is its OPFS entry name; neither end has to know which.
|
||
|
||
### `CellId`: the kind of identity is the permission
|
||
|
||
`SetCell`/`ClearCell` take a `RecordCell` (and a view name), `Drill` takes an
|
||
`AggregateHash` and nothing else. A client cannot compose a write against an
|
||
aggregate **or** a drill against a stored cell — not because either is
|
||
checked, but because neither carries an id of the other's type
|
||
(design-principles §3). It projects a fork the model already makes:
|
||
`EditOrDrill` branches on exactly this in the TUI today.
|
||
|
||
An earlier draft made this `Option<CellId>`, absence meaning aggregate. The
|
||
enum is better: an aggregate is not the *absence* of an identity, it is a
|
||
different *kind* of identity — and modelling it as `None` left `Drill` with
|
||
nothing to address.
|
||
|
||
A `RecordCell` is `(record hash, measure)` because the surrogate names the
|
||
**record**, not the cell (ADR 002). That is what lets one identity serve both
|
||
addressing and staleness: `record_versions` is keyed by the same hash, so a
|
||
client's own token is the lookup key for its own version, with no `CellKey`
|
||
rebuilt on the hot path. `measure` is absent for a record carrying no
|
||
`_Measure` coordinate at all — the shape `add-record-row` seeds.
|
||
|
||
### `CellId::Derived` — a formula owns the cell (improvise-ir7l)
|
||
|
||
A formula target is a real stored coordinate, so the record branch claimed it
|
||
and every client offered an editor over it. The write *landed* and was then
|
||
shadowed forever, because `evaluate_aggregated` reads `formula_cache` before
|
||
raw data — not a revert, a permanent silent shadow.
|
||
|
||
The fix keeps ADR 002's discipline rather than adding a second answer beside
|
||
it: a formula-owned cell projects a **different kind of identity**, so the
|
||
write is unconstructible instead of flagged. `Derived` carries the formula's
|
||
target and **no surrogate**, so there is nothing a client could spend on a
|
||
`SetCell`; that is the same reason `Aggregate` carries an `AggregateHash` and
|
||
not a `RecordHash`.
|
||
|
||
**In records mode the question is asked of the record's real coordinate.** The
|
||
synthetic `(_Index, _Dim)` key names no measure, so `formula_for` can never
|
||
match it; `GridLayout::record_cell_key` is the one mapping from a records
|
||
position to a model cell, and both `cell_id` and `CmdContext::owning_formula`
|
||
go through it. This is not defensive: a measure can be **both** an explicit
|
||
`_Measure` item and a formula target, and the records `+` column
|
||
(improvise-poia) is a one-keystroke way to build one.
|
||
|
||
Two orderings matter in `project::cell_id`. Derived is asked **before** the
|
||
record branch, since the coordinate is stored and the record branch would
|
||
otherwise win. It is asked **after** the aggregate branch, because a formula
|
||
cell that also aggregates is still drillable (gotcha 10) — `Aggregate` and
|
||
`Derived` are not alternatives for the same cell, and reversing the two turns
|
||
a drillable cell into a dead one.
|
||
|
||
The predicate itself is `Model::formula_for(&CellKey)` in `improvise-core`,
|
||
the one home of "is this cell derived". It already existed three times inside
|
||
`model/types.rs` as an open-coded loop; the TUI's `EditOrDrill` and the
|
||
producer now call the same function rather than each spelling the rule. The
|
||
TUI had to change in the same breath: it re-derives from the `Model` it holds,
|
||
which is legitimate (it is the authority), but it must give the *same* answer
|
||
or the two front ends diverge — design-principles §5.
|
||
|
||
### `CellId::Create` — the column writes no cell (improvise-poia)
|
||
|
||
The two records-mode creating columns — `+` and `+cat` (gotcha 1) — projected
|
||
`None`, which means "no projected cell". So a client could not tell a column
|
||
that creates something from a padded slot, and gotcha 42's conflation had a
|
||
third instance.
|
||
|
||
**The ticket offered two designs and both assumed a cell write.** They do not
|
||
happen: `commit.rs::add_measure` emits exactly one effect,
|
||
`AddItem { category: "_Measure", item: name }`, and `add_category` emits
|
||
`AddCategory(name)` and deliberately writes no coordinate on the current row
|
||
(improvise-2zq). The record under the cursor is not part of the operation, and
|
||
neither is any value — **what the user types is a name**. So there is no
|
||
"distinct command carrying the record surrogate plus the new measure name" to
|
||
invent, and no two-command flow to sequence: one existing `ModelCommand` does
|
||
it, and the address it needs is the name.
|
||
|
||
What was missing was therefore never an address but a **recognition**. `Create`
|
||
carries the kind and nothing else, because the kind plus the typed name is the
|
||
whole operand. It is not a `SetCell` target, so `render.rs::editable` stays
|
||
`Record`-only and `opened_at_cursor` captures no editor target for it — both
|
||
by matching, which the compiler now forces.
|
||
|
||
**No client composes it yet**, and that is one gap rather than this one:
|
||
`keys.rs::commit` returns early for every records-mode commit, because a
|
||
category column renames every measure cell in the row and that fork needs
|
||
`record_col`. The creating columns do not need it, so the early return is
|
||
broader than its reason — improvise-gcvb, which also records the two things
|
||
that make it more than a one-liner: an inserted `+cat` column means the TUI
|
||
does **not** advance, and `AddCategory` can abort.
|
||
|
||
### `CellId::Coordinate` — a drawn coordinate is not "no cell" (improvise-exdg)
|
||
|
||
A records-mode coordinate cell (the `Region` column holding `North`) used to
|
||
project `None`. That was a **third** meaning of `None`, next to the padded slot
|
||
and the blanked position: a visible cell that holds text and whose write
|
||
belongs to the host. So absence was encoding a permission, the same conflation
|
||
`Create` removed for the creating columns (improvise-poia). The pinning test,
|
||
`a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is
|
||
gone.
|
||
|
||
**Since improvise-fdk1 it carries `{ record, category }`**, the operands of
|
||
`ModelCommand::RenameRecordCoordinate`. Editing a coordinate renames it across
|
||
every measure cell in the row (gotcha 1). When exdg landed, no client composed
|
||
that operation, so the variant was left empty on the grounds that the operand
|
||
belongs with the command that consumes it. That command now exists, and both
|
||
operands come from the **projection**:
|
||
|
||
- **`record` is the row's own record.** A client could borrow the hash from a
|
||
sibling `Record` cell in the same row. That would be a second derivation of
|
||
which cells share a record, which is what ADR 002 forbids. It would also have
|
||
nothing to borrow in a document with no measures, where the coordinate is
|
||
the only cell a row has. For the same reason `published_basis` counts a
|
||
`Coordinate`'s record, or a rename of such a row would be `RecordUnstated`.
|
||
- **`category` is the column's identity.** Its label is not: a category and a
|
||
measure may share a name (gotcha 1), so reading the category off the column
|
||
label would be a guess.
|
||
|
||
`RenameRecordCoordinate` is position-free (improvise-hkbc): it names the record
|
||
by surrogate and the new coordinate by value. `records_named` names the record,
|
||
because a rename moves it (gotcha 43). The destination, when it exists, is
|
||
added by the server's `records_at_stake` rather than here, because only the
|
||
document can derive it (improvise-milr, `context/code/command.md`). Creating the item is internal to the
|
||
effect. `render.rs::editable` still answers `false` for a coordinate: the page's
|
||
inline editor writes stored cells only, and the rename's producer is the
|
||
keyboard path (`context/code/clients.md`). `typed_cell_command` and
|
||
`record_cell_at_cursor` still answer `None` for it, so paste onto a coordinate
|
||
is still improvise-kkcy.
|
||
|
||
### `CellDisplay::id` — `None` is not "aggregate"
|
||
|
||
That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point
|
||
of the enum. `None` means the slot holds **no projected cell**: a `GridCache`
|
||
position padded by `fit_to_labels`, or one blanked by a reframing. Every
|
||
records-mode column is some kind of cell (`Record`, `Derived`, `Create`,
|
||
`Coordinate` or `NonItem`). Conflating the two either hides a cell or offers a
|
||
write the type system exists to make unconstructible.
|
||
|
||
`NonItem { category, item }` is a stored value whose `category` coordinate
|
||
names no item of it (improvise-gk51). It is drawn, and it takes no write. The
|
||
host would refuse one, so it is not offered as a `Record`. Why it is a kind of
|
||
its own rather than one of the others: `context/code/command.md`, "The
|
||
projection asks the first rule too".
|
||
|
||
### `CellDisplay::edit_text` — the difference corrupts data
|
||
|
||
What an editor opened on this cell should start with: the value as the user
|
||
last **typed** it, not as it is **drawn**. `text` has been through
|
||
`format_f64`, so a stored `1200` is drawn `1,200` under a comma-grouping
|
||
format; seeding an editor with that and committing it parses as
|
||
`CellValue::Text("1,200")`, and the number is gone. Display rounding is
|
||
view-only and must never be fed back into the model (design-principles §4) —
|
||
and an editor's seed is exactly a feedback path.
|
||
|
||
It is the projection of `App::display_value_at_cursor`, which is what
|
||
`EditOrDrill` seeds the TUI's buffer from, so both front ends start from one
|
||
string rather than each deriving one. `None` where there is nothing to edit:
|
||
positions whose `id` is `None`, plus pivot cells with no key. A records-mode
|
||
cell always carries one, even an empty one. An error cell carries none, because its text is a diagnostic and not
|
||
a value.
|
||
|
||
A browser found this: `tests/lockstep.rs` drove `i` through the host and the
|
||
client over the same grid, and they disagreed by a comma.
|
||
|
||
**It is the value's exact spelling, not its `Display`** (improvise-umi4).
|
||
`CellValue`'s `Display` writes a non-whole number `{:.4}`, so this field
|
||
carried `1.2346` for a stored `1.23456789`, and `i` then `Enter` with nothing
|
||
typed stored the rounded number in both front ends. Every arm that seeds an
|
||
editor now goes through `CellValue::edit_text` (`core.md` §17). That is the
|
||
shortest decimal that parses back to the same `f64`.
|
||
|
||
### An untouched editor writes nothing (improvise-ixxx)
|
||
|
||
An exact seed is not enough, because the commit reclassifies it. `typed()`
|
||
reads text, not the cell it came from, so `i` then `Enter` on `Text("007")`
|
||
stored `Number(7)`. A raw `CellValue::Error` has the edit text `ERR:msg`, and
|
||
the same keys stored it as `Text("ERR:msg")`. After umi4 the seed differs from
|
||
the stored value only when `typed(edit_text(v)) != v`. In every such case the
|
||
right answer is to keep `v`, which is what an untouched commit means.
|
||
|
||
So **a commit whose text is the seed the editor captured as it opened writes
|
||
nothing**. `resolve::untouched(Option<&OpenedEditor>, &str)` is the one home
|
||
for that, beside `typed()`. Every commit path reaches it:
|
||
|
||
- **TUI, pivot and records.** `CommitAndAdvance` carries `opened`, which
|
||
`commit_at_cursor` takes from `CmdContext::opened_editor`. It skips only
|
||
`commit_cell_value`: the mode change, the cursor restore, the advance and the
|
||
re-entry all still run, so records Tab still advances. The script form
|
||
(`commit-cell-edit <mode> <value> <coords>`) carries `None` and always
|
||
writes, since no editor was opened. The drill's staging goes through the same
|
||
`commit_cell_value`, so it is covered too.
|
||
- **Browser keyboard.** `resolve::commit` → `written`, which returns an empty
|
||
write for an untouched buffer and leaves `ChangeMode` and `advanced` alone.
|
||
- **Browser mouse.** The seed is a field of #188's `EditTarget` (`opened`),
|
||
captured by `edit_target_at` from the cache's `edit_text` beside the record
|
||
and its version. `typed_target_frame` starts with the `untouched` check, and
|
||
the page passes no seed (`typed_cell(target, text)`). The `<input>` shows
|
||
`edit_seed(target)`, so what the page shows and what it compares against are
|
||
one capture (improvise-ugj4).
|
||
|
||
**The seed is captured at open, never recomputed at commit.** Where the editor
|
||
opened is where `OpenedEditor` is written. For the TUI that is `EditOrDrill`
|
||
and `EnterEditAtCursor`. For the browser it is `resolve::opened_on`, which
|
||
`edit_or_drill` and `advanced` share, and `ClientState::settle_editor` for an
|
||
editor that arrived without its buffer. For the mouse it is `edit_target_at`.
|
||
The reason is concurrency. If another client writes the cell while this editor
|
||
is open, a seed recomputed at commit has moved to the new value. The untouched buffer then differs from it, so the
|
||
stale text gets written over the other client's value. The browser's basis
|
||
claim would not stop that write, because it is taken when the commit is
|
||
composed. Comparing against the captured seed avoids that lost update as well.
|
||
|
||
`OpenedEditor` lives on `ViewState` (`opened_editor`) and travels as
|
||
`Command::SetOpenedEditor`, with the `ViewOnly` effect of the same name. It is
|
||
a struct on purpose. What else an open editor must capture, namely which record
|
||
it edits (improvise-2wu3) and at what version (improvise-0j0f), belongs in the
|
||
same object. `EnterMode` into an editing mode writes `None`, because an editor
|
||
opened that way has no seed and must not inherit an earlier one.
|
||
|
||
**Untouched means not edited, not merely equal** (improvise-2054). Comparing
|
||
content alone dropped a deliberate write. Open on `7`, let another client write
|
||
`5`, then press Backspace and `7`: the buffer spelled the seed again, so the
|
||
commit wrote nothing and the user was not told. `OpenedEditor` therefore
|
||
carries `touched`, and `untouched` requires `!touched && text == seed`. The
|
||
edited write is then composed, and if the cell moved, the basis check
|
||
(improvise-0j0f) refuses it loudly rather than dropping it silently.
|
||
|
||
`resolve::edited(opened, buffer)` is the one rule for what marks an editor. It
|
||
is an edit of **the editor's own buffer**, `edit`, by `append-char` or
|
||
`pop-char`. `AppendChar`/`PopChar` in the TUI and the browser's two arms each
|
||
call it and emit `SetOpenedEditor` once, on the first edit. Two things
|
||
deliberately do **not** mark it:
|
||
|
||
- **A key into another buffer.** Typing `:enter-mode editing` goes into
|
||
`command`. If that marked the stale editor, it would hide the stale-seed case
|
||
the `EnterMode` guard exists for, and no test could see the guard.
|
||
- **`clear-buffer`.** Esc and the post-commit clear are not the user's edits.
|
||
If they marked the editor, the guard above would again be redundant and
|
||
unpinnable.
|
||
|
||
The comparison stays exact: whitespace is content, and nothing trims.
|
||
|
||
**The mouse editor reports its first edit.** Its edits happen in a DOM
|
||
`<input>` that Rust never sees, so the page's `input` listener, registered
|
||
`{ once: true }`, swaps its target for `edited_target(target)`, which is
|
||
`EditTarget::edited`: the same capture with `touched` set (improvise-jibp).
|
||
Before that, the mouse path compared content alone, so the 2054 case survived
|
||
there. The `<input>` used to be seeded from the **painted** `editText` while
|
||
`EditTarget` captured the **cache**. `repaint` returns early while an editor is
|
||
open, so a paint that went stale then showed old text against a newer seed and
|
||
version (improvise-ugj4). The page now seeds from `edit_seed(target)`, and
|
||
`RenderCell` no longer carries `edit_text`, so the paint cannot be asked.
|
||
|
||
This does **not** change classification. `007` typed fresh is still
|
||
`Number(7)`, and `a_touched_browser_commit_still_writes` pins that.
|
||
|
||
`numeric` and `error` are decided by the server for the same reason: it is
|
||
the side that knows which it was, and re-deriving client-side is how the two
|
||
ends drift apart. `CellDisplay` is deliberately not *drawn from* a `CellValue`
|
||
— by the time a cell reaches a client the number formatting has already
|
||
happened, and `text` is what a renderer paints.
|
||
|
||
### `CellDisplay::value` — what `yank` copies (improvise-dsxq)
|
||
|
||
The value the host evaluated at this position, the same `Option<CellValue>`
|
||
that `text` was formatted from: `evaluate_aggregated` over the view's
|
||
`none_cats` in a pivot grid, `records_value` in a records grid. It exists
|
||
because the browser's `yy` has to copy what the TUI's `YankCell` copies, and
|
||
neither string on the cell can be turned back into it:
|
||
|
||
- `edit_text` was `CellValue::to_string()`, which writes a non-whole number
|
||
`{:.4}`, so `1.23456789` became `1.2346`. improvise-umi4 has since made it
|
||
exact, but the next bullet still holds.
|
||
- Re-parsing any text with `typed()` turns number-like text into a number —
|
||
`Text("007")` becomes `Number(7.0)`.
|
||
- In a pivot grid `edit_text` is the *stored* cell (`get_cell`), so an
|
||
aggregate or a formula cell has none and yanked nothing at all.
|
||
|
||
So the value is **projected**, not re-derived (gotcha 47): a client that
|
||
wants the value asks for the value. It is kept separate from `edit_text`
|
||
rather than replacing it, because the two answer different questions — what
|
||
an editor starts with is a *string*, deliberately the one the TUI's buffer
|
||
holds. (That string was itself lossy until improvise-umi4. The fix changed what
|
||
the TUI's own editor shows, which was the point.)
|
||
|
||
In a records grid the TUI does **not** yank this value: `YankCell` and
|
||
`PasteCell` resolve the synthetic `(_Index, _Dim)` key, so the TUI yanks
|
||
`None` and pastes a junk cell into the document (improvise-6p8q). The browser
|
||
is right there and the TUI is wrong, which is why the lockstep yank test stays
|
||
on a pivot grid.
|
||
|
||
### `CellDisplay`'s equality is the wire's, not `f64`'s (improvise-4w09)
|
||
|
||
`CellDisplay`'s `==` has one job: the grid diff (`project::grid_updates`) and
|
||
the seed property (`GridCache::folded(&g.seed()) == g`, gotcha 32) run on it.
|
||
The question it answers is therefore **"could a client tell these apart?"**,
|
||
not "are these the same number?".
|
||
|
||
A derived `PartialEq` answered the second question. `value` holds an `f64`, and
|
||
`NaN != NaN`. So once #178 added `value`, a cell holding NaN, which `.improv`
|
||
carries as a first-class value (improvise-55v2), was unequal to itself. The
|
||
diff resent it on every grid-dirty projection, and the seed property was false
|
||
for exactly the documents that hold one. It also cost `Eq` on `CellDisplay`,
|
||
`GridCache` and `GridUpdate`.
|
||
|
||
So `PartialEq` is written by hand and compares `value` with
|
||
`same_on_the_wire`, which has two clauses:
|
||
|
||
- **Every NaN is equal**, whatever its bits. The wire spells them all `"nan"`
|
||
(`model/cell.rs::wire_f64`), and they cannot all share a payload: `0.0 / 0.0`
|
||
on x86 sets the sign bit, and a client that parsed `"nan"` holds `f64::NAN`.
|
||
Bit equality alone would fix the self-diff and still call the host's NaN and
|
||
the client's NaN different.
|
||
- **Any other number is its bits**, so `0.0` and `-0.0` differ. serde_json
|
||
writes them differently, and a change between them is one the client does
|
||
not yet have.
|
||
|
||
That is an equivalence relation, so `Eq` is back on all three types.
|
||
`CellValue`'s own `PartialEq` is unchanged: formula evaluation and model code
|
||
keep IEEE semantics. Only the projection, whose equality means "nothing to
|
||
send", adopts the wire's.
|
||
|
||
The hand-written impl destructures `CellDisplay` exhaustively, so adding a
|
||
field fails to compile until it is compared. The dangerous failure for a diff
|
||
is **under**-reporting (gotcha 35), and a field left out of `==` is exactly
|
||
that. Evidence: `ui::project::tests::a_grid_holding_nan_quiesces` and
|
||
`render::tests::a_cell_equals_another_exactly_when_the_wire_cannot_tell_them_apart`.
|
||
|
||
### A number must read back as the number written (improvise-6oy4)
|
||
|
||
serde_json's default f64 parser is fast and best-effort: it does **not**
|
||
guarantee reading back the `f64` that serialized the text. Its
|
||
`float_roundtrip` feature does, at roughly twice the cost of float parsing.
|
||
Without it, the review of PR #178 measured about one ordinary decimal in seven
|
||
coming back one ULP off, both in `CellDisplay::value` (host to client, so `yy`
|
||
copied a neighbour) and in a `SetCell` (client to host, so a typed or pasted
|
||
number landed as a neighbour).
|
||
|
||
The feature is on for **every workspace crate with a normal dependency on
|
||
serde_json** (improvise-dz8c). Each crate's own manifest names it, because each
|
||
build is made from one crate alone: the `wasm` CI job and `wasm-pack` invoke
|
||
per crate, and feature unification cannot reach across separate invocations
|
||
(`context/repo-map.md`, `## Wasm`). Enabling it in one crate and relying on the
|
||
others to inherit it works in `cargo test` and nowhere else.
|
||
|
||
**Why "can parse" and not "parses a frame".** The first version of the rule
|
||
covered only the three crates that parse frames and listed them by hand.
|
||
Frames are not the only place a user's number crosses a JSON parser.
|
||
`improvise-io`'s legacy-JSON `.improv` load (removed since, improvise-tt22), `improvise-command`'s JSON import
|
||
(`StartImportWizard`, `ImportJsonHeadless`) and the root binary's
|
||
`improvise import` all parsed with the lossy default, and in the TUI binary
|
||
nothing enabled the feature (improvise-6yvg, reproduced: `0.24815714285714285`
|
||
stored as `…283`). The worker bundle happened to be exact through unification,
|
||
so one import gave different numbers in the two front ends. "Parses JSON input"
|
||
cannot be read off a manifest, but "has a normal serde_json dependency" can, and
|
||
it over-approximates in the cheap direction: the feature changes only parsing,
|
||
so a crate that only serializes pays nothing for it. Dev-dependencies are out,
|
||
because a test build ships nowhere. That also leaves out `gp-tree`, whose only
|
||
serde_json is a dev-dependency (its example CLI reads a saved model). Every
|
||
number it persists is an `f32`, and 3M random `f32`s all read back exactly
|
||
through the lossy parser, against about 30% of random `f64`s.
|
||
|
||
**Why a declaration and not the build graph.** The first check asked
|
||
`cargo tree` whether each listed crate's *build* had the feature. That answer
|
||
can be yes by accident. `improvise-command` depends on `improvise-io`, so with
|
||
the feature removed from command's manifest, command's own test build still
|
||
has it, and its import test stays green. The rule requires the crate's own
|
||
manifest to say it, so a later change to a dependency cannot take it away.
|
||
|
||
**Why not enable it once, in `improvise-protocol`.** That removes the list, but
|
||
it would give the protocol a normal serde_json dependency it has no use for
|
||
(its repo-map entry says core and serde only). It would also reach only builds
|
||
that include the protocol, and `improvise-io` does not depend on it. Under this
|
||
rule the protocol's one serde_json dependency, the optional `test-support` one,
|
||
carries the feature too. That is off in every shipped build, so it costs
|
||
nothing.
|
||
|
||
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
|
||
reads `cargo metadata --no-deps` and names every package, and every
|
||
target-specific entry, whose normal serde_json dependency lacks the feature. A
|
||
new crate is covered the day it declares the dependency. It does not reach
|
||
`harness/opfs-contract`, which is its own workspace and only serializes.
|
||
|
||
The end-to-end tests cannot guard this alone. They run in one unified build
|
||
with `improvise-ws-server` as a dev-dependency, so they stay green with the
|
||
feature removed from the browser bundle.
|
||
|
||
### `AxisFrame` — the grid's frame, and why it is *one* type
|
||
|
||
`AxisFrame` (improvise-anr9/s1xw) replaced a `Vec<String>` of labels that
|
||
`GridLayout::row_label` built by **slash-joining a multi-level tuple** — the
|
||
exact rendering `two_row_categories_produce_cross_product_labels` asserts must
|
||
not appear. The protocol carried a form of the labels the TUI itself
|
||
considered wrong, so the browser drew `Food/Alice` where the TUI drew two
|
||
header columns, with no groups and no collapse indicators (gotcha 44).
|
||
|
||
It is one type rather than a `labels` plus a `levels` beside it, and that is
|
||
the whole point: **two answers to "what are the labels" is the disease** this
|
||
projection architecture exists to cure (improvise-edp). A renderer asks this
|
||
type and there is nothing else to ask. Note what this cure cost that
|
||
`flatten_cat_tree` did not: a `Vec<String>` had to change **type**, and the
|
||
shared derivation could not be added beside the old one.
|
||
|
||
`cats` is the categories outermost-first; `entries` interleaves
|
||
`Group(FrameGroup)` headers with `Item { tuple, collapsed }` label tuples exactly
|
||
as `GridLayout::row_items` interleaves them. On the row axis the TUI paints a
|
||
`Group` as its own row; on the column axis it paints the runs as spans above
|
||
the headers. **That difference is painting, and it is the only thing that
|
||
differs.**
|
||
|
||
`cats` may be empty — an axis carrying no category is a real layout (one
|
||
nameless row or column), not a gap, hence `levels()` returning **at least
|
||
one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to
|
||
smuggle a multi-level axis in flattened.
|
||
|
||
`FrameGroup::collapsed` is on the wire rather than left to the client because
|
||
it is a fact about the *view*, which only the server holds.
|
||
|
||
**`Item::collapsed` is on the wire too, and for a sharper reason** (improvise-4k1j).
|
||
It is the layout's own `AxisEntry::DataItem::collapsed`, which `GridLayout`
|
||
sets on a fold and on every item expanded beneath one, at any level. A client
|
||
cannot derive it from the headers. `FrameGroup` names no level, and an inner
|
||
`GroupEnd` looks the same as an outer one, so under two row categories the
|
||
nearest header can be the inner category's. That is how `H` came to hide a
|
||
group name as an item. Tuples cannot answer it either, because a group and an
|
||
item may share a name. `AxisFrame::folds(idx)` reads the flag, and it answers
|
||
exactly what `GridLayout::row_folds_a_group` answers, because it is the same
|
||
bit. improvise-e265 puts a level on headers and ends, which makes per-level
|
||
membership derivable. The flag still stays: a client reading the host's fact is
|
||
stronger than one re-deriving it, however correct the derivation. There is no
|
||
`FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a
|
||
client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs`
|
||
has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host
|
||
list), and neither renderer read the field. A promise about an interaction no
|
||
code performs is the stale-sibling defect, so the field went with it. It is
|
||
not a loss for `z`. The TUI's `ToggleGroupAtCursor` also places the cursor on
|
||
the layout the toggle *will produce*, which a thin client cannot compute. So
|
||
composing `z` in a browser needs its own design, and the operand should come
|
||
back with that design rather than wait for it.
|
||
|
||
### `AxisFrame`: four accessors that are **not for a draw loop**
|
||
|
||
`item`, `label`, `shows_level` and `group_of` each scan `entries` from the
|
||
start, so asking them per position is quadratic in the frame length and, in
|
||
the TUI, **grows with the absolute scroll offset**. That defect was found
|
||
three times on improvise-anr9. The per-position forms survive for one-off
|
||
lookups only, and their two-line warnings are the thing standing between a
|
||
future renderer and a measured regression.
|
||
|
||
- `shows_level` is the worst of the four: it materializes **every** item on
|
||
every call. It is deliberately not made cheaper, because a fast
|
||
per-position form is an invitation to the loop `header_labels` exists to
|
||
replace.
|
||
- `group_of` is `groups()` at one position, so it walks the whole frame
|
||
(improvise-e265).
|
||
|
||
The one-pass forms a draw loop wants:
|
||
|
||
- **`header_labels(first_rendered)`** — every header label with suppression
|
||
already applied, indexed `[data index][level]`, `""` where the label
|
||
repeats. The suppression rule still has exactly one home (`fn shows`); this
|
||
is `shows_level` applied in bulk, not a second copy of it.
|
||
- **`groups()`** — `group_of` for every position, in one pass.
|
||
- **`shares_ancestry(with)`** — the TUI's column-header underline: at the leaf
|
||
level only the selected column matches, at a higher level every column under
|
||
the same ancestor. There is deliberately **no** per-position form beside it,
|
||
because one would be a second answer paid for per (column, level) — two
|
||
frame scans apiece.
|
||
|
||
**`leaf_labels()` is not "the labels".** It is the innermost label of each
|
||
item: the single most specific name a position has, what a test or a one-line
|
||
summary calls a row by. A renderer reaching for it on a multi-level axis is
|
||
drawing the *wrong* header, not a shorter one.
|
||
|
||
**`first_rendered`** is the first data index actually on screen. That entry
|
||
always shows its full tuple, or a scrolled viewport loses its group context at
|
||
the top edge (improvise-jjx).
|
||
|
||
**`body_from(idx)`** drops the group headers that own the first drawn item: a
|
||
frame opening mid-group carries the open run at every level so `group_of` can
|
||
answer for it (improvise-e265), but those headers are *above* the first drawn
|
||
row and must not be painted as rows. This is
|
||
the same cut `GridLayout::data_row_to_visual` has always made.
|
||
|
||
Two things about its argument, and the first of them **used to be the whole
|
||
story and was wrong**. It exists partly because the two front ends scroll
|
||
differently — a browser's frame is already windowed and passes `0`, the TUI's
|
||
covers the whole grid and passes its row offset (gotcha 44). But `idx` alone
|
||
cannot say whether the frame *leads the axis*, and at the top there is no
|
||
header "above" to drop: it is content, and a collapsed first group has no
|
||
other affordance. That is `AxisFrame::first` and improvise-0o6x — §3a.
|
||
|
||
**`group_of` answers membership**, and the paragraph that stood here said the
|
||
opposite: that it answers "nearest preceding header", deliberately, because
|
||
that is what the TUI had always painted, and that encoding an end boundary
|
||
would mean changing `AxisEntry` in `improvise-core` and was "filed rather than
|
||
smuggled into" improvise-anr9. All of that was true when written. **The filed
|
||
ticket is improvise-gd83 and it has landed** — `AxisEntry::GroupEnd` closes a
|
||
run, `groups()` clears on it, and the TUI's painted span changed with it. The
|
||
mechanism and its two emission arms are §3a.
|
||
|
||
That correction is here rather than only in §3a on purpose. This paragraph
|
||
forbade, in terms, the change §3a describes; a reader arriving at the stale
|
||
copy first would conclude the rule had been smuggled past its own prohibition.
|
||
When a fact is stated twice, changing it is a grep (gotcha 48) — and the two
|
||
copies being in *one file* is what made this worth saying out loud.
|
||
|
||
### The width rule has one home
|
||
|
||
`header_level_widths` (improvise-1125): per level, the widest label **in
|
||
display cells** plus a gap, clamped to `[MIN_ROW_HEADER_W, MAX_ROW_HEADER_W]`.
|
||
It lived twice before — inline in `src/ui/grid.rs::render_grid` and again in
|
||
`improvise-command::ui::viewport::compute_row_header_width` — and a browser
|
||
sizing its own row header would have been a third.
|
||
|
||
It takes an **iterator of borrowed tuples** rather than an `AxisFrame`
|
||
because its other caller holds a `GridLayout` and is on the keypress path
|
||
(reached from `App::cmd_context`); building a frame there to ask the question
|
||
would clone every row tuple on every key (raised by Copilot on PR #23).
|
||
`AxisFrame::header_widths` is the frame-shaped entry point, derived rather
|
||
than projected as a field because the frame is the whole input — nothing about
|
||
the model is needed, so a shared function cannot fall out of step with the
|
||
data the way a separately-sent field could.
|
||
|
||
### `GridCache` is viewport-indexed, and `GridExtent` is not
|
||
|
||
A cache position is a position within the *viewport*: the client does not know
|
||
the model's coordinates, and the server is free to change what a row means as
|
||
long as it says so.
|
||
|
||
`GridExtent` is how big the **whole** grid is, of which the frames are one
|
||
window (improvise-bpq2). It is the one fact a client needs to move its own
|
||
cursor: `row_frame.len()` is what this client was *sent*, so a client holding
|
||
only its window cannot tell "the last visible row" from "the last row" and
|
||
cannot clamp — a cursor clamped against the window stops at the bottom of the
|
||
screen. It is deliberately **not framed** by the frames, unlike `cells`,
|
||
`col_widths` and `totals`: it is the size of the thing they are a window
|
||
*onto*, so `fit_to_labels` must leave it alone.
|
||
|
||
For a client whose window covers the whole grid — the TUI, which builds its
|
||
cache at offsets zero — it *equals* the frame counts, and **that coincidence
|
||
is exactly what makes a derived version look correct there and fail in a
|
||
browser.** It is a named pair rather than two `usize` on `GridCache`, because
|
||
beside `row_frame`/`col_frame` two bare counts read as "how many rows are in
|
||
the frame", which is the confusion the field exists to remove.
|
||
|
||
`GridUpdate::Extent`'s batch position is **not** load-bearing the way
|
||
`RowFrame`-before-`Cells` is: it addresses no position, so it cannot overtake
|
||
or be overtaken. It is emitted first only because outermost-first reads better.
|
||
|
||
### `GridCache::totals` — empty means "no totals", not "not caught up"
|
||
|
||
Framed by `col_frame` exactly as `cells` and `col_widths` are, with one
|
||
asymmetry: an **empty** strip means this grid has no totals to draw (records
|
||
mode, or an empty grid) and is therefore *not* padded, where a non-empty one
|
||
is. Padding an empty one would draw a blank total row under a records-mode
|
||
grid that has none.
|
||
|
||
The emptiness is the server's answer rather than a client's re-derivation,
|
||
and the totals **must** be projected rather than client-derived: the sum runs
|
||
over *every* row including the ones this viewport has scrolled past, so a
|
||
client holding only its window could not compute it.
|
||
|
||
`page_coords` is framed by **nothing** — it names categories, not positions —
|
||
so it survives a reshape that drops every row and column. It carries Page
|
||
*and* Filter (`View::filtering_cats`, gotcha 27): both restrict what is
|
||
visible, and the bar states what is being looked at, not what the user may
|
||
page through.
|
||
|
||
### `CategoryTreeUpdate::Items` and `::Axes` have no producer, deliberately
|
||
|
||
`document_projections` only ever sends `Categories(..)`, which replaces the
|
||
whole panel. The other two arms are **derived rather than invented**: the
|
||
effects that move a category tree come in three shapes — the list reorders
|
||
(`AddCategory`/`RemoveCategory`), one category's items move (`AddItem`/
|
||
`RemoveItem`), several axes move at once (`SetAxis`, and `CycleAxis` through
|
||
it) — and `doctrine(derive_the_enum_from_the_effects)` is the same recipe that
|
||
built `ModelCommand`. An arm the derivation produces is not speculation just
|
||
because the producer has not reached for it.
|
||
|
||
What the producer reaches for instead is the coarse arm, on purpose:
|
||
`category_tree_of` walks every category once, the panel is small, and
|
||
`Dirty::of` cannot say *which* category moved anyway — an `EffectKind` says
|
||
that a batch changed the tree, never which part, exactly as it cannot say
|
||
which records a `CellData` batch wrote (gotcha 40). Emitting `Items`/`Axes`
|
||
therefore needs the tree **observed and diffed**, the way `document_update`
|
||
observes the title bar, and that is a bandwidth optimisation for a widget that
|
||
costs a few hundred bytes. It is not worth doing until category-tree traffic
|
||
is measured to matter.
|
||
|
||
The contrast with the deleted `Invalidated` is the point: these are *unused*,
|
||
and `Invalidated` was *redundant* — its meaning already lived in running code.
|
||
Unused-but-derived earns a note; redundant earns a deletion.
|
||
|
||
### The fold is not commutative — three rules in `apply`
|
||
|
||
**(a) A `Cells` update that overtakes its frames is dropped, not guessed at.**
|
||
`fit_to_labels` truncates to the frames it has. A client must not reorder the
|
||
stream.
|
||
|
||
**(b) A frame change that *renames* a position blanks the cell under it.**
|
||
`fit_to_labels` preserves **by position**, which is only right where the label
|
||
did not change, so `apply` calls `invalidate_relabelled` itself rather than
|
||
trusting a producer to declare it. It blanks the totals too, because they are
|
||
contents and a stale sum under fresh cells is the one number a user takes at
|
||
face value.
|
||
|
||
**There used to be a `GridUpdate::Invalidated` arm** meaning "contents stale,
|
||
frame unchanged", and it is **deleted** (improvise-n89z). It never had a
|
||
producer: `grid_updates` diffs, so it always knows *which* cells moved and
|
||
sends `Cells`, and `GridCache::seed` compacts. There is no state in which a
|
||
server knows the contents are stale and not which ones — and if there ever
|
||
were, the answer is a fresh `Snapshot` through `rebase`, not a blanking the
|
||
client cannot act on. Meanwhile its stated meaning had migrated into running
|
||
code: `invalidate_relabelled` blanks exactly what a reframing invalidates,
|
||
totals included. So it was **redundant**, not merely unused, and three
|
||
documents described it as load-bearing, which is worse than silence — a reader
|
||
reasoning about the fold's guarantees assumed something produced it.
|
||
`the_retired_invalidated_spelling_does_not_parse` keeps it from returning as a
|
||
serde alias.
|
||
|
||
**(c) Every arm that touches a frame *or* the widths re-fits.** A renderer
|
||
zips widths with labels, and a length mismatch indexes off the end. Fitting on
|
||
the frame arms alone is what let a late `ColWidths` put the mismatch straight
|
||
back: a short update keeps each unheard-of column at its own widest level
|
||
label until `ColWidths` catches up, and a long one is dropped as having
|
||
overtaken the frame that names it, exactly as (a) holds `Cells`.
|
||
|
||
The fallback width is measured in **display cells**, the same unit
|
||
`header_widths` and the authoritative `ColWidths` use: `chars().count()` would
|
||
draw `日本` two cells wide where it needs four, so a cache caught between
|
||
`ColFrame` and `ColWidths` would render the new column too narrow. The
|
||
materialization inside `fit_to_labels` is deliberate — `AxisFrame::item`
|
||
scans, so asking it per column would make a re-fit quadratic.
|
||
|
||
### `invalidate_relabelled` — a `cats` change re-identifies *everything*
|
||
|
||
**A cell's identity is `cats` zipped with its item tuple, so a change of
|
||
`cats` re-identifies every position at once** — even the positions whose
|
||
labels are byte-identical. `Region = [North]` becoming `Department = [North]`
|
||
leaves `items` equal and names a different cell, so the tuples alone cannot
|
||
answer this and every surviving position is stale.
|
||
|
||
The sharpest half is invisible: a preserved `CellDisplay` carries an editable
|
||
`CellId::Record` (ADR 002), so a client would go on offering an edit that
|
||
addresses the record the **old** coordinate named — a misdirected write, in
|
||
the same family as gotcha 18's drill snapshot, and one no rendering shows.
|
||
|
||
Otherwise the comparison is on the **item tuples**, not the entries: a group
|
||
header appearing or collapsing changes the frame's shape without renaming any
|
||
surviving position, and blanking on that would throw away cells the very next
|
||
`Cells` update would have to resend.
|
||
|
||
Two asymmetries about totals:
|
||
|
||
- A relabelled **column** drops its total (improvise-hkt6): the total under it
|
||
is the sum of the old column. A relabelled **row** does not, because a total
|
||
spans every row and is not framed by any one of them.
|
||
- A **recategorization of either axis** blanks every total. A column total is
|
||
a sum over the rows, so re-identifying the rows leaves a number that answers
|
||
coordinates the grid no longer has.
|
||
|
||
This is what obliges the *producer* to diff the totals against the frame the
|
||
label updates will leave rather than against the mirror they change: a column
|
||
renamed while its total stayed the same number is blanked here, and a producer
|
||
comparing `mirror.totals` would find them equal and send nothing.
|
||
`grid_updates` compares `framed.totals` for exactly that reason. The blanking
|
||
stays anyway, because rule (b) exists to stop `apply` trusting a producer to
|
||
remember — **the rule lives on both sides or on neither.**
|
||
|
||
`LabelAxis` is private so the row and column cases share one invalidation
|
||
rule rather than two copies that can disagree.
|
||
|
||
### Bootstrap is a compaction, not a replay
|
||
|
||
A server does not replay history to a late client — it holds the current state
|
||
and expresses it as the same updates the steady stream carries, so bootstrap
|
||
is not a second code path and there is no whole-state shape that could
|
||
disagree with the incremental one. The property that earns it is
|
||
`GridCache::folded(&cache.seed()) == *cache`, and it is the **ordering** that
|
||
earns it: the frames frame the cells, so they go first.
|
||
|
||
`Snapshot { view, seed }` is the only thing on the wire that is not an event,
|
||
and `view` is the one point at which `ViewState` is serialized (improvise-q08).
|
||
|
||
Note the fold tests: asserting `fold(prefix) + fold(rest) == fold(whole)` is
|
||
**vacuous** — it is the definition of a left fold — so the tests that earn
|
||
their place are the order-sensitivity one and the compaction-equivalence one
|
||
(`doctrine(fold_tests_must_test_order_not_the_axiom)`).
|
||
|
||
### `CategoryTreeCache` carries every category, `_`-prefixed included
|
||
|
||
improvise-vjms: the TUI panel has always listed them, and a browser panel that
|
||
quietly did not would be a second, divergent answer to "what are the
|
||
categories". So the `_`-prefixed convention does reach the client — it is
|
||
presentation, and both ends render the same rows from this one source.
|
||
|
||
**The trap is `_Measure`, whose items are derived**: explicit items ∪ formula
|
||
targets (gotcha 7). Whoever builds these rows must go through
|
||
`Model::effective_item_names`; reading `category("_Measure").items` compiles,
|
||
looks right, and silently omits every formula target.
|
||
|
||
Three mutations reach this panel because the effects are three *shapes*:
|
||
adding or removing a category reorders the whole list, adding or removing an
|
||
item touches one category, and a transpose moves several axes at once without
|
||
touching membership. Which categories are *expanded* is
|
||
`ViewState::expanded_cats`, per-client, so the flattening is a client-side
|
||
concern and does not belong on the wire.
|
||
|
||
`FormulaCache` and `ViewListCache` take one whole-list update, because add and
|
||
delete are the only effects that touch them and both are cheap to send whole.
|
||
Which view is **active** is not in `ViewListCache`: that is
|
||
`ViewState::active_view`, carried by `Command::SwitchView`, because it is
|
||
per-client (improvise-8uz). Likewise which tile the cursor is on is
|
||
`ViewState::tile_cat_idx`.
|
||
|
||
### `WizardCache` has a shape and deliberately **no** `Projection`
|
||
|
||
improvise-764, and that absence is the ticket's answer to "where does the
|
||
wizard state live". A `Projection` is broadcast to every connected client's
|
||
widget, which is right exactly when the change is addressed to *no one*
|
||
session. A half-open import dialogue is the opposite: it belongs to precisely
|
||
the session that opened it. That is already settled doctrine elsewhere —
|
||
`ImportWizard` lives on `App` and not on `ViewState`, and `AcceptWizardModel`
|
||
was removed from `ModelCommand` for this same reason (improvise-tom1) after B
|
||
was shown swapping the document out of a dialogue B never opened. Putting the
|
||
wizard on the broadcast stream would have re-created that bug on the
|
||
*rendering* side: every client drawing a dialogue one of them opened.
|
||
|
||
So it is a cache in shape and a **local render model** in fact, built from the
|
||
authoritative `App` by the host that holds the wizard. **A cache without a
|
||
`Projection` is the shape session-local residue takes when it still has to be
|
||
drawn by two different front ends.** It lives here rather than in
|
||
`improvise-command` for the `flatten_cat_tree` reason: the browser client
|
||
depends on `improvise-protocol` alone, and when it grows a wizard it must draw
|
||
*these* rows, not a second derivation. When multi-client import arrives it
|
||
wants the addressed form, which is where ADR 001 layer 2 goes anyway.
|
||
|
||
Every field is already render-ready — the widget that used to build these
|
||
reached through `ImportWizard` into `ImportPipeline`, `FieldKind`,
|
||
`DateComponent` and a whole `Workbook`; none of those cross now. Two
|
||
substitutions are made *here* so both ends spell them the same way: an
|
||
undetected date format reads `"?"`, and the root array path reads `"(root)"`
|
||
rather than empty. `WizardProposal::kind_label` is carried rather than
|
||
re-derived from `kind`, so the two ends cannot disagree about what "Measure
|
||
(numeric)" is called.
|
||
|
||
`WizardStepKind` mirrors `improvise_io::import::WizardStep`, spelled again
|
||
rather than re-exported, because this crate must not depend on `improvise-io`
|
||
(the wizard is an io type, gotcha 30) and a client that draws the wizard must
|
||
still be able to name the step it is on. `WizardFieldKind` is the semantic
|
||
half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the
|
||
terminal mapping is `draw.rs::face_style`.
|
||
|
||
### `Projection` carries no multi-widget variant
|
||
|
||
The envelope a widget subscribes by: it folds the payload of the variant that
|
||
names it and ignores the rest. There is deliberately no variant carrying
|
||
several widgets' updates together — that would be the aggregate cache in
|
||
another form.
|
||
|
||
Nothing is defined for the help overlay: its content is static and
|
||
`help_page` is already in `ViewState`.
|
||
|
||
---
|
||
|
||
### `ColWidths` stays diffed against the mirror, and the ticket's reason was wrong
|
||
|
||
improvise-44ad asked whether `grid_updates` should diff `col_widths` against the
|
||
**label-folded** cache rather than against the mirror, as improvise-hkt6 already
|
||
obliged for `totals`. Folding the labels re-fits the widths, so under a
|
||
fold-relative diff the `ColWidths` a reframe currently carries becomes redundant
|
||
and is suppressed. Declined, and the two things learned are worth more than the
|
||
change.
|
||
|
||
**The stated cost does not exist.** The ticket says the move breaks
|
||
`a_client_on_a_socket_is_seeded_and_gets_its_edit_back`, and that that assertion
|
||
is §3(c)'s standing evidence — so rewriting it inside a change §3(c) guards
|
||
would be the improvise-q0vb / PR #12 shape. Measured 2026-09-21 by deleting the
|
||
re-fit from `fit_to_labels`: **three** existing unit tests fail
|
||
(`a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by`,
|
||
`a_column_without_its_width_yet_is_measured_in_display_cells`,
|
||
`col_widths_always_span_the_column_labels`) and **the socket test is not one of
|
||
them**. What it actually asserts is that a reframe *carried* widths — a
|
||
statement about the producer's diff, not about the consumer's re-fit. §3(c) is
|
||
triply guarded elsewhere, so the evidence argument was about the wrong test.
|
||
|
||
**The real reason to decline is coupling.** The widths a label-fold invents are
|
||
each column's own label width, which is an *approximation* the consumer applies
|
||
until the authority catches up. Diffing the producer's output against it makes
|
||
what the server sends depend on a fallback rule defined in the client. Today
|
||
both are in this crate and the dependency is invisible; the day a second client
|
||
implements the fold, the producer's message set silently depends on that
|
||
client's approximation. The redundant `ColWidths` per reframe is what buys out
|
||
of that, and it is the direction `Dirty::of` is deliberately sloppy in (§5,
|
||
repo-map gotcha 35): over-sending costs one projection, under-sending leaves a
|
||
client wrong with nothing in the stream to correct it.
|
||
|
||
**So the asymmetry between `totals` and `col_widths` is stated rather than
|
||
discovered.** `totals` is fold-relative because hkt6 found a real hole — a
|
||
rename that leaves the number equal sends no `Totals` and the client blanks its
|
||
own. `col_widths` has no such hole, because the re-fit is a *safe* fallback
|
||
rather than a stale value.
|
||
|
||
What was landed is the half the ticket calls "the better test either way":
|
||
`a_client_never_holds_widths_of_a_different_length_from_its_labels` asserts the
|
||
invariant §3(c) is really about, at **every step** of a fold that widens before
|
||
its widths arrive and narrows before they shrink — not merely at the end, which
|
||
is where a message-count assertion looks.
|
||
|
||
### `GridCache::records` — what kind of grid this is (improvise-e5pf)
|
||
|
||
The host answers "is this the wide records layout" with
|
||
`GridLayout::is_records_mode()`, which reads the axes. A thin client has no
|
||
`GridLayout`, and it was answering the neighbouring question instead —
|
||
`ViewState::mode.is_records()` — at three decision points in `resolve.rs`. The
|
||
two agree only while the mode and the layout agree, and
|
||
`Command::ChangeMode(AppMode::Normal)` is precisely the message that separates
|
||
them: the host settles `Normal` against the layout the batch *ends* on
|
||
(`App::settle_normal_mode`), and `reduce_view` folds the variant it was handed.
|
||
|
||
So the fact is **projected**, not derived: `GridCache::records`, carried by
|
||
`GridUpdate::Records`. Deriving it on the client from `row_frame.cats`
|
||
containing `_Index` would have been a second implementation of
|
||
`is_records_mode` — improvise-edp's disease — and deriving it from the mode is
|
||
the bug itself.
|
||
|
||
Like `GridUpdate::Extent` it **frames nothing**, so `apply` does not re-fit and
|
||
its position in a batch is not load-bearing: it describes what kind of grid the
|
||
frames describe, not any position in one.
|
||
|
||
**The settling is observed, never composed — and the first version of this fix
|
||
got that wrong.** It settled at each *composition site*, in `resolve::changed_mode`,
|
||
against the grid the client held **at the moment it resolved the key**. ChatGPT
|
||
Codex found the hole on PR #98: `switch_view_at` composes
|
||
`[SwitchView(target), ChangeMode(Normal)]`, so the settling read the **outgoing**
|
||
view's grid and records → pivot emitted `RecordsNormal` while pivot → records
|
||
emitted `Normal`. The drift the fix existed to close, by a second door.
|
||
|
||
The unreachability argument the first version leaned on covered the *toggle*
|
||
route only — `R` is a `stranding` decline in the browser — and records-ness is
|
||
auto-detected from the axes (gotcha 1), so a **view switch** changes it with
|
||
nothing in the batch saying "records". `doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)`.
|
||
|
||
So `changed_mode` is **deleted**, every site emits the plain `AppMode::Normal`,
|
||
and `ClientState::fold` settles after every fold against the grid it **holds**:
|
||
`self.view.mode = settled_mode(&self.view.mode, self.grid.records)`. That is
|
||
route-independent — it needs no enumeration of composition sites, which is the
|
||
part nothing checks — and it is the same shape the repo already prefers
|
||
elsewhere: `cursor_epoch` over matching on `CycleAxis` (gotcha 24),
|
||
`document_generation` over matching on `LoadModel` (gotcha 39), the dirty flag
|
||
over a table of command variants (gotcha 45).
|
||
|
||
`settled_mode` is the **one rule**, in `improvise-protocol`, and all three ends
|
||
call it: `ClientState::fold`, `ClientSession::fold` (the server's mirror, so the
|
||
two cannot diverge in a field nobody currently reads) and
|
||
`App::settle_normal_mode`. `App::normal_mode` is gone — it was that function's
|
||
host-side duplicate. It settles the `Normal` pair and **nothing else**:
|
||
`Editing`/`RecordsEditing` pass through untouched, which is exactly why a
|
||
records decision asks the grid and never the mode.
|
||
|
||
There remains a **window, and it is inherent**: between the local optimistic
|
||
fold of a `SwitchView` and the arrival of the target view's projection, the
|
||
client holds the outgoing grid and cannot know the target's records-ness. The
|
||
mode settles the moment the projection lands. No design removes that window —
|
||
read-time derivation has exactly the same exposure, since it would read the same
|
||
stale grid — so the honest statement is that the mode settles *when the grid
|
||
does*, on both ends.
|
||
|
||
The three decision points ask `state.grid.records`. What that buys concretely: the
|
||
commit at a records cell **declines** rather than composing
|
||
`typed_cell_command` against a grid whose columns are
|
||
`[coords…, +cat, measures…, +]` (gotcha 1), which was a wrong write and strictly
|
||
worse than the black hole it was written to produce.
|
||
|
||
## §3a. The frame's group boundaries and its position in the axis
|
||
|
||
Two facts `AxisFrame` gained after `improvise-anr9`, both about group runs.
|
||
|
||
**`FrameEntry::GroupEnd` closes a run.** `group_of` used to answer "nearest
|
||
preceding header", which is not membership: `expand_category` emitted a header
|
||
on *entering* a group and nothing on leaving one, so an ungrouped item after a
|
||
grouped run inherited it (`improvise-gd83`). The §3 paragraph that recorded the
|
||
old rule — and forbade this change — is corrected there, not merely superseded
|
||
here. `AxisEntry::GroupEnd` is
|
||
the boundary, carried onto the wire as `FrameEntry::GroupEnd`; `groups()` closes
|
||
its level on it, and every deeper one. It is emitted only on
|
||
`Some -> None`, never between two groups — the next header already ends the
|
||
previous run — and at the end of an expansion whose last item was grouped, which
|
||
is the across-prefix arm `cross_product` creates.
|
||
|
||
Nothing draws a `GroupEnd`. Both front ends skip it and it consumes no data
|
||
index, so it is invisible except through `group_of`/`groups`.
|
||
|
||
**Runs nest, so both markers name a level (improvise-e265).** `FrameGroup`
|
||
carries `level`, an index into `AxisFrame::cats`, and `FrameEntry::GroupEnd` is
|
||
`GroupEnd { level }`. `groups()` keeps one open run per level: a group at L
|
||
opens L and closes deeper runs, and a `GroupEnd` at L closes L and deeper. Each
|
||
position's answer is its **innermost** open run. So with `Region` over `Month`,
|
||
`North/Mar` after `Q1` ends is in `Upper`, and it was in nothing while a flat
|
||
reading let `Q1`'s end close `Upper` too. The user ruled per-level membership,
|
||
and that `z` toggles the innermost group. The layout reads its markers by the
|
||
same rule (`context/code/core.md` §6), and
|
||
`the_frame_agrees_with_the_layout_on_every_rows_innermost_group` holds the two
|
||
together at every window offset.
|
||
|
||
The level goes on the `GroupEnd` as well as on the header because the markers
|
||
are cut by three producers (expansion, pruning and windowing), and a marker
|
||
that names its own level can be read correctly inside any fragment of the
|
||
sequence. The header's level also gives a client the group's category,
|
||
`cats[level]`, which a browser `z` needs to compose `ToggleGroup`. That is not
|
||
the dead field improvise-7xyx removed: `level` is read by `groups()`, and the
|
||
category name is not carried twice.
|
||
|
||
**Within one category runs nest by `depth` (improvise-felo.6).** A group's
|
||
parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME
|
||
level, so both markers also carry `depth`, the group's number of ancestors.
|
||
A run's key is `(level, depth)`, compared in that order: a header at a key
|
||
closes every run with a greater key, and so does a `GroupEnd`. With `Date`
|
||
alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after
|
||
`2024` ends in nothing. Depth rides beside `level` rather than being folded
|
||
into one flat index, because a flat index would need every category's tree
|
||
height to be known before the first marker, and `level` would stop naming
|
||
`cats[level]`. Both front ends draw a nested header exactly as they draw any
|
||
other: one inline row per `Group` entry, the innermost group in the column
|
||
band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and
|
||
`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and
|
||
`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the
|
||
windowed frame against the layout.
|
||
|
||
`group_runs` is unchanged. It coalesces whatever `groups()` answers, by header
|
||
identity (improvise-h0iv), so the band still paints one group per position, now
|
||
the innermost. One band per level would be a separate feature.
|
||
|
||
**`AxisFrame::first` is the absolute data index of the frame's first item.** The
|
||
windowing rule carries the open header at each level *above* a scrolled window so
|
||
`group_of` can answer, and `body_from` drops it on the way to the screen. At the
|
||
top of an axis there is no "above", so that same leading header is content — a
|
||
collapsed first group has no other affordance (`improvise-0o6x`). `idx` alone
|
||
cannot tell the two apart, because the browser's frame is already windowed and
|
||
always passes `0`; `first + idx == 0` can.
|
||
|
||
The TUI builds over the whole grid so `first` is always `0` there, and the
|
||
browser's comes from the projected window. A frame built by hand in a test must
|
||
set it, which is why it is a plain field rather than derived.
|
||
|
||
It carried `#[serde(default)]`, which was a defect rather than a decision and
|
||
is now **gone** (improvise-1wl0): `first: 0` is not an absence but the claim
|
||
"this frame leads the axis", which is the claim `body_from` acts on — so a
|
||
frame that lost the field deserialized as leading, and a scrolled browser drew
|
||
the carried header above its first row. Same argument as `Basis` having no
|
||
`Default`: a zero-valued claim that happens to be true in the common case is a
|
||
forgeable bypass rather than an absence.
|
||
|
||
**Dropping the attribute was preferred to `Option<usize>` or a two-variant
|
||
enum, and the reason is about the wire rather than the type.** There is exactly
|
||
one production producer (`project.rs::axis_frame`, which reads `window.start`),
|
||
so a frame is never partially constructed and "unknown" is not a state any
|
||
honest peer can be in — making it representable would oblige `body_from` to
|
||
answer for it, which is the same question one level down. A missing field is
|
||
now a loud parse failure at the transport, where a wire-format skew belongs.
|
||
No skew ships today, both ends being one workspace at one commit; this is about
|
||
the day there is one, and about the attribute reading as a considered
|
||
compatibility decision when it was not.
|
||
|
||
The `Default` derive still yields `first: 0`, and that is not the same hole: an
|
||
`AxisFrame::default()` has no entries, so `body_from` returns an empty slice on
|
||
either arm and the claim is vacuous. The hazard was specifically a *populated*
|
||
frame acquiring the claim by omission.
|
||
|
||
**`group_runs` / `group_run_heads` are the coalescing rule's one home.** The TUI
|
||
and `render.rs::col_groups` each coalesced adjacent columns sharing a group,
|
||
kept in step by a comment (`improvise-8qlr`). The TUI's one divergence is that
|
||
it labels its first *visible* column even mid-run, so a scrolled grid does not
|
||
draw an unlabelled group line.
|
||
|
||
**A run is opened by a HEADER, not by a name** (improvise-h0iv). Both prior
|
||
copies coalesced by name alone, and `improvise-8qlr` preserved that *because* it
|
||
was a consolidation: changing the rule in the commit that moved it would have
|
||
left the agreement test unable to tell "both ends agree" from "both ends changed
|
||
together". With one home that argument is spent, and the rule can be changed
|
||
once for both ends — which is the shape to notice, since a consolidation's
|
||
frozen behaviour reads as decided afterwards and nothing says it was only frozen
|
||
for the duration of the move.
|
||
|
||
**The ticket's own proposed fix does not work, and that is worth more than the
|
||
fix.** h0iv suggested comparing the `(cat, name)` pair; `FrameGroup` carried
|
||
`cat` then (improvise-7xyx has since removed it).
|
||
But its own reachability case is *one* category: `Month` grouped into `Q1`,
|
||
nested under `Region`, giving `North/Q1` and `South/Q1` adjacent — the two
|
||
headers are `(Month, Q1)` and `(Month, Q1)`, **equal as values**, so the pair
|
||
separates nothing. `the_two_headers_are_equal_as_values_so_comparing_them_cannot_split_them`
|
||
pins that as whole-value equality, so the next reader does not re-propose it. What separates them is
|
||
that they are two different `FrameEntry::Group` entries, and `groups()` already
|
||
borrows each from `self.entries` — so the comparison is `std::ptr::eq` on the
|
||
header, which is the literal statement of "the same header opened both".
|
||
|
||
**The case that settled the product question was not the span but the
|
||
indicator.** Two adjacent same-named runs might well be one span a user wants
|
||
spanned, which is why h0iv was filed rather than fixed. What is not arguable is
|
||
`column_groups_coalesce_by_name_as_the_tui_does`, whose fixture merged
|
||
`Month/Q1` (expanded) with `Fiscal/Q1` (**collapsed**) and whose own message
|
||
said the merged span is "drawn with the FIRST one's indicator" — a collapsed
|
||
group rendered as expanded, with no affordance saying otherwise. A span
|
||
boundary is a matter of taste; a wrong collapse glyph is a wrong fact. So: two
|
||
runs. Reversing it is one comparison, and the test is renamed
|
||
`column_groups_coalesce_per_header_as_the_tui_does` rather than deleted.
|
||
|
||
## §3b. A frame's levels are checked as it arrives
|
||
|
||
A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index
|
||
`cats`. `AxisFrame::groups` used to keep one open run per level and size that
|
||
stack with `open.resize(g.level, None)`, so a level arriving over the wire
|
||
decided how much it allocated. A frame whose level is `4294967295` would ask for a
|
||
multi-gigabyte vector, and the client would be taken down instead of the frame
|
||
being declined. ChatGPT Codex found this on #250, the PR that gave levels to the
|
||
wire (improvise-e265).
|
||
|
||
Since improvise-felo.6 `groups()` keeps only the headers it has received,
|
||
ordered by `(level, depth)`, so no number on the wire sizes anything and the
|
||
new `depth` field needs no bound. The level check stays for its other reason:
|
||
`cats[level]` names the group's category, which a browser `z` reads.
|
||
|
||
The bound is enforced at deserialization, by `#[serde(try_from =
|
||
"UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A
|
||
level that is not below `cats.len()` fails to parse, with an error that names the
|
||
level. A malformed frame therefore fails loudly: `Client::on_frame` returns the
|
||
error to the page, the same way it returns any other frame that does not parse.
|
||
It is not clamped, because a clamped level would be a guess about which group
|
||
the server meant, and a wrong group is a wrong fact.
|
||
|
||
Three alternatives were weighed and not taken:
|
||
|
||
- **Bounding inside `groups()`.** The same allocation would still happen in any
|
||
other reader, and a frame that is wrong would pass through silently.
|
||
- **Checking only the first marker.** A frame can be well-formed up to its last
|
||
group, so the check walks every marker. The test puts the bad level on the
|
||
second marker for that reason.
|
||
- **A frame built in Rust is still unchecked.** The fields are public, and the
|
||
producers build levels from `AxisEntry`, whose levels are category indices by
|
||
construction. The hazard this closes is the wire, where the input is not
|
||
ours.
|
||
|
||
## 4. `frame.rs` — one envelope pair, two deployments
|
||
|
||
### The framing adds as little as it can get away with
|
||
|
||
`doctrine(derive_the_enum_from_the_effects)`: inventing wire variants ahead of
|
||
the mutations that need them produces a protocol nobody can implement.
|
||
|
||
### There were three pairs for two deployments (improvise-xks6)
|
||
|
||
The socket's server side, a hand-kept **mirror** of it in the browser bundle,
|
||
and a third pair written for the worker (improvise-tm6) before the projection
|
||
architecture landed. The question that had to be answered before any of it
|
||
moved was whether the **worker** and the **socket** want the same vocabulary.
|
||
They do, and the test applied to each difference was whether the *argument*
|
||
for it names the thing they differ in. None did — every difference was a
|
||
**date** rather than a decision:
|
||
|
||
- **`Viewport` is deployment-independent.** The extent is the one session
|
||
input no `Command` carries by construction: it is a fact about a terminal or
|
||
a browser window, it is not in `ViewState`, and no effect produces it, so no
|
||
derivation reaches it. A worker hosting the same `Sessions` owes
|
||
`set_extent` the same answer. It is the **handshake** on both, because a
|
||
`Snapshot` seeds the *visible* cells — one built at extent zero is an empty
|
||
grid the client would immediately have to ask again for.
|
||
- **`Key` is deployment-independent too.** A keymap binding names an *intent*
|
||
resolved against a `CmdContext` a thin client does not hold (gotcha 38),
|
||
which says nothing about what the bytes travel over. The socket lacked the
|
||
variant because key forwarding had never been built on it — a gap
|
||
(improvise-bpq2), not a design.
|
||
- **`Commands(Vec<_>)` batches where a singleton cannot.** A fan-out must
|
||
arrive whole and in sequence, because a `GridUpdate::Cells` that overtakes
|
||
its frames is *dropped*, not replayed. That is a property of the **fold**,
|
||
not of the transport: an in-process `postMessage` interleaves exactly as a
|
||
socket does. `Commands(vec![c])` expresses everything a `Command(c)` variant
|
||
could.
|
||
|
||
The worker pair was not merely different but **incoherent by omission**:
|
||
written before ADR 001, it could carry `Command::Model { basis, .. }` and had
|
||
no `Rejected`, so a client speaking it composed a claim that could never be
|
||
answered. **That incoherence — not the difference — is the tell for
|
||
staleness.**
|
||
|
||
### The mirror is gone, and so are the two tests that held it honest
|
||
|
||
That deletion was the ticket's measure of success.
|
||
`the_socket_framing_matches_the_server` round-tripped hand-listed fixtures
|
||
through the other crate's types to catch a **rename**;
|
||
`every_server_variant_is_one_this_crate_mirrors` matched exhaustively to catch
|
||
a **variant added on the server and never mirrored**. With one definition both
|
||
are **type identity**: a rename is a compile error at every call site in both
|
||
crates, and the un-mirrored variant is not a constructible state. That is
|
||
stronger than either test, and it is the shape to insist on — **if a
|
||
consolidation leaves one of its compatibility tests alive, the consolidation
|
||
is not finished**
|
||
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
|
||
|
||
What survives is `the_retired_spellings_do_not_parse`, aimed at the one thing
|
||
still possible; see `context/test-evidence/protocol.md`.
|
||
|
||
### `ClientFrame::Commands` — the frame is the unit of atomicity
|
||
|
||
Not the command (ADR 001, improvise-955n). The host answers for *every*
|
||
command in the frame before applying any, so a frame is never applied
|
||
partially **because of a refusal**. It used to apply in order and abandon at
|
||
the first rejection, which left a hole ADR 001's "never applied, not even
|
||
partially" does not have.
|
||
|
||
**The frame states one basis**, and it is the only claim a client can
|
||
construct: a basis is published between batches and never during one, so no
|
||
client can hold one describing the document as it would be half-way through
|
||
its own frame. Commands then run in order, each against what its predecessor
|
||
left, and data dependence between them is expected — a chain may create a
|
||
record and then write it.
|
||
|
||
**But only two of the three tiers are answered once.**
|
||
`document_generation` and `layout_version` describe the *document*, so
|
||
answering them on the frame's first mutation is right. The **record tier is
|
||
asked of every mutation**, because which records are at stake is a property of
|
||
the command rather than of the frame — skipping it for later commands let one
|
||
client's chain overwrite a record another had moved, silently. The exemption
|
||
that keeps data-dependent chains working is narrower than "later commands are
|
||
not checked": a record that **neither resolves nor is claimed** is one the
|
||
frame is about to create, and only that combination is exempt. A record that
|
||
resolves is answered for however the claim is written. `CheckedBatch` in
|
||
`improvise-command` carries the derivation.
|
||
|
||
**All-or-nothing is the contract and clause 4 is not yet delivered.** That is
|
||
the other way a frame stops half-way, and it is not a refusal: a chain whose
|
||
later command *fails at apply time* leaves its predecessors applied, because
|
||
`App::apply_effects` aborts **without undoing** (gotcha 21 — an abort is not a
|
||
rollback).
|
||
|
||
### `ClientFrame::Save` is a request, not a `Command`
|
||
|
||
improvise-368h. It carries **no path**, because a client cannot see the host's
|
||
storage: `effect::Save` writes to *this host's* `file_path` through *this
|
||
host's* `Storage` (gotcha 2), and which document a host is hosting is the
|
||
host's own fact. A client naming a path would be naming a namespace it cannot
|
||
read — the same mistake `ModelCommand::SetCell` avoids by taking a surrogate
|
||
rather than a `CellKey`.
|
||
|
||
**It is a frame and not a `Command`, and the reason is atomicity.** It began
|
||
as a `Command`, which put a *runtime-fallible* operation inside a batch
|
||
`Commands` guarantees is applied whole or not at all — so `[Save, SetCell]`
|
||
reported a failed write and then applied the edit anyway, and a client reading
|
||
"refused" could not tell which. Saving also fails the test every `Command`
|
||
passes: it mutates no shared state and no client's slice, so `reduce_view` has
|
||
nothing to fold and `Checked` has no basis to answer for. Here it cannot be
|
||
composed with a mutation at all — **the batching question stops being a rule
|
||
and becomes unconstructible** — and it sits beside `Viewport`, the other
|
||
session input no `Command` carries.
|
||
|
||
Success is silent: the document did not move, so no widget is dirty and nobody
|
||
else is owed anything.
|
||
|
||
### `ClientFrame::Key` — and a correction
|
||
|
||
The doc used to say "the host runs it through the keymap against the
|
||
authoritative `App`". **It does not.** `Host::handle` answers this arm with an
|
||
explicit empty vec, because a key resolved on the host moves state no client
|
||
can hear (`a_key_resolved_on_the_host_moves_state_no_client_can_hear`): the
|
||
projection stream carries nothing of the host's own `ViewState`, so host-side
|
||
resolution *diverges*. improvise-bpq2 closes that by **avoiding** it — the
|
||
browser resolves keys against the shared keymap (§6) — so an upstream key is a
|
||
**black hole, not a slow path**, and every `KeyOutcome::Upstream` names a
|
||
binding that does nothing at all in a browser. The variant is kept because the
|
||
list of keys that still need the host is named in
|
||
`improvise-wasm-client/src/keys.rs`, so closing one has to come there and say
|
||
so.
|
||
|
||
It carries `(KeyCode, KeyModifiers)` rather than a `KeyEvent` because
|
||
`KeyEvent` is not `Serialize` — the same pair `Command::SetTransientPrefix`
|
||
already puts on the wire.
|
||
|
||
### `ServerFrame` has three variants because `Sessions::apply` has three cases
|
||
|
||
`Applied::Diff`, `Applied::Rebased`, `Applied::Rejected` (gotcha 39).
|
||
|
||
`Snapshot` is **boxed**: it carries a whole `ViewState` plus the seed that
|
||
redraws the document, and every `Commands` frame would otherwise be allocated
|
||
at that size for a variant sent once per connection. Serde sees through the
|
||
box, so the JSON is unchanged. It is sent again to *every* client whenever the
|
||
document is replaced, because a client's `cursors` are keyed by the outgoing
|
||
document's view names and only a fresh snapshot replaces them (gotcha 24).
|
||
|
||
`ServerFrame::Commands` carries both of the things a client folds, because on
|
||
the wire they are the same thing: a `Command::Project` is a widget update, and
|
||
any other `Command` is a peer's accepted move that this client's own view
|
||
slice must fold too (gotcha 36). Order is load-bearing, so a transport must
|
||
deliver a batch whole and in sequence.
|
||
|
||
`Rejected` is addressed to **the asker alone** — nothing happened, so nobody
|
||
else is owed anything — and it carries no new state, because the client
|
||
already holds what is current through the projections it has been folding all
|
||
along. That is what makes rejection cheap enough to be a *normal outcome*
|
||
rather than an error.
|
||
|
||
### `Accepted`, and why a rejection alone is not a protocol (improvise-nxah)
|
||
|
||
`ServerFrame::Rejected` told a client that a frame did nothing. Nothing told it
|
||
that a frame *did* something. That asymmetry is not cosmetic, because the
|
||
browser folds a frame's view commands **optimistically** before sending it: a
|
||
refusal must put them back, and to know *which* frame's folds to put back the
|
||
client has to be able to retire the ones that landed. With only a refusal on the
|
||
wire, a client holding two frames in flight cannot tell which of them a
|
||
`Rejected` answers — so the rollback either addresses the wrong frame or never
|
||
happens. This is the same shape as the publication gap ADR 001 originally left:
|
||
it said a batch carries a basis and never said how a client learns one, and
|
||
`Projection::Basis` had to close it. Here it said a batch may be refused and
|
||
never said how a client learns it was not.
|
||
|
||
So a **mutating** frame is answered exactly once, `Accepted` or `Rejected`, and
|
||
the two facts that make that a usable rule are already load-bearing elsewhere.
|
||
*Only* `Command::Model` can be refused — `Checked::against` says so exhaustively
|
||
and `Host::commands` only enters `mutating_frame` when one is present — so a
|
||
frame carrying no `Command::Model` is answered by neither, and a client can tell
|
||
by inspecting the frame it is about to send rather than by remembering a rule.
|
||
And `Rejection::refused_an_edit` already separates the two `Save` refusals from
|
||
the batch refusals, so a failed write cannot consume the answer owed to an edit.
|
||
|
||
`Accepted` carries **no state**, and it goes **last** in the frame's answer, for
|
||
the reason a published basis goes last (§5): everything the frame moved has
|
||
already arrived as projections, so a client discarding its undo knows exactly
|
||
what it is discarding. A client that folded the acknowledgement first would be
|
||
retiring a frame whose effects it had not yet been shown.
|
||
|
||
---
|
||
|
||
## 5. `basis.rs` — what a batch was composed against
|
||
|
||
### The problem is not ordering
|
||
|
||
The server already serialises every write — one `App`, one `apply_effects` —
|
||
so there is a total order and nothing here is about ordering. What was missing
|
||
is noticing that a client composed an edit against a **picture of the document
|
||
that has since moved**: the same stale-basis shape as the cursor under a
|
||
reshaped grid (gotcha 24) and the session mirror after a document swap (gotcha
|
||
39), one level up.
|
||
|
||
### Three tiers, and the tiering is the whole economy
|
||
|
||
| tier | moved by | a batch is stale if |
|
||
|---|---|---|
|
||
| `document_generation` | a `WholeWorkbook` effect | it moved at all |
|
||
| `layout_version` | an observed change to any view's drawn shape | it moved at all |
|
||
| `records` | a `CellData` effect on that record | any record the batch names moved |
|
||
|
||
A cell edit is stale when the **layout** moved, even though no record it names
|
||
was touched, because the shape its keys hang off changed underneath it. That
|
||
is ADR 001's class 2 — A removes the category `Region` while B commits an edit
|
||
keyed by `Region=East` — and it is **corruption** rather than a lost
|
||
keystroke. Detecting it falls out of the tiering with no lock anywhere: a
|
||
cell-level lock would not have covered it, because holding
|
||
`Region=East × Revenue` does not stop anyone deleting `Region`.
|
||
|
||
### The client understands none of this
|
||
|
||
A `Basis` is three opaque tokens — nothing in it is a structure to interpret,
|
||
the same discipline as the cell surrogate it is keyed by (ADR 002). The server
|
||
publishes the current one as a `Projection`, the client keeps it in a
|
||
`BasisCache` and hands back `claim()` when it composes a write.
|
||
|
||
### `Basis` has no `Default` and no public constructor
|
||
|
||
A zero-valued basis is not "no claim", it is the claim that **the document is
|
||
fresh** — which in a brand-new `App` is true, so a default would be a
|
||
*forgeable bypass* rather than an absence. `BasisCache::claim` is the only
|
||
door a client has, and it returns `None` until the server has published one.
|
||
|
||
Note what that does and does not buy. `Basis` derives `Deserialize` because it
|
||
must cross the wire, and its fields are public, so **a hand-rolled client can
|
||
state any tiers it likes**. This is an *anti-staleness coordination check, not
|
||
a security boundary*: it closes **omission**, not **invention**, and nothing
|
||
here tries to close invention, because a client that forges a current basis is
|
||
indistinguishable from one genuinely caught up. "The only constructor" is a
|
||
statement about clients written against this crate, which is still worth
|
||
having — it makes the honest path the easy one.
|
||
|
||
There is deliberately **no flag that skips any of this**, because "a mechanism
|
||
whose only purpose is to violate the invariant is not a feature, it is the
|
||
invariant deleted with extra steps". A *force* re-composes the same intent
|
||
against the current basis and submits a fresh batch; it never applies a stale
|
||
one.
|
||
|
||
### `claim` omits rather than invents
|
||
|
||
A record the cache has never heard of is **left out**, not defaulted to zero.
|
||
Defaulting would be a claim the client cannot support, and the server would
|
||
read it as "untouched" — the exact forged-basis case `RecordUnstated` exists
|
||
to catch.
|
||
|
||
### The publication is what ADR 001 omitted
|
||
|
||
The ADR says "a batch carries the basis it was composed against" and never
|
||
says how a client comes to know one; nothing in the projection stream carried
|
||
any of the three tiers, so **the check as written was satisfiable only by
|
||
forging**. That is the one place that document met the code and was wrong.
|
||
`Projection::Basis` + `BasisCache` close it.
|
||
|
||
It is folded like any other widget (improvise-rkeq) — not a widget, nothing
|
||
draws it, but it subscribes on exactly the same terms, and the alternative was
|
||
a second server→client channel with its own ordering rules for a payload that
|
||
changes at precisely the moments a projection already does. The update
|
||
replaces wholesale, as `Projection::Formulas` does, so "has it changed" and
|
||
"send it" collapse into one decision.
|
||
|
||
**It is emitted *after* the grid updates in a batch**, because the counters
|
||
describe the cells those updates carry: a basis arriving first would let a
|
||
client claim a version for a value it had not been shown — the mirror image of
|
||
the fold's frame-before-cells rule.
|
||
|
||
### Which records are checked is the server's decision
|
||
|
||
`reduce.rs::records_named` in `improvise-command`, an exhaustive match with
|
||
one or-pattern for the variants that name no record, so a new record-writing
|
||
variant fails the build. Reading the list off the *client's* claim would be a
|
||
**force by omission**: the record tier skipped, with no flag to remove and
|
||
nothing in the message saying so.
|
||
|
||
### `published()` takes a `BTreeMap`
|
||
|
||
So the record list is deduplicated and in a stable order. The server diffs the
|
||
published basis against what it last sent this client, and a list whose order
|
||
followed grid traversal would report a change every time a row moved.
|
||
|
||
### `Rejection` keeps structural and data staleness apart
|
||
|
||
Which is the whole reason ADR 002 put a maintained `RecordHash` index in
|
||
`DataStore`: a regenerate-on-demand scheme could not tell a record that had
|
||
scrolled out of view from one that no longer existed.
|
||
|
||
- **`RecordMoved`** is class 1. Somebody wrote the record since this client
|
||
read it. Force is coherent and should be one keystroke: the user has seen
|
||
the other value and chooses to overwrite it.
|
||
- **`RecordGone`** and **`LayoutMoved`** are class 2. There is nothing
|
||
coherent to force — the key names a coordinate that no longer exists, and
|
||
applying anyway is exactly the ghost-coordinate corruption this check exists
|
||
to prevent. **Force never creates structure**: re-instating a category
|
||
somebody deleted so an edit can land is a larger intent than the user
|
||
expressed and must be an explicit action. What the UI owes here is what
|
||
changed and the concrete alternatives, not a button.
|
||
- **`RecordUnstated`** is a case of its own because "you lost a race" and "you
|
||
were never in one" are different things to tell a user — and because naming
|
||
it is what closes the force-by-omission hole. A well-formed client cannot
|
||
produce it: it composes a write from a cell it is drawing, and the published
|
||
basis covers every record its grid names.
|
||
|
||
This is also why the wasm client stores the `Rejection` itself rather than
|
||
collapsing it into `status_msg`: a string cannot carry *which* record and
|
||
*which* kind.
|
||
|
||
### Two classes live in one type
|
||
|
||
`Rejection` is a refusal of a **frame**, not of a command — since
|
||
improvise-368h a `ClientFrame::Save` the host cannot honour is answered here
|
||
too, and that is not a refused command at all.
|
||
|
||
1. **Staleness** (ADR 001) — `DocumentReplaced`, `LayoutMoved` and the three
|
||
record variants. The only class where *force* means anything.
|
||
2. **A request the host cannot honour** — `NoDocumentPath` and `SaveFailed`
|
||
(improvise-368h). Nothing went stale; there is nothing to re-compose.
|
||
|
||
They share this type rather than getting refusal channels of their own, for
|
||
the reason the type exists: a refusal is "nothing happened, here is why",
|
||
addressed to the asker alone, and a second channel would be a second set of
|
||
ordering and addressing rules for one idea. A **third class is coming and is
|
||
not here yet**: a transaction rolled back because a later command in a chain
|
||
could not be applied (clause 4 of the frame contract). Until rollback exists
|
||
there is nothing to report, because nothing is undone.
|
||
|
||
`NoDocumentPath` is decided **before** anything runs, by the same constructor
|
||
that answers for a basis, so a save that cannot land is refused rather than
|
||
attempted. What the asker should do about it differs by front end: a TUI
|
||
prompts for a path (improvise-lae), a browser worker whose document *is* its
|
||
storage entry never sees this at all. `SaveFailed` carries no message because
|
||
`Rejection` is `Copy` and an I/O error's text is not — the host keeps the text
|
||
in its own status line, and what crosses the wire is the one fact a client can
|
||
act on: the document it is editing is not on disk. **Reporting a failed write
|
||
as success is the thing this exists to stop.**
|
||
|
||
---
|
||
|
||
## 6. `keymap.rs` — one table, both ends
|
||
|
||
This used to live in `improvise-command`, where only the terminal could reach
|
||
it, and the browser client carried a hand-written classifier answering the
|
||
same question for the subset it could resolve. That is two definitions of one
|
||
vocabulary kept in step by a lockstep test over a hand-written list of keys —
|
||
and `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`
|
||
says such a pair is one definition waiting to be made.
|
||
|
||
The move is the one gotcha 13 already anticipated:
|
||
`ViewState::transient_prefix` stores a prefix as an **address** rather than an
|
||
`Arc<Keymap>` precisely so `ViewState` could stay free of `improvise-command`
|
||
types while the keymap came the other way.
|
||
|
||
### The boundary that did not move
|
||
|
||
A lookup yields a command **name and arguments**. Turning that into something
|
||
that runs needs a `CmdContext` — a `Model`, a `GridLayout`, an `App` — which a
|
||
thin client does not have, so *intent → mutation* stays where it was:
|
||
`improvise-command` resolves a name through its `CmdRegistry` into `Effect`s,
|
||
and a browser client resolves the subset it can compose into `Command`s.
|
||
|
||
That is a better seam than the one it replaces. The drift risk used to be
|
||
"which key does what" — ~170 bindings, no direct test, invisible when wrong.
|
||
It is now "which command names can I execute", which is a list.
|
||
|
||
What stayed in `improvise-command`, and why each had to:
|
||
|
||
- `Keymap::dispatch` and `KeymapSet`, which pair this table with a
|
||
`CmdRegistry` and return `Box<dyn Effect>`.
|
||
- `SetTransientPrefix`, which is an `Effect` — it writes
|
||
`ViewState::transient_prefix`, and `Effect` is the command layer's trait.
|
||
|
||
The keymap's own tests live with `dispatch` in
|
||
`improvise-command/src/command/keymap.rs`, for the same reason: they need a
|
||
registry to check a bound name against.
|
||
|
||
### `ModeKey` and `AppMode` are two different enumerations
|
||
|
||
They happen to be the same size (17). `SearchMode` is a `ModeKey` and not an
|
||
`AppMode` — it is Normal plus `search_mode: bool`; `Quit` is an `AppMode` with
|
||
no keymap. **Do not "fix" one count to match the other.**
|
||
`ModeKey::from_app_mode` is exhaustive on purpose: a new `AppMode` without a
|
||
keymap silently swallows every keypress, so the compiler must flag it.
|
||
|
||
### Lookup fallback order
|
||
|
||
`exact(key, mods)` → `Char` with `NONE` (shift is implicit in the char) →
|
||
`AnyChar` → `Any` → parent. `Keymap::with_parent` is Emacs-style inheritance,
|
||
and it is what lets `RecordsNormal` reach Normal's `g`/`y`/`Z` prefixes.
|
||
|
||
`Keymaps::prefix_keymap` deliberately goes through `lookup`, which walks
|
||
parents, for exactly that reason. Resolution assumes prefixes are **flat**;
|
||
`prefix_keymaps_are_flat` fails the build if a nested one is added.
|
||
|
||
**`lookup_local` is exhausted before the parent is consulted**, and that one
|
||
line of ordering is what makes the global below cost nothing.
|
||
|
||
### The global — all seventeen modes, and the answer really is "all"
|
||
|
||
`global_keymap()` binds `?` (help), `:` (the command line) and the three panel
|
||
toggles, and every mode's keymap is built `Keymap::with_parent(global)`. The
|
||
user's ask (improvise-636d) said "almost all", and the honest answer after
|
||
naming the exceptions one at a time is **none of them** — no mode needs to
|
||
opt out, because every mode that must not answer a global key **already
|
||
shadows it locally**:
|
||
|
||
- the eight text-entry modes and `SearchMode` bind `KeyPattern::AnyChar`, so
|
||
`?` types a literal `?` and the parent is never reached;
|
||
- `ImportWizard` binds `KeyPattern::Any`, so nothing escapes it either;
|
||
- `Help` binds its own `?`, which **closes** the overlay rather than
|
||
re-entering the mode already showing. That is the one product call the
|
||
ticket left open, and it is decided here.
|
||
|
||
None of that is an override anyone had to write — the first two fall out of
|
||
the fallback order, and only Help's is deliberate.
|
||
|
||
**The global may bind Char keys only, and that is load-bearing rather than
|
||
tidy.** `AnyChar` matches `KeyCode::Char` and nothing else, so a global `F1`
|
||
would reach the parent *while the user is typing* and throw away a half-typed
|
||
cell edit. That is why `F1` stays bound on Normal alone although it runs the
|
||
same command as `?`, and why `the_global_binds_only_unmodified_char_keys`
|
||
asserts the shape rather than the list. A chord is excluded for the same
|
||
reason: `lookup_local`'s `Char`-with-`NONE` retry is what a shifted `?` relies
|
||
on, and it does not run for a `CONTROL` binding.
|
||
|
||
**`Esc` is not a candidate and should not become one.** It means something
|
||
different in nearly every mode — leave the panel, cancel the edit, close the
|
||
overlay, abandon the wizard — so a global `Esc` would be shadowed everywhere
|
||
it mattered and wrong wherever it was not.
|
||
|
||
What the change is worth: before it, `?` resolved in **2 of 17** modes
|
||
(Normal, and `RecordsNormal` through the one inheritance edge), missing from
|
||
all three panels, `TileSelect`, `SearchMode`, `ImportWizard` and `Help`
|
||
itself — exactly the modes a lost user is standing in. `bind_panel_toggles`
|
||
had **two** call sites and `:` was written into two spines while `?` was in
|
||
one; that asymmetry was the evidence that nothing kept the spines in
|
||
agreement, and it is what `no_mode_binds_a_panel_toggle_of_its_own` now keeps.
|
||
|
||
`every_modes_keymap_chain_ends_at_the_same_global` compares with
|
||
`Arc::ptr_eq` rather than by value, because "several tables that agree today"
|
||
is the thing being removed, and two equal-but-separate globals would satisfy
|
||
any value comparison.
|
||
|
||
### The layer below it: `document_keymap` (improvise-wiew)
|
||
|
||
The global above is **all seventeen modes**, and that answer is unchanged. What
|
||
changed is that a second shared layer now sits between it and some of them.
|
||
|
||
The user asked for `[`/`]` "probably in the global keymap", and then answered
|
||
the question that left open himself: *"the help display (and import wizard,
|
||
etc.) shouldn't respond to these keys, but the 'normal' modes like
|
||
categories/tiles/formulas/etc. should."*
|
||
|
||
The distinction is **is the document the subject right now**, and it is genuinely
|
||
a new one rather than the global's argument applied again. Every key the global
|
||
binds — `?`, `:`, the three panel toggles — is a **UI** action: it opens a room,
|
||
it does not change the document. `page-next`/`page-prev` resolve to
|
||
`ModelCommand::SetPageSelection`, which is `EffectKind::Model(LayoutGeometry)`.
|
||
They are the first shared keys that *mutate*, and under the Help overlay that is
|
||
a mutation the user cannot see, because the overlay is covering the grid it
|
||
just moved.
|
||
|
||
So the chain is three deep:
|
||
|
||
```
|
||
global ? : and the panel toggles — every mode
|
||
└── document [ ] — Normal, the three
|
||
├── Normal panels, TileSelect; RecordsNormal
|
||
│ └── RecordsNormal through Normal
|
||
├── FormulaPanel / CategoryPanel / ViewPanel
|
||
└── TileSelect
|
||
├── Help global only
|
||
├── ImportWizard global only
|
||
└── the text-entry modes and SearchMode, unchanged
|
||
```
|
||
|
||
**The name is the guard.** Nothing in the code stops the next person parenting
|
||
Help to this layer; what stops them is that it is called `document_keymap` and
|
||
not `common` or `shared`, so its name answers "should my new mode inherit it?"
|
||
A name describing *who* inherits a layer invites everything to join, and the
|
||
distinction dies within two waves.
|
||
|
||
**RecordsNormal inherits it once, through Normal.** It already parents Normal,
|
||
so it reaches the layer transitively; parenting it directly as well would be a
|
||
second edge with nothing keeping the two in step. That is asserted on the chain
|
||
identity rather than on behaviour, because a duplicated edge answers `page-next`
|
||
correctly on the day it is added and only diverges later.
|
||
|
||
**The layer owes the same Char-only constraint as the global**, and for the
|
||
identical reason: it is reached by *fallthrough*, so a non-Char binding on it
|
||
would fire while a descendant's `AnyChar` was typing. No descendant binds
|
||
`AnyChar` today — which is exactly why the constraint is written down as a test
|
||
rather than left to the arrangement. An unconstrained shared layer is where the
|
||
next "just put it in the shared one" lands.
|
||
|
||
**Two things that cost nothing and were checked anyway.** Text entry is
|
||
untouched: the fallback order is `exact → Char(NONE) → AnyChar → Any → parent`,
|
||
so a text-entry mode's `AnyChar` answers `[` before any parent is consulted, and
|
||
typing a bracket into a category name still types it. And `prefix_keymaps_are_flat`
|
||
is about the *sub-keymaps a prefix opens*, not about inheritance depth, so a
|
||
third level of parents violates nothing it asserts.
|
||
|
||
The browser follows for free: `resolve.rs` dispatches on the command **name**,
|
||
so every mode that now reaches `page-next` composes it in a browser too, with no
|
||
second table to update. That is the whole point of one shared keymap.
|
||
|
||
### `TextEntry` — the spine, welded
|
||
|
||
Every text-entry mode has the same spine: characters append to one named
|
||
buffer, Backspace pops from it, Esc clears it before leaving. Only the buffer,
|
||
the exit mode and the commit keys differ. Building them through here means a
|
||
new text mode cannot accidentally type into one buffer while clearing another,
|
||
or drop the clear entirely — which would leave the next entry pre-filled with
|
||
the last one.
|
||
|
||
`TextEntry::new` takes the `AppMode` and reads the buffer name off its
|
||
`MinibufferConfig`, so the buffer the prompt describes and the buffer the keys
|
||
edit are the same string **by construction** rather than by agreement
|
||
(improvise-k8h) — the keymap never spells a buffer name.
|
||
|
||
`commit(key, cmd, args)` welds the command to its `clear-buffer` so no caller
|
||
can bind a commit without its clear (improvise-w3q).
|
||
|
||
**The two cell-editing commit keys are the exception, and the clear moved rather
|
||
than disappeared** (improvise-fy9o). `commit-cell-edit` and
|
||
`commit-and-advance-right` reopen an editor on the cell they advance to, and the
|
||
welded clear ran **after** them, so it blanked the re-seeded buffer. The
|
||
reopened editor looked empty, and a second Enter committed `""` and cleared the
|
||
landed cell. The user's ruling is that the reopened editor shows the landed
|
||
cell's current value, so with improvise-ixxx's untouched rule an unchanged Enter
|
||
writes nothing. `commit_reopening(key, cmd, args)` therefore binds the command
|
||
alone, and the command clears its own buffer **at its head**:
|
||
- in the TUI, `CommitAndAdvance` does it right after the mode change;
|
||
- in the browser, `resolve::cleared` does it, reached from both `commit` and
|
||
`create_column`.
|
||
|
||
Being at the head is also gotcha 21's rule: a head clear survives an abort
|
||
where the trailing weld did not. The corner commit and the `+cat` insertion
|
||
reopen nothing, and they still leave no text behind.
|
||
|
||
**After a write, the browser's reopened editor takes its seed from the host's
|
||
answer, not from its cache** (improvise-3uzg). The first fix (improvise-3tt4)
|
||
seeded from the cache everywhere except the cell the frame had just written.
|
||
That assumed the write changed the meaning of only that one cell, which is
|
||
false. Under `prune_empty`, emptying the last value in a row takes the row
|
||
away. The advance then lands on a renumbered position, and the cache still
|
||
holds the old record there. The browser opened on South's `2` while the TUI
|
||
opened on West's `3`, and typing `5` wrote 25 into West. Any write can change
|
||
what another position means, and the client cannot tell which. So the rule is
|
||
stated once, over the write, never over a list of cases:
|
||
- **A commit that writes** reopens its editor as `OpenedEditor::awaiting()`, an
|
||
empty buffer whose seed is still to come. `create_column` is included,
|
||
because `AddItem` adds a column.
|
||
- **`ClientState::settle_awaited_seed`** fills the seed from the grid the
|
||
client holds, but only once no frame it sent is still unanswered. The answer
|
||
folds the host's projection before `Accepted`, so the grid is the host's by
|
||
then. `accepted()` therefore settles.
|
||
- **A commit that writes nothing** awaits as well while any earlier frame's
|
||
write is unanswered. Otherwise it seeds from the cache at once. "Nothing
|
||
changed, so the cache is current" was the rule here, and it was false
|
||
(improvise-mdfc). *This* commit changed nothing, but an earlier one might
|
||
have, and the cache predates it until the answer arrives. Tab Tab pressed
|
||
quickly at the rightmost column opened the second editor on the value from
|
||
before the first Tab's write: 505 where the TUI stores 5095. So the test is
|
||
"does this frame write, or is a write still in flight", which is
|
||
`writes || in_flight.iter().any(|sent| sent.awaited)`. It checks *any*
|
||
in-flight write, not the newest frame: a frame that writes nothing stays in
|
||
the queue behind an unanswered write, so the newest frame can be one of
|
||
those.
|
||
- **Every opener asks the in-flight half, because it lives in `opened_on`**
|
||
(improvise-p6v6). mdfc first put the whole test inside `advanced`, which left
|
||
`i`/`a` (`edit_or_drill`) seeding from the cache with no in-flight check.
|
||
`i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the
|
||
browser where the TUI stores 195. The two halves now live in different
|
||
places, for a reason. "A write is still in flight" is true of the *client*,
|
||
so `opened_on` asks it, and no opener can forget. "This frame writes" is
|
||
true only of the commit being resolved, which is not in `in_flight` yet, so
|
||
`advanced` still asks it itself. Folding both into `opened_on` would lose the
|
||
second: the 3tt4 and 3uzg tests fail when `advanced` defers entirely.
|
||
The click-edit path (`socket.rs::edit_target_at`, improvise-ugj4) also seeds
|
||
from the cache, but it claims the record at the version it *read*. Behind an
|
||
unanswered write the host therefore refuses its commit as `RecordMoved`: a
|
||
loud failure, not a silent overwrite.
|
||
- **The seed settles only into an open editor** (improvise-4cb5). An Esc typed
|
||
before the answer arrives leaves the awaiting `opened_editor` in place. The
|
||
late seed would then fill the edit buffer in `Normal` mode, where the TUI's is
|
||
empty.
|
||
- **`awaiting` has no `serde(default)`** (improvise-mgis), for the reason
|
||
`AxisFrame::first` has none (§3a, improvise-1wl0). `awaiting: false` is a
|
||
claim, "the seed is settled". A peer that dropped the field would hand over an
|
||
editor that stays empty forever, and whose Enter writes nothing. A missing
|
||
field is therefore a parse failure.
|
||
|
||
Asking the host to send the seed was the alternative. It would have needed a
|
||
new message, and the host already sends it: the landed cell's `edit_text` in
|
||
the projection it owes this client anyway. Two things are accepted costs:
|
||
- Until the answer arrives, the editor is empty. Over a worker that takes
|
||
well under a frame.
|
||
- A key typed before the answer arrives marks the editor touched, and the late
|
||
seed then does not overwrite it. What the user sees is then what gets
|
||
written. That is never another record's number only because of the mdfc
|
||
rule above: before it, an untouched commit behind an unanswered write opened
|
||
on the stale cache, and typing then extended the wrong number. What is left
|
||
is a divergence from the TUI rather than a wrong record. A key that beats the
|
||
answer replaces the value the TUI would have extended: back to back,
|
||
`i 9 Tab Tab 5 Enter` stores 5 or 5095 in Chromium over the worker
|
||
(improvise-n4i9).
|
||
|
||
The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor`
|
||
already reads the renumbered grid (gotcha 23). This also took
|
||
`CellValue::edit_text` out of the client bundle. That call formats an `f64`,
|
||
which is what compiled to `i64.trunc_sat_f64_s`, so `a093d126`'s
|
||
`nontrapping-float-to-int` flag on the wasm-client is reverted. A `?ws=` page
|
||
loads only the client bundle, so it works without the feature.
|
||
|
||
Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four
|
||
readers take a `Sequence`'s **first** step as its identity: `keymap::runs`,
|
||
`help.rs`, and two resolver audits. So every hint on those keys started
|
||
reporting `clear-buffer`. `text_entry_modes_clear_the_buffer_they_type_into`
|
||
keeps its guarantee by asking a plain-`Cmd` commit key's command what it emits.
|
||
|
||
`rebind` exists for
|
||
exactly one case: CommandMode's Backspace, which exits the mode on an empty
|
||
`:` line instead of popping a character that isn't there.
|
||
|
||
**Eight `AppMode` variants carry a `MinibufferConfig`**, and `buffer_of`'s
|
||
callers are all eight. Since improvise-636d there is **one** constructor —
|
||
`TextEntry::new(mode, exit, parent)` — because every text-entry mode now has a
|
||
parent: seven take the global directly and `RecordsEditing` takes `Editing`'s
|
||
map, which reaches the global through it. `with_parent` is gone, and with it
|
||
the "a count off the `::new` call sites reads seven" trap this paragraph used
|
||
to warn about: the call sites are all eight.
|
||
|
||
`SearchMode` is hand-written on purpose and does **not** use `TextEntry`: it
|
||
has no commit key, and its query is owned by `exit-search-mode` rather than by
|
||
`clear-buffer`. "search" is not in the `buffers` map but its own `ViewState`
|
||
field, and `cmd::core::read_buffer`/`write_buffer` are the only code that
|
||
knows it (improvise-gdl).
|
||
|
||
Category and item add stay open after a commit, so Tab and Enter do the same
|
||
thing: file this one and start the next. `ItemAdd`'s keymap is built with an
|
||
empty category name because the category only shapes the prompt text, never
|
||
the buffer.
|
||
|
||
### `PanelKeymap`
|
||
|
||
The three panels share a spine — Esc/Tab leave for Normal, arrows and j/k move
|
||
that panel's cursor. The keys that act on the panel's *contents* differ in
|
||
**shape**, not just in command name (Category's `n` takes an argument, View has
|
||
no `a`, Formula has no Enter), so they arrive through chained methods rather
|
||
than a fixed-arity signature (improvise-4pg). `delete()` binds `d` and Delete
|
||
together so a panel cannot offer one without the other.
|
||
|
||
The toggle keys and `:` are **no longer part of this spine**: they are the
|
||
global's, so `PanelKeymap::new` takes the parent and binds neither.
|
||
`bind_panel_toggles` has exactly one call site — `global_keymap` — and a new
|
||
panel reaches all seventeen keymaps from one `Panel::ALL` entry rather than
|
||
four.
|
||
|
||
### Normal-mode bindings worth a note
|
||
|
||
`i`/`a` pass their target editing mode as an **argument** to `edit-or-drill`,
|
||
so the command itself stays mode-agnostic; `RecordsNormal` overrides just
|
||
those two bindings to say `records-editing`. Ctrl-S saves to the known path
|
||
and Ctrl-W asks for one — the pair a user with no file path needs, and what
|
||
the "No file path" status message points at (improvise-lae).
|
||
|
||
---
|
||
|
||
## 7. `viewmodel.rs` — the residue between a cache and a renderer
|
||
|
||
improvise-edp asked for a ViewModel *layer*, a `SurfaceViewModel` paired with
|
||
every `SurfaceCache`. **Most of that layer turned out not to exist**: after
|
||
improvise-35e each widget already takes exactly one cache plus the mode, and a
|
||
cache built per-widget is already render-shaped, so there is nothing left to
|
||
adapt. What this module holds is the residue — derivations genuinely *between*
|
||
a cache and a renderer, because they mix a cache with per-client `ViewState`.
|
||
|
||
Today that is exactly one: `flatten_cat_tree`. It lives here rather than in
|
||
`improvise-command` because two ends need it and only one of them can reach
|
||
that crate — the browser client depends on `improvise-protocol` alone
|
||
(deliberately; see its `Cargo.toml`), and `move-panel-cursor` walks this
|
||
flattening, so resolving that key client-side (improvise-bpq2) would mean
|
||
re-deriving it. **Two derivations of one flattening is how the two ends
|
||
drift.**
|
||
|
||
`CatTreeEntry` carries the axis outright, so a renderer needs neither a `View`
|
||
nor the cache the entry came from: the entry list *is* what the panel draws.
|
||
`expanded` is `ViewState::expanded_cats` — per-client, which is why this is
|
||
derived here and not sent — and the index into the result is
|
||
`ViewState::cat_panel_cursor`.
|
||
|
||
---
|
||
|
||
## 8. `key.rs`
|
||
|
||
These mirror the subset of the terminal backend's key API the command, keymap
|
||
and effect layers actually use, so those layers carry no terminal-backend
|
||
dependency. Variant names match crossterm's so call sites only need an import
|
||
change. The only place both type families may appear is the TUI boundary
|
||
(`src/draw.rs`), which converts incoming backend events into these before
|
||
calling `App::handle_key`.
|
||
|
||
`KeyCode::Null` stands in for "no key" (headless command execution) and for
|
||
any backend key code this crate does not model. `KeyEvent` is **not**
|
||
`Serialize` — `ClientFrame::Key` and `Command::SetTransientPrefix` both carry
|
||
the `(KeyCode, KeyModifiers)` pair instead.
|
||
|
||
---
|
||
|
||
## 9. Corrections made while relocating this prose (2026-09-20)
|
||
|
||
Four comments in this crate were **wrong about the code** and were fixed
|
||
rather than moved. Recorded because the campaign's own evidence is that a
|
||
stale argument reads exactly like a live one:
|
||
|
||
1. **`frame.rs`, `ClientFrame::Key`** said "the host runs it through the
|
||
keymap against the authoritative `App`". `Host::handle` returns an empty
|
||
vec for that arm. This is the fourth sibling of gotcha 48's "three comments
|
||
in one range said *not built yet*" — improvise-bpq2 moved resolution to the
|
||
client and updated one copy of the argument, not all of them.
|
||
2. **`reduce.rs`, `every_command_round_trips_through_json`** said the model
|
||
half brings "four payload types the view half never carried — `CellKey`,
|
||
`CellValue`, `Axis` and `PathBuf`". `ModelCommand` carries no `CellKey` at
|
||
all since ADR 002 (`SetCell` takes a `RecordCell`), and `CellValue` *is* in
|
||
the view half, as `Command::SetYanked`.
|
||
3. **`render.rs`, `identified_grid`** carried three paragraphs of doc that
|
||
describe a *different* test — "improvise-hkt6 … three columns and a rename
|
||
in the middle" is `a_relabelled_column_drops_its_total`, which had no doc
|
||
of its own, while `identified_grid` is a two-column fixture. The paragraphs
|
||
had been merged onto the wrong item.
|
||
4. **`keymap.rs`, `buffer_of`** said "the seven modes that carry a
|
||
`MinibufferConfig`". Eight do, since `SavePrompt` (improvise-lae); seven is
|
||
the number of `TextEntry::new` call sites, which is the exact confusion
|
||
repo-map gotcha 11 already warns about.
|
||
|
||
Also stale and corrected, without being wrong enough to list above:
|
||
`key.rs`'s module doc called these "crate-local key types for the command
|
||
layer" — they are the protocol's, and both front ends use them.
|
||
|
||
|
||
**`records_named` lives here** (moved from `improvise-command::ui::reduce` by
|
||
the review of PR #50). It is `&ModelCommand -> Vec<RecordHash>` with no `App`
|
||
in it, and both ends need the same answer: the server to decide which records
|
||
to check, a client to decide which to claim. Two copies made the client's half
|
||
untestable — `lockstep.rs` restates the basis against its twin host, so it
|
||
compares the server's answer with itself. One home makes the claim right by
|
||
construction, and the exhaustive match is the guard it always was: a new
|
||
record-writing variant fails the build.
|
||
|
||
---
|
||
|
||
## §8 `help.rs` — where UI strings live relative to a wire enum
|
||
|
||
Three tickets asked one question three times — improvise-x7sw (which-key hints
|
||
have no protocol home), improvise-ktv1 (the browser has no help overlay) and
|
||
improvise-k099 (`AppMode::hint_text`/`label` put terminal UI strings on a serde
|
||
enum). Answering it three times is the disease. The answer:
|
||
|
||
**A UI string is keyed by a command name, never by a key, and never hangs off
|
||
the enum that crosses the wire.** `AppMode::label` and `AppMode::hint_text` are
|
||
gone; `help::mode_label` and `help::hint_line` replace them. The enum a client
|
||
deserializes now carries no English at all.
|
||
|
||
**And wherever a string names a key, the key is looked up rather than spelled.**
|
||
`Hint { keys, command, caption }` is the unit: `keys` come off
|
||
`Keymaps::default_keymaps()`, `command` is the semantic value a localized or
|
||
non-terminal front end keys off, and `caption` is the default English — exactly
|
||
the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the
|
||
last translation local (`invariant(semantic_palette_at_the_core_boundary)`).
|
||
|
||
### What that bought, and what it cost
|
||
|
||
It **deleted** `every_hint_key_resolves_in_its_mode` and its 60-line tokenizer.
|
||
That test existed to hold a hand-written string honest against the keymap; the
|
||
string is derived now, so the property is true by construction. A consolidation
|
||
that keeps its compatibility test is unfinished
|
||
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`),
|
||
and this is the deletion that measures this one. What replaced it,
|
||
`every_advertised_hint_names_a_binding_that_exists`, checks the *other*
|
||
direction — a mode advertising a command nothing binds — which was never
|
||
checkable before.
|
||
|
||
It also deleted `Keymap::binding_hints`: `help::which_key` is the one walker,
|
||
and the binding dump reads it too rather than a second one.
|
||
|
||
The cost is that the hints changed shape. They are longer, they name arrow keys
|
||
beside `hjkl`, and two sub-lists in the help pages lost their visual indent.
|
||
That is the price of the keys being true.
|
||
|
||
### The three rules the rendering follows
|
||
|
||
1. `hint_line` **drops chorded keys** — any key held with a modifier beyond
|
||
shift, asked of the **modifiers** and never of the label (improvise-thpe).
|
||
`Hint::keys` is `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so
|
||
the string it used to parse is not in scope to parse; `Hint::labels` and the
|
||
help pages format last, through the one spelling in `keymap::key_label`.
|
||
`T` advertises
|
||
tile select; its four Ctrl-arrow aliases would push the rest of the line off
|
||
the screen and teach nobody anything. A client wanting all of them reads
|
||
`hints()`, which keeps them.
|
||
2. It **runs single-character keys together** (`hjkl`) and slashes the rest
|
||
(`Esc/Tab`), which is the idiom the hand-written hints already used.
|
||
3. A help-page row whose command resolves to **no keys is dropped**, so a page
|
||
can never advertise a key that does not exist. `keys()` returning `Option`
|
||
is what makes that unrepresentable rather than validated.
|
||
|
||
### What is *not* derived, and why
|
||
|
||
**The wizard's.** `AppMode::ImportWizard`'s whole keymap is one `Any` binding
|
||
into `HandleWizardKey`, which decides per step inside the command — so there is
|
||
nothing in the table to look up, and its hint is a hand-written tail. That is a
|
||
real hole and it is where it is visible, not papered over: a keymap cannot
|
||
describe keys a command resolves privately.
|
||
|
||
**The ex-commands.** `:q`/`:w` are parsed text rather than keys, so they are
|
||
`HelpLine::Literal` — built only by the `ex!` macro, whose leading colon is its
|
||
own — and the parser, not the keymap, is what checks them
|
||
(`every_ex_command_the_help_pages_show_is_one_the_parser_accepts`).
|
||
|
||
This paragraph used to say the same of `gg`, `yy` and `ZZ`: "a prefix sequence
|
||
is two addresses and not one binding". Two addresses is true and was no reason
|
||
to spell them by hand — `Keymap::prefixed_keys_running` looks the pair up the
|
||
way `keys_running` looks up one key, and `keys()` asks both (improvise-wk18). A
|
||
key string with no command name was also the one row no guard could reach.
|
||
|
||
**Every row the pages ask for is visible to a guard.** The page builders take
|
||
the `keys` lookup as a parameter; `pages()` passes the real one, and
|
||
`help_bindings()` (tests only) passes one that records `(mode, command, args,
|
||
description)`. So a check over the help overlay reads the *same builders* the
|
||
overlay is drawn from, never a list kept beside them. That premise is a type,
|
||
not a convention (improvise-v29m): `HelpLine::Keys` carries a `KeyRow` whose
|
||
only constructor does the lookup, so a builder cannot type a key row the
|
||
recorder would never see; and `ex!` refuses at compile time any usage with a
|
||
bare word after the command name, so an ex row cannot smuggle a key either.
|
||
`resolve::tests::every_advertised_command_answers_in_its_own_mode` walks it
|
||
beside `advertised`, which is how the browser's black holes on the help page
|
||
became visible (improvise-uln5).
|
||
|
||
**The captions.** `i:edit` where `i` entered records mode would still pass most
|
||
tests here: a caption is English beside a command name and only a reader judges
|
||
it — the same limit gotcha 14 recorded for the old hint test. Two checks now
|
||
bite at its edges. The browser guard requires an advertised key to *act*, so a
|
||
key whose caption promises an action and which only refuses must be named
|
||
(improvise-3t5x); and `only_the_edit_key_is_captioned_as_editing_the_cell` asks
|
||
by command that no other Normal row claims to edit (improvise-jhdf). A false
|
||
caption in words neither looks for still passes.
|
||
|
||
### The browser half
|
||
|
||
`RenderModel` carries `help`, `help_page` and `help_titles`, and an **empty
|
||
`help` is what says the overlay is closed** — the page asks no question about
|
||
the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints
|
||
rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost
|
||
~310 lines of content that is now shared. Neither end restates what the keys
|
||
are.
|
||
|
||
## 10. `face.rs` — appearance named by role (ADR 004)
|
||
|
||
ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section
|
||
is where its first two migration steps landed and what they settled that the
|
||
ADR left open (improvise-nn4s.1, improvise-nn4s.2).
|
||
|
||
### What is here
|
||
|
||
- `Face`: one variant per role. Where a domain enum already exists the face is
|
||
**parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`,
|
||
`Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`,
|
||
`Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by
|
||
construction (clause 2). `Face::name` spells each one as a theme file keys it
|
||
(`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those
|
||
names are a compatibility surface once a theme can be downloaded (clause 9).
|
||
- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated
|
||
by the theme**. A theme that omits a face must still get the face's parent's
|
||
values ("a theme written today keeps working when a face is added tomorrow",
|
||
clause 9), and that is only true if the chain does not depend on what the
|
||
theme says.
|
||
- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`,
|
||
and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and
|
||
`Some(Colour::Default)` states "the surface" and stops inheritance. The two
|
||
are different answers and the type keeps them apart.
|
||
- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the
|
||
chain once and returns every attribute concrete. **No front end walks the
|
||
chain** (clause 3). `resolve` takes a face and nothing else.
|
||
- `Theme::shipped()`: today's terminal rendering, palette-relative throughout
|
||
(clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line.
|
||
- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both
|
||
exhaustive over `AppMode`, so a new mode fails the build here as it used to
|
||
in `draw.rs::mode_style` (improvise-zx8t).
|
||
|
||
### The JSON shape
|
||
|
||
Face names are the keys and a colour is spelled for a person: `"default"`, a
|
||
slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`),
|
||
`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not
|
||
ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown
|
||
name): a file the user edits by hand should say where it is wrong rather than
|
||
silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s
|
||
derived order, so a re-download after an edit is a reviewable diff (clause 9).
|
||
The protocol crate derives and the front ends encode, so `serde_json` stays a
|
||
dev-dependency here.
|
||
|
||
### Choices the ADR did not make, made here
|
||
|
||
- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and
|
||
the shipped theme pays for it.** The mode indicator is black text on an
|
||
accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under
|
||
this chain every `minibuffer.<entry>` in the shipped theme states all three of
|
||
its attributes and inherits none of them from its parent. A first version gave
|
||
them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a
|
||
published parent is compatibility behaviour, so a theme written to the
|
||
accepted ADR that restyles `mode.text-entry` would have silently missed every
|
||
prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the
|
||
accepted chain. Changing it is the user's call and belongs in the ADR, not in
|
||
this file.
|
||
- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition
|
||
covers the thirteen DarkGray modes and the text-entry chain, and does not
|
||
place `TileSelect`.
|
||
- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve
|
||
`fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is
|
||
not a no-op on a `TestBackend` buffer, and clause 8 names only one exception.
|
||
So it waits for improvise-nn4s.6 and a ruling.
|
||
- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they
|
||
are the browser's substitute for a terminal palette (clause 4), and the first
|
||
consumer is improvise-nn4s.5. Adding them then is a field with a serde
|
||
default, not a format break.
|
||
- **Only the faces some call site draws with exist.** The ADR's full vocabulary
|
||
(`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the
|
||
call sites that use it, because a face with no caller is a published name
|
||
with no evidence behind it.
|
||
|
||
### The category-add exception
|
||
|
||
`minibuffer.category-add` resolves to green in the shipped theme, where the old
|
||
literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it
|
||
is clause 8's one declared exception. `AppMode::category_add` now carries
|
||
`MinibufColor::Green` too, so the browser, which still reads the wire colour
|
||
until improvise-nn4s.4, agrees with the TUI rather than drifting from it.
|