The toast hid every status after six seconds, so a refusal the user was
still fixing vanished with its prompt open: su4u's bug on a timer. The
browser now asks the TUI's question. EditorSession moves into
improvise-protocol, ClientState records which editor a status was
written in, and RenderModel::status_stands tells the toast to hold it
until that editor closes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
tt22 removed the formula crate's serde dependency with the legacy JSON loader,
keeping serde only where a type crosses the wire. FormulaId crosses the wire, so
it is the third such type. No derive goes back on the formula AST.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-oplp: EnterTileSelect and the browser's resolver both opened the
tile cursor at 0, which is _Index, so `T Enter` or `T r` put _Index alone
on Row and emptied a pivot grid; the browser worker then persisted it.
The rule is improvise_protocol::opening_tile, over &[TileEntry], called by
both ends. TileEntry gains `regular`, filled host-side from CategoryKind in
project::tiles_of, because a thin client cannot tell _Index from a user
category without sniffing names. _Index/_Dim stay tileable (#192).
A lockstep case presses `T Enter` on both ends and pins which category
moved; it was red at base (_Index on Row), and fails for a TUI-only revert
and for a `regular` filled true everywhere. contract.spec.js counted three
ArrowRights from tile 0 and now asserts `T` alone selects Product.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-7epn: two cases on the one-tick-per-read clock. One resolves on
poll 3, and one times out after polling up to the deadline. Replacing the
post-poll deadline check with `break` now fails exactly those two
(23 passed, 2 failed), where before it left the suite 23/23.
improvise-lnsh: when `cargo metadata --locked` refuses the opfs-contract
lock, build.sh now says not to remove --locked and names
`cargo update -w --manifest-path harness/opfs-contract/Cargo.toml`.
Checked by hand against a lock with pest pinned to 2.9.1. Evidence is in
context/test-evidence/tooling.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-5xbq: resolve::page_rows is now the one home of the page-scroll
rule; the TUI's Move calls it instead of repeating visible_rows * 3 / 4.
A lockstep case pages (PgDn/PgUp, Ctrl-d/Ctrl-u) on a non-square window,
8 rows by 3 columns, and pins the TUI's landing rows as well as comparing
host and client, since one home moves both ends together. Both faults
the ticket injected (page_rows = window_rows / 2; paging by window_cols)
now fail at the first PgDn, before and after the move to one home.
improvise-1c5m: the help overlay draws the mode's own hint (hint_line,
the string #hint carries) in a sticky footer, so a narrow window whose
app bar wraps under #help still says how to page. Playwright case at
600px, red at base, and red again with the footer made non-sticky.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Enter or e in the formula panel opens FormulaEdit on the formula under the
cursor, seeded with its text. The editor names the formula by a FormulaId
captured when it opens, and commits through a new ReplaceFormula effect and
ModelCommand. The edit keeps the formula's position, renames it in place when
the target changes, and refuses out loud if the formula was removed or
rewritten meanwhile, if the new target belongs to another formula, or if the
text does not parse. A refusal leaves the editor open over the user's text.
FormulaCache now carries each formula's id, so the browser composes the same
edit, and a formula-row click opens the editor on the clicked row.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-qnpi: `pr-reviews --wait` checked its deadline before the first
re-poll, so a second boundary between two reads of the clock skipped the
only poll (exit 1 with polls=1, reproduced under CPU load). The deadline
is now checked after each poll. A new case drives it with a PATH shim
whose `date` ticks one second per read, so it fails deterministically on
the old loop rather than under load.
improvise-mkbp: harness/opfs-contract/Cargo.lock pinned pest 2.9.1 while
its path deps require 2.9.2, so every build rewrote it. The resolved lock
is committed and build.sh starts with `cargo metadata --locked`; the
wasm-pack `-- --locked` route was tried and does not work (wasm-pack
re-resolves first). opfs.spec.js now names the fix when harness/serve/opfs
is missing, which is what running web/assemble.sh alone leaves.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The worker transport wrote "in-page worker · <doc>" when it spawned the
worker, before anything had answered, so a tab whose worker could not start
showed a success-looking status over a blank page. It now writes "starting
the worker · <doc>…", and createShell's arm() takes an `answer` that sawFrame
writes on the first frame, but only while the watchdog is still armed. A
ReadOnly or SeedMissing notice lands BEFORE the Snapshot, and fail() has
already disarmed, so the claim cannot overwrite the precise reason.
The two-tab spec now logs every #status and denies both the premature claim
and "did not answer" before the page clock is walked. A new spec pins the
exact status sequence on the writable path. Reasoning:
context/code/clients.md, "The watchdog".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
grouped.spec.js asserted a collapsed group contributes no rows (8 data rows).
It now contributes one per Store (10), labelled Upland, and its cells are
not editable. The editable assertion was checked against cell_id without
folds_a_group: it fails with 6 editable cells where 0 are expected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
views/formulas were empty-means-closed, so an open panel over a document
with no formulas built the same RenderModel as a closed one. Option<Vec<_>>
carries both facts in one field; the page hides on null.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
In the TUI, EditOrDrill hands an aggregated cell to DrillIntoCell, so `i`
on an aggregate is drill by another door. The help page called it only
"Edit current cell", and the browser refused it in words unrelated to `>`'s,
so a user could not tell the two were one missing feature.
- help.rs: the edit-or-drill row says it drills into an aggregate.
- help.rs: Normal-mode Enter was captioned "Edit current cell (same as i)"
but is bound to enter-advance alone, which only moves down; the row moves
to Cursor movement and says so.
- resolve.rs: the Aggregate arm now reads "an aggregate is edited by
drilling in — " + not_yet("drill"), the same sentence `>` uses.
- context/code/clients.md and the evidence entry reverse the recorded
"different sentences on purpose" rationale, which was false about the TUI.
- harness/tests/drill.spec.js follows the new wording.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The report was "a narrow window clips grid columns without usable horizontal
scroll", and the ticket suspected a flex `min-width: auto` trap swallowing
`#grid`'s overflow. It was not that: `#grid` has `overflow: auto`, which
already zeroes a flex item's automatic minimum. What could not shrink was the
app bar.
Measured at 22ab58f on the grouped harness fixture, `documentElement.scrollWidth`
read 1011 at every window narrower than 1011px — a floor, not a slope, which is
the signature of a sum of `flex: none` reserves. Below it the PAGE grew a
horizontal scrollbar, and what it scrolled to was the bar's own right-hand end
sitting outside the window, while the grid slid off to the left.
`header` takes `flex-wrap: wrap`, chosen over a `@media` breakpoint because a
flex item wraps exactly when the line cannot hold it, so the desktop bar is
untouched by construction rather than by a number someone picked. `main` gets
the `min-width: 0` the ticket asked about, and the three side panels are capped
at 40vw so the grid keeps the larger share of a phone window.
What this deliberately does NOT do: make `#grid` scroll to columns beyond the
announced extent. The browser's Viewport is a real window (gotcha 44/47), so
those columns are never projected and no scrolling can reveal them; the
keyboard already reaches every one, and a spec now asserts that.
`harness/tests/narrow.spec.js` asserts `scrollWidth <= clientWidth`, which is a
question a browser answers exactly. `body { overflow-x: hidden }` was rejected
for making that assertion vacuous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The rule is the deliverable: nothing a user expects to be fixed may move.
Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.
Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.
The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.
ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.
Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.
Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`harness/tests/pages.spec.js` arrived on `main` with #123 while this branch was
open, and asserts `toHaveText("20,300")` against `web/demo.improv`. The merge
was clean and the combination was wrong — design-principles §7's third case
exactly: `git merge-tree` answers "can git combine these texts", not "does the
combination do what both sides meant". CI caught it on the merge ref, which is
the only place the two sides had ever been run together.
The assertion is unchanged in what it discriminates: it exists so that a menu
moving only the label fails, and `"20,300.00"` separates Q4 from Q1's
`"18,400.00"` exactly as the old literal did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The menu's early return sat ABOVE `app.js`'s `metaKey` guard, so with the menu
open `Cmd-R` reached `menuKey`, was answered `ignore`, and was then defaulted
away by `if (!e.ctrlKey && !e.altKey) e.preventDefault()` — true for Meta.
Reload was blocked. `Ctrl-j`/`Alt-Home` had the mirror fault and MOVED the menu,
two lines below a comment saying Ctrl and Alt stay the browser's.
Ordering plus one bail: the `metaKey` guard goes back above the menu branch,
`pageMenuKeydown` returns on `ctrlKey || altKey`, and what remains claims
unconditionally — every unmodified key, `ignore` included, which is the rule the
menu exists to keep.
The harness assertion installs its `defaultPrevented` probe AFTER load so it
runs second and sees what the page decided, and ends on an unmodified `j`
asserting `["j", true]` — without which "nothing was prevented" is satisfied by
a page that claims nothing. Proved by restoring the reviewed code: it fails on
`Control+j` moving the active row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.
Two halves, per the user's ruling:
* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
noise on whole numbers is the cheaper error — a currency column with two
redundant zeroes is legible, a ratio with none is wrong — and a format cannot
be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
keeps the view's. `formula.pest` gains
`format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
reason (a predicate is invisible to the grammar-walking generator) and
`FORMAT` added to `quote::KEYWORDS`.
`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.
Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.
Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.
The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Three tickets on one subject: paging.
improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.
improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.
improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.
Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
- `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
binding. Composing the command anywhere else would put a derivation in the
page, which design-principles §5 forbids; `Command`/`ModelCommand` are
untouched.
- `improvise-command/src/command/keymap.rs` gains three tests. The two
existing guards were the mechanism that forced the wiew argument, so they
had to be answered where they live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Found by reviewing the restyling before pushing it, and demonstrated before
it was fixed.
`td.error` and `td:not(.editable)` used to set DIFFERENT properties — colour
and opacity — so both applied and a dimmed error cell was a dimmed red. The
restyling made the dimming a colour too, at the same specificity and below
`td.error` in the file, so an error in an aggregate cell rendered plain grey:
rgb(107, 103, 95) where the error colour is rgb(168, 50, 45). `td.selected`
and `td.typing` did the same by setting `color: var(--fg)`.
`#grid td.error` is the only place on this page where colour carries MEANING
rather than emphasis, and it is the one a reader takes at face value: a
circular reference or a `mixed` aggregate reading as an ordinary number is
worse than it reading as nothing.
So the dimming moves ABOVE the semantic colours, and a selection is a ring
and a tint and never a colour. That is the class of defect a stylesheet has
and a program does not — two rules that were independent become one property,
and nothing in any language fails.
Also: an expanded notice now caps at 40vh and scrolls. `main` is free to
shrink to nothing, so on a short viewport opening it could have pushed the
grid out entirely.
The test is aimed at the STYLESHEET rather than at a document, because the
question is which rule wins and a fixture per combination would be three
documents testing three documents. It appends a <td> carrying each
combination to the real page's real table and compares two controls taken the
same way, rather than pinning absolute colours.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
Three things the user reported after looking at the deployed page, in the
order they cost a visitor something.
The deployment notice was ten dense lines of configuration reference —
?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the
category tree and the grid on every visit: about 230px of an 820px window.
It is now a <details>, so the collapsing is the BROWSER's (keyboard
operable, and correct with app.js absent or still loading) and the only
thing the page adds is remembering the answer. It starts COLLAPSED, and
collapsing withholds nothing: the one fact a first visitor needs — the
document lives in this tab, is saved to this browser's private storage and
never leaves the machine — is the <summary>, which never collapses.
What a stored value means is a decision and lives in shell.js; touching
localStorage is a browser API and stays in app.js, wrapped, with every
value that is not one of the two spellings read as "unanswered".
The minibuffer was a 14px line in the page's bottom-left corner, up to
800px from the cell being typed into. It is now a raised bar over the
grid. The CONTENT is untouched — RenderMinibuffer is built from
`state.view.mode.minibuffer()`, the same semantic value the TUI draws — so
this is position, weight and contrast, and there is no second derivation of
what a prompt says. MinibufColor still resolves here exactly as
draw.rs::minibuf_fg resolves it for the terminal.
style.css stops being "legibility only": layered surfaces, one accent, a
filled badge for the mode, chips for the two axis strips, one selection
treatment, tabular numerals in the grid, and a focus ring on the one
keyboard surface the page has. The palette is the landing page's, so the
two read as one product.
Asserted (harness/tests/contract.spec.js, Chromium): the notice starts
collapsed and remembers, in BOTH directions across a reload; it opens from
the keyboard, which the page's own keydown handler must not claim; the
minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static
bottom bar cannot satisfy — and is larger than body text. Each was proved
discriminating by injecting the fault and watching it fail.
NOT asserted, and named as such in context/code/clients.md: colour,
contrast, spacing, weight, the grouped and multi-level header DOM, either
theme, and the localStorage-throws path. Those are argued and read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.
`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).
`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.
improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.
All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.
Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`harness/opfs-contract` carries its own [workspace] table, so it is not a
member of the root workspace -- deliberately, since it names
improvise_io::opfs and a member would break `cargo test --workspace`. The
consequence is that `cargo fmt --all -- --check` passes over it without
looking, so every gate I ran was honest and green while the file I had
edited was never visited.
It needs its own gate, as CI runs it:
cd harness/opfs-contract
cargo fmt -p opfs-contract -- --check
cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings
The gap is bounded: grepping for crates carrying their own [workspace]
table finds exactly this one and the root.
And a fmt failure is the FIRST step of that job, so the clippy behind it
never ran and nobody knew whether it was clean. It is -- verified locally
at this head, no warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
The send guard was written, argued in a comment and never run, which
AGENTS.md forbids: a fix needs a test that failed before it. Proven
against the pre-fix silent drop -- status stays "disconnected" and the
edit vanishes.
The first version of this test was VACUOUS and failed for the wrong
reason. It pressed ArrowDown, which on a client with no snapshot composes
no frame at all, so transport.send was never reached and the guard under
test never ran. Enter is forwarded upstream and composes a frame
regardless of client state. doctrine(an_injected_fault_must_be_the_fault
_you_named) seen from the other side: the TEST must reach the path.
The closing assertion keeps improvise-taxx intact -- the drop must still
be named first. A loud send may add "your edit did not go", never replace
"disconnected" with something vaguer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH