Commit Graph

52 Commits

Author SHA1 Message Date
Edward Langley 22bb621dd8 test(harness): close the stale-assemble hole, and decide the dropped assertion
improvise-669b. PR #65 single-homed HANDSHAKE_TIMEOUT_MS in web/shell.js, but
the home is single in the TREE: the spec imports web/shell.js and the page
loads harness/serve/app/shell.js. createShell now exposes timeoutMs() beside
timesArmed(), and outlastTheWatchdog requires the running page's value to equal
the one it imported. Injected with the ticket's recipe (8000 -> 100 in source,
no rebuild): 5 passed / 1 failed where it was previously 6/6 green.

Also in scope, the same file's stale line: HANDSHAKE_TIMEOUT_MS restates
nothing and one test outlasts it, not two.

improvise-qf57. The formula-row click's `#grid input` toHaveCount(0) stays
dropped, and now deliberately. The reviewer's argument was that the exact
`#mode` assertion subsumes it; measuring gives a better one. app.js creates a
grid <input> in exactly one place, the grid cell's own mousedown, so no panel
handler can reach it — and an invented formula-row activation opens the
MINIBUFFER, not a grid editor. Injecting ChangeMode(formula_edit()) into
click_formula_row: the toHaveCount(0) probe PASSES and the run fails on the
#mode assertion below it. Restoring it would add an assertion that cannot
fail, which context/test-evidence/clients.md already records as the reason a
sibling assert! was removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:56:52 -07:00
Edward Langley 01d48f2ce9 Merge remote-tracking branch 'gh/main' into fix/ws-origin-check
# Conflicts:
#	context/test-evidence/clients.md
2026-09-20 21:26:39 -07:00
Edward Langley cead3210e5 fix: two Origin headers state none a policy can answer for
Review of the previous commit. `HeaderMap::get` is first-wins, and the
deployment README recommends puts a reverse proxy in front of the socket — one
that APPENDS `Origin` rather than setting it leaves two, and answering from the
first lets the second be anything at all. No browser sends two, so two is not a
state any policy can answer for and the handshake is refused, exactly as an
`Origin` that is not text already was.

Guarded by two_origins_state_none_this_policy_can_answer_for, proved by
restoring the first-wins read, which fails that test alone across the
workspace. The test also asserts the allowed origin BY ITSELF is still allowed,
so the refusal is demonstrably the second header rather than the first.

Second review finding: a_handshake_from_a_foreign_origin_is_refused matched
"403" in a Display string, which cannot tell a refused upgrade from a dropped
connection. It now matches Error::Http and asserts the status. Proved by
changing forbidden() to UNAUTHORIZED, which fails it and
a_refusal_is_a_forbidden_response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:21:49 -07:00
Edward Langley d100639348 fix: check Origin at the handshake, so a page cannot drive someone's session
improvise-kc7n. A websocket handshake is exempt from the same-origin policy —
there is no CORS preflight — so any page a user visited could open a socket to
improvise-ws-server from that user's own allowed address and read and edit every
document served. An address check cannot see it: the request genuinely comes
from an allowed client, and only Origin says what is driving it. Loopback is the
least protected case, having no proxy in front of it at all.

`src/origin.rs` is the decision and `transport::admitted` its one caller, which
refuses the upgrade with a 403 rather than accepting and ignoring. The default
allows pages served from loopback — which is the shipped local recipe, page on
`http://localhost:8080` and socket on another loopback port — and refuses
everything else; `--allow-origin` names the deployed page, which reaches the
server through a TLS proxy and is not loopback. An absent Origin is allowed and
decided rather than defaulted: no browser omits it, so absent is a non-browser
client, and refusing those would break this crate's own tests while buying
nothing that --bind does not already buy.

Five edits, five guards, each reverted alone against the named test:

- origin.rs permits() present arm  -> a_foreign_origin_is_refused
- origin.rs permits() None arm     -> a_handshake_with_no_origin_is_accepted
- origin.rs is_loopback()          -> a_handshake_from_a_loopback_page_is_accepted
- transport.rs handshake callback  -> a_handshake_from_a_foreign_origin_is_refused
- main.rs --allow-origin wiring    -> allow_origin_widens_the_policy_and_nothing_else_does

`transport::serve` takes the policy rather than defaulting one, so a host cannot
forget to pass it. The argument is context/code/clients.md §Origin and the
evidence context/test-evidence/clients.md; README no longer says the server
never looks, and the proxy's Origin allow-list is now defence in depth.

This is not authentication — it answers what page is driving a client, never who
is connecting. improvise-kpnr is untouched and still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:20:18 -07:00
Edward Langley a69b0bca86 test: the third edit of improvise-0x9u's repair, and two evidence corrections
`close` restores the DRAWN cell text rather than the seed, and reverting that
one line left the whole suite green while `18400` sat where `18,400` had been
on the shipped demo. Asserted after the second click, proved by that revert.

improvise-o31u's desktop arm cannot fail for the reason its evidence gave:
both named mechanisms were measured and neither moves it (`#cast-pivot` is
966px at 1280, so an 880px floor is inert wherever it is placed). The entry
now says it is forward-looking, and credits the phone arm with the fault it
actually catches.

Plus three one-liners: UNCALLED's source-scan blindness, the gzip -9 proxy in
the improvise-cl7y table, and why `view-back` is not one of its five siblings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:15:49 -07:00
Edward Langley 9e37213cd4 fix(docs): re-record the four casts from a script, with no shell to leak
improvise-2oxt. All four casts under docs/casts/ opened and closed on
`(0) Mac:<hostname> ~gf/u/edwlan/improvise` — a real hostname and home directory
on what improvise-kh8 made the site root — and `formulas.cast` and `import.cast`
showed that prompt as their visible OPENING frame. drill.cast also predated the
records columns entirely, showing a single `Value` column where the current
build draws a column per measure plus `+cat` and `+`.

The repair is structural, not careful. `docs/record-casts.py` drives `improvise`
on a pty it owns and writes asciicast v2 itself, so `improvise` is the pty's ONLY
child and there is no shell for a prompt to leak from — nothing to remember to
clean, and nothing that comes back when somebody re-records on another machine.
Three other things stopped being accidents with it: the geometry is an input
(120x37, the numbers docs/index.html already spelled, so they did not change),
the pacing is a per-step table, and the header carries no `env` (which held
$SHELL) and no `timestamp` (the recording machine's clock).

Two decisions that each cost a re-record. No `:q` is typed at the end: the player
loops, so the last frame is also the frame before the first, and a minibuffer
reading `:q` is what it would show — the process is signalled instead. And each
scenario had to be ARRANGED so the thing it names is visible: the drill cast
drilled into a cell with no records behind it, and the pivot cast pressed `c` on
a category already on the column axis. Both recorded cleanly and demonstrated
nothing — true frames about the wrong thing — and neither was visible from the
keystroke list.

That is the method worth keeping: a cast can be replayed through a small ANSI
reconstruction and READ AS TEXT, so "what does frame N show" has an answer rather
than a guess. It is how the `+cat`/`+` columns in the new drill.cast were
confirmed present, and how both dud scenarios were caught.

The guard is `landing.spec.js`, asserted over the cast FILES: no header carries
`env` or `timestamp`, the geometry is the one the page configures, and no chunk
contains an OSC window-title sequence — the leak's signature, emitted by a shell
once per prompt with the cwd in it and by `improvise` never. Asserted over the
file rather than the rendering because a player draws a prompt as ordinary text,
so a screenshot assertion would have to know what the hostname had been. BOTH
clauses were injected separately, because the `env` one would otherwise mask the
OSC one: the old cast fails on `env`, and the old cast with `env` stripped fails
on the title sequence.

docs/index.html loses the `p.dated` note admitting the drill cast was out of
date, which is now false.

Tests: 33 harness + 3 skipped (was 28 + 3). Rust and node unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:52:24 -07:00
Edward Langley 9421eb207c fix(docs): the landing page's casts are legible on a phone
improvise-o31u. On a 390px viewport all four players computed a terminal font
size of 4.83258px — reproduced to five digits before the fix. The player DERIVES
its font size from the width it is given, and 120 columns into the ~356px inside
the page's gutters gives about 3px per character. Since improvise-kh8 this page
is the SITE ROOT, so that is the first thing a phone visitor sees.

Under `max-width: 900px` the player is allowed to overflow INSIDE `.player`
(`overflow-x: auto`, an 880px floor on its child) rather than being squeezed to
fit. Measured after: 11.9457px, matching PR #47's reverted measurement.

The instrument is the finding and is worth more than the CSS.
`getBoundingClientRect` reports a height for a `content-visibility`-hidden child
and heading text overflows its box without widening it, so neither "the element
is present" nor a box measurement can see this. The COMPUTED FONT SIZE is what a
reader sees, so that is what is asserted.

Two guards on the trade this fix must not make: the document's own
`scrollWidth - clientWidth` stays 0 — a page that scrolls sideways on a phone is
worse than one unreadable section — and `#cast-pivot` must be the thing that
scrolls, which separates "contained the overflow" from "removed it by shrinking
something else". The desktop arm is not ceremony either: the same query written
without a `max-width` bound, or with the floor on `.player` rather than its
child, changes 1280px too.

Tests: 4 landing tests (was 2). Rust and node unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:44:32 -07:00
Edward Langley 852e3f8659 fix(web): a selection driven below the fold scrolls itself into view
improvise-jq8l. `web/app.js` called `scrollIntoView` NOWHERE, so a selection
moved past the bottom of `#grid` stayed there, invisible, with no way back but
scrolling by hand.

What the ticket did not predict: this is NOT a projection bug and NOT a
`row_offset` bug, and the acceptance criteria's talk of reconciling the absolute
selection against the announced window describes work `render.rs` already does.
The row had been sent. `#grid` has `overflow: auto`, `ROW_PX`/`COL_PX`
UNDER-estimate (a real row measures 28.6px against the assumed 26) and inline
group rows are deliberately not charged for at all, so the page renders more rows
than its own box can show and the overflow is the DOM's. `block: "nearest"` is
the whole option: it scrolls only when the element is out of view, and it scrolls
the nearest container rather than the document.

The expensive half was the fixture. Neither shipped document can reach the state
— `web/demo.improv` is four rows, `harness/fixtures/grouped.improv` is eight, and
a probe measured BOTH at `scrollHeight === clientHeight` in a 484px box. The
assertion would have passed against a page that never scrolls, which is why the
spec asserts the overflow exists before it presses a key.
`harness/fixtures/tall.improv` is thirty rows in three groups; measured against
the unfixed page it gives 105px of overflow with the selected cell 96px below the
visible bottom and `scrollTop` still 0.

Tests: 25 chromium harness (was 24), Rust and node unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:41:27 -07:00
Edward Langley 628f43121d feat(web): the title bar is painted, a click survives an editor, and the twin door is gone
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with
one shape: a thing that looks done from every check except running it.

improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and
painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING
SEARCH over the page's source, so a field named by a function nothing calls reads
as painted, which is also how `model.minibuffer` shipped dead. So the guard is a
harness assertion that loads the page and reads `#doc-name`, proved by writing it
first (`element(s) not found`). The joining is `shell.js::documentTitle`, the
TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a
harness one, because this deployment persists on every accepted batch and clears
the flag in the same round trip, so the marker is transient. UNPAINTED is now
empty.

improvise-0x9u. From an EDITING state a cell click did nothing at all: the
mousedown moved focus, the input's blur ran `close`, `close` repainted,
`replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click`
was ever dispatched. The old spec pressed Escape between the two clicks, so it
only ever clicked from a non-editing page — this repo's characteristic defect.

Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the
first attempt failed the same assertion with the same symptom. Removing a focused
`<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside
itself and detached the `<td>` again. `close` therefore no longer repaints, the
`blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus
change happens at all — otherwise the default action focuses `#grid` after
`input.focus()` and blurs the new editor. `close` restores the DRAWN text, since
nothing repaints behind it any more.

improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept
for a reason `web/app.js` falsified in the same wave. It is one letter from
`set_viewport` and fails SILENTLY — identical frame, forgotten window, every
cursor key an upstream black hole. Deleted, along with the exported `init()` free
function that duplicated `new Client()`. The guard is not the deletion:
`wasm_surface.test.js` now reads the generated `.d.ts` and requires every
declared method to be one `app.js` calls or to be named in `UNCALLED` with its
reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts`
and watching it fail by name.

Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium
harness (was 23).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:37:44 -07:00
Edward Langley 9976486d69 docs: the help entry no longer contradicts itself
Found by review on #75. Renaming the heading to the test PR #72 restored made
check-evidence green, and left the body three paragraphs down still saying
"The test no longer exists" and "nothing replaced the assertion" -- one entry
making mutually exclusive claims about whether the coverage exists.

The checker could not see it, and that is not a gap in the checker: it
validates heading-to-test and says nothing about the prose beneath, which is
what a human reads. A mechanical check licenses exactly the claim it makes and
no neighbouring one.

The paragraph now records the gap as history -- gone for a release, invisible
because the checker then read only ### headings and only Rust names, restored
by #72 with improvise-igxp's stronger assertions -- and points at
help.spec.js's own entry rather than promising or denying coverage itself.

doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body):
a rename is a read of the whole entry, not an edit of its first line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 19:55:31 -07:00
Edward Langley 3d34b32085 Merge remote-tracking branch 'gh/docs/help-evidence-rename'
# Conflicts:
#	prolog-sessions/kb.pl
2026-09-20 19:40:02 -07:00
Ed Langley 279bf3a848 Merge pull request #71 from fiddlerwoaroof/wave/browser-keys
fix(web): an advertised key is never silent, a formula is visible, and +cat commits
2026-09-20 17:23:23 -07:00
Edward Langley 5fa554d712 docs: the help evidence heading names a test that exists again
PR #61 deleted the help spec; PR #69 demoted its evidence heading to prose as
Retired; PR #72 restored the coverage under a DIFFERENT name. So main briefly
carried a heading whose claim was false in both directions -- the prose said
retired while the test was back, and restoring the heading verbatim would have
named a test that does not exist.

Renamed to #72's actual test. check-evidence: 535 entries, all resolve; 162
tests cite the file and all have one.

Worth noting the sequencing, because it is the interesting part: #69 is the PR
that TAUGHT the checker to see #### headings and JS test names at all, so it is
simultaneously the thing that would catch this and the thing whose demotion kept
the merged tree green. The demotion was correct when written and stale by the
time it merged.

Plus doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary):
asserting WHICH refusal fired is only as strong as that message being unique
across every refusal the code emits, so enumerate the whole vocabulary rather
than the line you are looking at -- and where no message can discriminate,
assert the outcome, and guard the backstop separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:11:44 -07:00
Ed Langley fe25952aed Merge pull request #69 from fiddlerwoaroof/wave/tooling-gates
fix(tooling): six checks that did not check (vz54, 5ray, lyyc, x4wv, 2f97, dj1l)
2026-09-20 17:11:04 -07:00
Edward Langley be01339039 Merge gh/main (4e48a6e, PRs #70 and #72) into wave/browser-keys
Two doc conflicts, both pure appends at the same place, both resolved by
keeping BOTH sides:

- `context/code/clients.md`'s boundary table: #72 added the help-overlay row
  where this branch added the key→screen row. Six became seven, not six with
  one of them replaced.
- `context/test-evidence/clients.md`: both branches appended entries.

`README.md`, `harness/build.sh`, `harness/playwright.config.js` and
`.gitignore` are byte-identical to main's — this branch never touched them, so
#72's measured engine-coverage claims are intact rather than restored stale.

The check that matters here is NOT the count. #72 added three spec files while
this branch added one, so a total would have masked a loss. Diffed harness test
NAMES across the merge instead: 16 before, 23 after, **nothing lost**.

1398 + main = 1405 Rust passing; harness 28 passed + 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:07:39 -07:00
Ed Langley 4e48a6ecb4 Merge pull request #70 from fiddlerwoaroof/wave/effect-classification
fix(command): a counter is bumped on an observed change, not a declared intent
2026-09-20 16:59:18 -07:00
Edward Langley 4aa4e88ce4 fix(protocol): Ignored is a black hole too, and a decline cannot shadow working code
PR #71 review, blocking finding — and it was demonstrated rather than argued.

`KeyOutcome` has TWO silent variants and the new guard watched one. It asserted
`resolve(..) == Upstream(event)`, but `KeyOutcome::of(vec![])` returns
`Ignored`, which puts nothing on the screen either. Degrading only the three
declines no browser spec presses — add-record-row, delete-formula-at-cursor,
view-back — to `Step::Declined(Vec::new())` left 1397 Rust, 19 harness and 26
node tests passing. Three of the six commands this change exists to make
audible could go completely silent with the whole evidence base green.

The generalisation is the part to keep. `KeyOutcome::of` turning an empty
`Local` into an honest `Ignored` is good design and also a LAUNDERING step:
every wrong answer that composes nothing arrives wearing the variant that is
usually innocent. A guard's discriminator has to be STATE — did anything the
user can see change — never the shape of the return value. That is why the
`+cat` injection failed in `both_ends`, which compares documents and cursors,
and the decline injection passed everywhere, where nothing compared a screen.

Widening it costs ten entries, all one boundary case: `move-panel-cursor`
mirrors a `Cmd` that returns nothing when the cursor does not move. `furnished`
now carries three of everything with all three panel cursors seated at index 1,
so the residue is zero rather than parked in the named list. Seating them at 0
and calling the `Up` results expected would have hidden the real defect in the
same list as the boundary case.

`nothing_declined_is_also_composed` closes the other direction, which was a doc
comment. `step` reaches `declined` first, so an arm in `composed` for a declined
name is unreachable — a future ticket implementing `view-back` would leave the
browser saying "not available yet" over working code with nothing noticing. It
is derived from `declined` itself over every `(name, args)` bound in every mode,
parents and prefixes and sequence steps included, so a seventh decline needs no
edit here. It found a real overlap on its first run: `enter-mode` is answered by
both. Rather than move the check, both halves now hold the rule — `composed`
returns `None` for a stranded mode — so removing one fails towards `Upstream`
rather than towards a dead room.

`Keymap::parent()` added so a test can walk the fallback chain.

1397 → 1398 Rust; harness 19 + 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:55:04 -07:00
Edward Langley 14757a753f fix(command): the observation goes in the loop, or Save cannot clear it
Review of PR #70 found a regression the 1400-test suite did not name.

`observe_reshape` ran AFTER the effect loop and re-raised model_state.dirty
from the document_config comparison, while `Save` clears it INSIDE the loop.
So gotcha 45's ordering property -- "Save clears it in loop order, so a batch
that edits and then saves ends clean" -- survived for CellData/WholeWorkbook
and was broken for LayoutGeometry.

    [SetNumberFormat, Save]  -> reported a successful save, document dirty
    [SetCell, Save]          -> fine on both

Reachable from `:format ,.2f . w` and from any Binding::Sequence pairing a
geometry effect with a save. The TUI draws "Saved to ..." while :q refuses;
the worker, whose persist trigger IS the flag, pays a whole-document OPFS
rewrite. The exact failure class this PR set out to close, one level up.

The fix is position, not logic: the comparison is taken after each
LayoutGeometry effect against the value held after the previous one.
`layout_version` stays after the loop, which is not the same hazard -- nothing
clears it, and an aborted batch's effects never ran, so an end comparison sees
only what did. Neither arm of that is tested; filed.

Two more review findings:

a_panel_command_writes_that_panel_and_no_other asserted THROUGH the mapping it
tests -- zeroing via panel_open_mut and reading via panel_open, the same four
accessors. A consistent permutation of all four is a self-consistent bijection
and passed. It now reads the three field pairs by name once per panel, and the
consistent permutation fails it. It had been catching only a non-injective
mapping, which is precisely the injection the evidence recorded.

And improvise-56ek's claim of "no second copy left to agree with" was false
when written: resolve.rs carried a fourth copy of the same Panel -> ViewState
match, in the same crate on the same type. Deleted; its three call sites go
through ViewState::panel_open. The claim is corrected rather than overstated --
registry.rs maps Panel to CmdContext fields, a different type, and the
Panel -> (cursor, length) pairs are a different derivation and lockstep's
subject.

1401 tests pass. Both new guards proved by injection: reinstating the
after-loop comparison fails the ordering test by name with 454 others passing;
a consistent Formula<->Category permutation across all four accessors fails the
panel test, where before it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:54:57 -07:00
Edward Langley d1ce2ecbc3 fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.

rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.

Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.

fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.

gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.

Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.

1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:36:03 -07:00
Edward Langley f5198c9142 test(harness): gate the grouped reads on a painted head
Self-review under the code-review skill: the three grouped tests read the head
and the row labels in one shot, which cannot wait, while the grid is painted
from the first projection and reframed by the viewport the page then announces.
openGrouped now gates on the head's shape, its first group and the first row's
innermost label with retrying assertions. 15/15 under --repeat-each=3, and all
five injections re-run afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:35:47 -07:00
Edward Langley f9580a33af docs: what the harness now reaches, and the evidence for each assertion
The engine-coverage and measurement passages in context/code/clients.md, the
boundary table (four boundaries become six), and README's caveat -- which
named the grouped and multi-level DOM as never executed, and it now is. A fact
stated in more than one place is a grep, not an edit (gotcha 48).

context/test-evidence/clients.md records, per test, what fault was injected to
prove it can fail: the blanked help keys, the five grid-frame injections, the
404'd casts and the dead front-door link, and the mis-wired item-row click --
plus the two-command test-name diff that found that last one.

Playwright: 26 passed + 3 skipped, 34.4s / 33.7s (was 17 + 3, 15.1s); the
landing page's cast playback is most of the difference. node --test web/tests
26, cargo test --workspace 1394, both unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:31 -07:00
Edward Langley beef2587ad fix(command): a counter is bumped on an observed change, not a declared intent
`EffectKind` says what an effect *means* to do. `layout_version` and
`model_state.dirty` were believing it, and failed in both directions.

improvise-pdvm: AddItem/AddItemInGroup/RemoveItem declare CellData and
reshape the grid. Since improvise-9mo9 they call reset_cursor, so
cursor_epoch moved while layout_version did not -- one event moving one of
two counters that answer the same question, and a client told its cursor is
invalid while its layout basis tier still reads current.

improvise-75ld: a refused AddCategory (MAX_CATEGORIES) and a *duplicate* one
both bumped layout_version and raised dirty. The duplicate is the reachable
silent case -- Model::add_category returns Ok for a name it already has, so
it does not even abort -- and it refused every other client's next batch with
Rejection::LayoutMoved and made the worker rewrite the whole OPFS document.

Both are answered the way record_versions and cursor_epoch already are:
observe. Two fingerprints are taken around a batch that contains any
EffectKind::Model(_).

  view_shape   one view's DRAWN shape (axes, effective items minus this
               view's hidden ones, collapsed groups with something drawn
               under them, page selection, prune_empty). Per view name, so
               it answers both "did anything move" and "which views moved".
  document_config  everything a LayoutGeometry effect can write that the
               file would carry. Raises dirty. A different hash on purpose:
               a number format and a hidden ghost are bytes on disk and not
               shape.

No `Effect::kind()` declaration changed, so Dirty::of (gotcha 35) and
record_versions (gotcha 40) are untouched. What moved is which reader
believes the declaration: CellData and WholeWorkbook still raise dirty off
it, in an exhaustive match so a fourth ModelChange must answer for itself.

improvise-nynk: Sessions::adopt_cursor_epoch reset every mirror and returned
one Broadcast::ToAll, which improvise-jytm made wrong -- a reshape of view A
threw away the cursor of a client on view B. The trigger stays the epoch
moving (gotcha 24); only the audience narrows, to the observed reshaped-view
set, as one Broadcast::To per affected client. An empty set means "not
known", never "nobody", and cursor_invalidated is a named function with both
arms tested so that fail-safe cannot be tidied away.

improvise-56ek: SetPanelOpen and SetPanelCursor matched a Panel to a field
twice, in two crates. One home now -- ViewState::panel_open_mut /
panel_cursor_mut and their read halves -- and no compatibility test between
them, because there is no second copy to agree with.

1400 tests pass (baseline 1394: +7 guards, -1 replaced). Every guard proved
by injecting the fault it names; the injections and what each one broke are
in context/test-evidence/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:33:56 -07:00
Edward Langley 2eb46122d3 fix(tooling): --check-evidence sees #### headings, JS tests, and the reverse direction (improvise-5ray, improvise-lyyc)
Two blind spots with one consequence: an evidence file could promise a guard
that was not there, and a test could point at prose that did not exist.

5ray: headings were collected with `startswith("### ")` and resolved against
Rust fn names only. context/test-evidence/clients.md uses `#### "name"` for
every browser test precisely BECAUSE a `###` naming a Playwright test would
have been reported as an orphan -- so the convention that kept the checker
quiet was the same one that made it blind. Now `###` and `####` alike, decided
by SHAPE: a bare identifier is a Rust test, a quoted string is a JS test
(harness/tests and web/tests), anything else is prose naming nothing.

lyyc: the check was one-directional. A test whose doc comment names
context/test-evidence must now appear there. Deliberately only that narrower
obligation -- most tests have no evidence and should not be forced to. Neither
direction can see a STALE entry; that stays discipline.

Each half found a real instance on its first run. A `#### "? opens help..."`
heading outlived the test 36ade35 deleted, and is demoted to prose with the
coverage filed as improvise-v5pc. `an_entry_that_holds_no_document_is_seeded_over`
cited the file and had no entry; one is written from the test.

The checker's --self-test covered the comment scanner and nothing else, which
is how both survived. It grows ten evidence cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:54 -07:00
Edward Langley ec03f950b3 Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks 2026-09-20 14:36:19 -07:00
Edward Langley 27580e4d64 docs(tests): a repair needs its own probe, because a repair can look like a fix
The two-step mutation recipe recorded alongside this finds a DISARMED
test. It says nothing about whether the repair you then write is real.
That gap is not hypothetical: the first repair of
panel_commands_address_the_named_panel_only folded [open:false,
open:true], read as a fix, and was still vacuous.

The probe that settles it is a ONE-DIRECTIONAL fault -- make the writer
`if open { set true }`, so the flag can be raised but never lowered. A
vacuous repair cannot see it, because a test that only ever asserts the
`true` direction is satisfied by a writer that only ever writes `true`.

Injected into both copies at once it fails set_panel_open_and_cursor AND
panel_commands_address_the_named_panel_only, each in its own copy --
which is what establishes both repairs are genuine rather than merely
differently spelled. Verified here rather than taken on report.

Prefer it to re-reading the diff: the diff contained `open: false` in
the version that was vacuous, which is exactly why reading it passed.

Docs only -- crates/, src/, web/ and harness/tests/ are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 14:33:37 -07:00
Edward Langley 89f67afd4c fix(harness): make a vacuous watchdog denial fail, and single-home the window
PR #65's review found this PR had planted a fresh instance of the defect it
exists to repair. Both blocking findings fixed, each demonstrated.

FINDING 1. "a peer that cannot be reached is named, and the watchdog stays out
of it" could not see the property its comment claimed. `shell.arm` runs only
after `await transport.ready`, which never resolves on a socket that never
opens — so the watchdog is never armed there and denying "sent no document"
denies an unreachable event. Reverting PR #21's routing left that clause
passing.

The aggravating half: `context/test-evidence/clients.md` already said so, and
this PR EDITED that paragraph — inserting "runFor(10s) advances the watchdog"
immediately above the sentence explaining it is never armed. Not text the PR
left alone; text the PR walked past mid-sentence.

The clause is gone and the test keeps the naming property it really guards: a
`close` on a socket that never opened must not read as "disconnected". And the
shape cannot return — `outlastTheWatchdog` now asks the page `timesArmed() > 0`
before jumping, so a watchdog denial re-added to that path FAILS BY NAME. It
asks *ever armed* rather than *armed now* on purpose: by the time the disconnect
test jumps, a correct page has already disarmed, which is the property, so the
obvious spelling would have denied the passing case. Building the guard is what
surfaced that distinction; reading had not.

FINDING 2. The spec kept its own `HANDSHAKE_TIMEOUT_MS` beside a comment saying
a silent disagreement "would pass for the wrong reason". It did: the spec at
1000 against the page's 8000 made the suite green over the reverted routing,
because the jump landed inside the window. The constant is now exported from
`web/shell.js` and imported by both, so the disagreement is unconstructible.
Shrinking it in its one home still catches the routing revert — that is the
demonstration the guard did not go quiet.

Filed, not fixed: improvise-78v6 (#61 also deleted the help-overlay spec and
nothing replaced it — a PR about a dropped guard that did not diff the dropping
PR's test names) and improvise-5ray (--check-evidence collects only `### ` and
resolves only Rust fns, so every browser entry's `####` is invisible; appending
one that names nothing exits 0, which is how the dangling heading cleared the
gate for a release).

Verified on the merged tree: node 26/26, bundle 3/3, Playwright 17 passed + 3
skipped, cargo test --workspace 1378 passed, comment-runs clean,
--check-evidence 501 resolving, all cited ids resolve, no .beads diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 14:24:25 -07:00
Edward Langley ac6f17288c docs(tests): record how to know a launch default has stopped disarming tests
The question PR #60 left open was not which tests the launch default
disarmed, but HOW TO KNOW THE CLASS IS CLOSED. Grep cannot answer it: it
finds sites that NAME the flag, and the hazard is a test that merely
starts from a default-built state and asserts something downstream,
mentioning nothing. Finding the three disarmed tests by grep was luck.

Mutation answers it, because it does not care what a test mentions.
Recorded in context/test-evidence/clients.md: mutate every site that
writes, propagates or reads the value to the constant the default
supplies; sites where nothing fails are suspects; then RE-RUN EACH
SUSPECT WITH THE DEFAULT INVERTED. If it now fails, the test was
vacuous -- the default disarmed it, fix the fixture. If it still fails
nothing, it was simply unguarded -- pre-existing, file it.

That second step is the whole method. Without it the two species are
indistinguishable (both look like "nothing failed"), and it is what
established that the vacuity class is closed at TWO rather than three.

It found two further silent sites, neither caused by the ruling:

- App::cmd_context propagates the flag and nothing tests it. Stuck
  true, `C` on a CLOSED panel decides to close it -- the panel becomes
  unopenable by its own key -- with the workspace green. Invisible
  because the command test helper builds the value it then asserts on.
  Filed as improvise-qsmp; control run proves it predates the ruling.
- render.rs propagates it into RenderModel. Stuck true is improvise-wsaw
  reintroduced one layer up, and RUST SEES NOTHING -- only the Chromium
  harness catches it. A Rust-only run of the `test` job cannot see a
  reintroduced wsaw, which is the sharpest argument on file for the
  browser job being able to fail the build.

Also marks the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it
PROSPECTIVE: with three panels its two siblings already cover every
Panel::ALL member, so it cannot fail alone today. It earns its place the
way gotcha 12 describes Panel::ALL -- a fourth panel reaches it from one
entry -- and saying so stops a green tick being read as present-tense
evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 14:24:08 -07:00
Edward Langley 1922beca06 fix(tests): a launch default disarms every test that asserts what it supplies
USER RULING (2026-09-20): the category sidebar is the launch default in the
TUI as well as the browser. `improvise model.improv` opening with the
sidebar showing is intended, not collateral. The behaviour is unchanged;
what changes here is that it is now a stated, tested decision.

Three tests asserted `category_panel_open` was TRUE after doing something
to make it true. The default now supplies that, so all three passed
without the code under them working.

**`set_panel_open_and_cursor` is the serious one.** With it vacuous,
`effect::SetPanelOpen`'s Category arm could be replaced by a no-op — `C`
doing nothing in the terminal — and the whole workspace stayed green at
1391 tests. Now closes the panel first and asserts it closed, so the
effect has to move the flag.

**`panel_commands_address_the_named_panel_only` is the subtle one, and my
first repair of it was ALSO vacuous.** Folding `[open:false, open:true]`
and asserting the final state is true still passes against a reducer that
folds nothing: the default already supplies `true`, so neither command
needs to land. Only asserting the FALSE direction discriminates. Caught by
injection reporting NONE failing after the "fix" — it would otherwise have
shipped looking repaired.

The rule this leaves, now in `context/code/clients.md`: **assert the
direction the default cannot supply.**

`tests/tui_launch_state.rs` gives the ruling a guard on the front end it
newly changes — the TUI's launch frame had no test at all. Not a rendered
frame: §7 says not to test `Widget::render` and the repo has no
`TestBackend`, so the target is `App::new`. One of the three walks
`Panel::ALL` comparing `App::new` against `ViewState::default()`, which is
what would catch a TUI-only launch state — the divergence wsaw closed.

**The view-row click test was vacuous for the property it is named for.**
`Default` is index 0 and holds `view_panel_cursor`, so clicking it made
`index` and the cursor the same number. Injecting the cursor-vs-index
fault into `click_view_row` passed the contract suite 3/3 — the exact
fault the ticket, the evidence and the Rust test exist for was invisible
to the browser half. Now switches to Default by keyboard, then clicks
Plan, and restores the two assertions the relocation had dropped.
Inherited from the deleted `pointer.spec.js`, not introduced here.

Filed rather than fixed: improvise-56ek (`SetPanelOpen` maps a Panel to a
field twice, in two crates — each copy needed repairing independently, and
the harness passing against a no-op'd effect is correct rather than a gap)
and improvise-qf57 (the formula row's dropped `toHaveCount(0)`).
improvise-4ibz is closed by the ruling.

Every claim here proved by injection under `--no-fail-fast`, which is the
method note: `cargo test --workspace` is fail-fast and understates blast
radius.

Gates: 1394 Rust tests, 26 + 3 Node, 16 Playwright (3 WebKit OPFS skips),
clippy -D warnings, fmt, eight wasm crates, comment runs, 516 evidence
entries. `.beads/` untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:12:44 -07:00
Edward Langley 57a25807af Merge gh/main (e8562f0) into fix/web-panel-clicks
PR #61 landed while this branch was open. It deleted
`harness/tests/pointer.spec.js` and `harness/tests/widgets.spec.js`, split
`web/app.js` into app/shell/serial, and added a Node suite under `web/tests/`.
Two conflicts, both resolved deliberately rather than by reflex.

**`harness/tests/pointer.spec.js` — modify/delete, resolved as DELETE.** The
habitual "keep both sides" would resurrect a file #61 removed on purpose.
`contract.spec.js` states its own scope as the RenderModel→DOM boundary and
the pointer's half of the JS→wasm one, so that is where these assertions
belong. Merged in, rather than restored:

- the category sidebar is open at launch and `C` still hides it;
- a first tile click selects and does NOT touch the document, a second cycles;
- the view row and formula row clicks improvise-iioi added, which
  `contract.spec.js` had no entry for at all.

**`context/test-evidence/clients.md` — content, resolved as BOTH SIDES.** The
two `pointer.spec.js` subsections are retargeted at the file that exists, with
a note saying why restoring the deleted one would have been wrong. The two
older `#### … the file's own scope` sections keep their history and gain a
forward pointer, because a reader following them would otherwise hunt for a
file that is gone.

**`web/tests/bundle/wasm_surface.test.js` now names `click_formula` and
`click_view`.** That test is an exact `deepEqual` over the page's `client.*`
calls and its comment says a new call "has to come and say so". It did: it
failed on the merge, which is the mechanism working. It could not have been
satisfied earlier — it did not exist when this branch was cut.

Two review findings fixed on top, neither a live bug:

- **The tile guarantee was overclaimed.** `T` leaves `tile_cat_idx` at 0, so a
  click on the LEFTMOST tile cycles on the first gesture. "A click never
  mutates on its own" and "a second click cycles" are both false against that
  probe. The behaviour is right — a click does what `Enter` does there — so the
  prose now states the implemented rule, and
  `t_then_a_click_on_the_leftmost_tile_cycles_it_immediately` names the case
  and asserts the neighbouring tile answers the same gesture differently.
- **`switch_view_at`'s `Option` had no guard on the keyboard side.** Removing
  the `unwrap_or_default()` makes `Enter` in an empty view panel `Upstream` —
  gotcha 47's black hole — and passed the whole workspace. That would make
  membership of the host-needed list state-dependent, which is the one property
  that list denies. One test closes it.

Gates on the merged tree: 1391 Rust tests, 26 + 3 Node tests, 16 Playwright
(3 WebKit OPFS skips), clippy -D warnings, fmt, harness fmt + wasm32 clippy,
eight wasm crates, comment runs and 513 evidence entries clean. `.beads/`
matches gh/main exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:00:19 -07:00
Edward Langley 9863d96eef fix(harness): restore the close-routing guard PR #61 dropped, on a faked clock
Adversarial review of PR #61 found one real coverage loss, and it is the
defect class that PR's own body names: an assertion that survives the deletion
of the thing it was about. `shell.spec.js` lost "a socket that opened and then
dropped reads as disconnected, and stays that way" and both MutationObserver
negative assertions. Reverting PR #21's routing half in `web/app.js` — the
socket `error` and `close` arms calling `setShell` instead of `fail` — left the
node suite 26/26 and Playwright 13/13 green.

`shell.test.js` cannot cover it, and why is the whole boundary argument:
"a loud failure DISARMS the watchdog" tests `createShell` in isolation and
cannot see whether `app.js` ROUTES the close event through `fail()`. That
routing is the half gotcha 48 says keeps getting lost; this was the third time,
after two earlier mis-confirmations that simulated the `opened` flag and not
the routing.

Both tests are back under their original names, so the two `####` headings in
context/test-evidence/clients.md name live tests again. They cost ~75ms each
rather than ~9s, because `page.clock` fakes the PAGE's timers: the page, the
socket, the listeners and the `setTimeout` call are all real and only the
passage of time is simulated. That is not a weakening, and the injections are
the proof in both directions — if `runFor` did not advance the page's timers, a
broken routing could not surface the watchdog's message and they would pass:

  - both arms reverted to `setShell`  -> "…and stays that way" fails
  - close arm only                    -> "…and stays that way" fails
  - close arm drops the `opened` flag -> "…the watchdog stays out of it" fails

Three injections, three outcomes, none of them the unit test.

The helper guards its own premise, because it turned out not to: `runFor`
without `clock.install()` does nothing AND throws nothing, so a test that
forgot the install asserted "the watchdog never spoke" against a watchdog that
never ran — 6/6 green. `outlastTheWatchdog` now requires the page's own
`Date.now()` to have moved, and that omission fails by name.

Also: `render_surface.test.js` matched `model.<field>` over source INCLUDING
comments, so un-painting a field and mentioning it in a comment passed —
demonstrated against the pre-fix version, then fixed and re-demonstrated.

And the PR's speed claim is corrected where it is documented. The eleven
deleted per-widget tests cost 3.3s between them against 3.4s for the three
contract tests replacing them: they were free. Essentially all of 36.9s -> 15.5s
was two real-timer waits, one of them this guard. The wait was the cost, not
the test.

Filed, not fixed: improvise-igxp (the help overlay's rows are asserted as
present, never as content — inherited verbatim from the test contract.spec.js
replaced) and improvise-4ktt (the js job's bundle-freedom is a shell-glob
convention, not a gate). The `####`-vs-`###` blind spot in --check-evidence,
which is why the dangling heading survived the gate, is a comment on
improvise-r0qa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:59:06 -07:00
Edward Langley 50e4a37214 fix(web): a click is all of its intents, never mutates, and the panel opens
Four user rulings and three review findings, all in the pointer's one
resolver.

improvise-hkr8 — a click is all of its intents or none of them. Each
`click_*` now returns `Option<Vec<Command>>` and `click` does the single
`unwrap_or_default`. The `None` that matters is `BasisCache::claim`
answering that no basis has been published: the click used to keep its
navigation prefix, drop its activation and report success, while the
keyboard handled the identical `None` correctly as `KeyOutcome::Upstream`.

USER RULING — a tile click SELECTS; a second click on the already-selected
tile CYCLES. Every click in the UI is now non-mutating by default. The
argument that earns the two keystrokes is not tidiness: a cycle is
`LayoutGeometry`, so it bumps `cursor_epoch` and resets EVERY connected
client's cursor (gotcha 24) — the easiest input to trigger by accident was
wired to the one class of change that reaches other people. The divergence
from `Enter`, which still cycles in one keystroke, is chosen and recorded.
"Already selected" is mode AND index, because `tile_cat_idx` is 0 before
anything selects anything and tile 0 would otherwise cycle on first click.

USER RULING — the category panel is open at launch, as the shared
`ViewState::default()` rather than a browser special case. A browser-only
default is "the page overrides the flag", which is the same defect as the
"the page ignores the flag" bug improvise-wsaw fixed. This changes the TUI
too: `src/draw.rs` draws the panel off the same field.

Three review findings, all "a guard that would not catch the drift it is
named for":

- The formula-row `assert!` could never fail — the `assert_eq!` above it
  pins the same vector and aborts first. Replaced by an assertion aimed at
  the keymap itself, which is where the fact lives; the reviewer had shown
  that adding `Enter` to FormulaPanel left 1381 tests green while the click
  and the keyboard disagreed.
- `click_view_row` re-spelled `SwitchView + ChangeMode` inline instead of
  sharing a helper, so a change forced into the keyboard arm by lockstep
  left the click behind silently. Both now call `switch_view_at`.
- `context/test-evidence/clients.md` documented the behaviour PR #59 ships
  as one of the injected faults that disproved it, and had no entry for
  #59's new test. Both fixed; the checker's one-directional blind spot that
  hid it is filed as improvise-lyyc.

Every change is proved by injecting the fault: six injections, six distinct
failure signatures, none passing for another's reason.

Gates: 1389 workspace tests (was 1381), clippy -D warnings, fmt, harness
fmt, all eight wasm crates, 27/27 Chromium harness, 189 cited ticket IDs
resolve, comment runs and evidence headings clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:45:31 -07:00
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:33:49 -07:00
Edward Langley d7386b4308 fix(web): the view and formula panels are clickable too (improvise-iioi)
`categoryList` got a click listener and its two siblings did not, so `Click`
had three variants and no ViewRow/FormulaRow. Demonstrated in Chromium:
Shift-V, click a row, nothing changes — mode, row list, active marker and
panel cursor all byte-identical.

This is the one a user hits first, and shipping the cure for one panel and not
the other two REPRODUCES THE ORIGINAL REPORT: someone who has just learned
category rows are clickable clicks a view row and reads the silence as broken,
which is literally what "I can't expand categories" meant (improvise-7ybz).

Both follow the existing rule — go there, then do what Enter does there — and
the two panels answer it differently, which is the rule applying rather than
one of them being half done:

  - A VIEW row focuses, moves the cursor and switches, because ViewPanel binds
    Enter to switch-view-at-cursor. It ends in Normal; the panel stays open.
  - A FORMULA row focuses and moves the cursor and NOTHING ELSE, because
    FormulaPanel binds no Enter at all. Opening the editor on a click would be
    an activation invented for the pointer that the keyboard does not have.

The trap is the same one the category click had: the activation must resolve
against the row CLICKED, never the panel cursor, because the SetPanelCursor
ahead of it is not folded yet. Injected and run — reading the cursor instead
gives SwitchView("Default") beside SetPanelCursor { cursor: 1 }, so the cursor
lands on Plan while the document switches to Default. Silent, and wrong in the
half nobody watches. The fixture holds two views and clicks the second for
exactly that reason.

Browser tests proved against the bundle built immediately before the fix: both
new ones fail there while the six existing pointer tests pass.

Also folds in the harness port fix: reuseExistingServer is now opt-in via
IMPROVISE_HARNESS_REUSE rather than on by default. A default-on reuse adopts a
server another checkout left on the port and tests THAT tree — which fired in
both directions in one session, including a false PASS. A path-hash default was
rejected because it still silently reuses when two agents share a worktree
path; an explicit opt-in cannot. CI never reused, so it is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:26:35 -07:00
Edward Langley f7d2214c7a test(harness): drill is refused in the browser, and one refusal is silent
The fourth widget reported, and the one with a different answer: there is no
wire vocabulary for drill. reduce.rs contains no Drill variant at all, neither
Command nor ModelCommand. That is improvise-gd28, and it is a decision rather
than an oversight — drilling writes drill_state, which gotchas 30 and 1 rule
session-local, and a Command::Model is routed with no SessionId. Adding
ModelCommand::Drill would reintroduce exactly what improvise-3x3u removed.

Everything below the command works, verified in the browser: CellId::Aggregate
is emitted and received, editable answers false for it, and edit_or_drill
reaches the aggregate arm. The TUI is the control and passes 12 drill tests, so
this is isolated to the client/wire.

The finding gd28 did not record is that the two entry points fail DIFFERENTLY.
`i`/`a` declines out loud with a status. `>` — the key the hint line actually
advertises as ">:drill" — is a name resolve::step does not handle, so it goes
KeyOutcome::Upstream -> ClientFrame::Key -> Host::handle's empty vec: a black
hole, not a slow path (gotcha 47). Silent. That asymmetry is why this reads as
broken rather than refused.

These tests document what is true rather than leaving folklore. They MAKE an
aggregate through the tile bar (web/demo.improv has none by construction —
gotcha 46) and assert the precondition, so they cannot pass against a grid of
Records. Both proved by injection: silencing the aggregate arm fails both, the
second on its check_the_checker control; making `>` resolve fails only the
second.

Evidence added to improvise-gd28 rather than filing a duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:14:53 -07:00
Edward Langley 955c30a864 fix(web): land the review findings main is missing
Repair-forward for the accidental push at 959f249, which put the PRE-review
version of the browser-widget work onto main. The three defects review found
are all in the version main currently carries; this is the delta that
supersedes it.

1. `categoryList` did not call `indicator()`. It inlined U+25B8/U+25BE, the
   SMALL triangles, where `indicator()` — the page's declared ONE spelling —
   the page's own grid group headers, and src/ui/category_panel.rs all draw
   U+25B6/U+25BC. The browser's category panel disagreed with the TUI's and
   with its own grid, inside the file whose comment says "only this spelling
   is the page's".

2. Two specs pinned the divergent pair, so the obvious cure would have failed
   tests that shipped with the feature. widgets.spec.js AND pointer.spec.js —
   the latter written on a different branch, which had grown the same wrong
   assertions independently. Three files converging on a spelling nothing
   compares is design-principles §5's predicted failure, observed. Proved
   discriminating by running the corrected specs against the pre-fix bundle,
   where they fail with `Received string: "▸ Product (4) R"`.

   The pointer.spec.js line is in this PR and not a later one because main
   already carries that file: without it this change leaves CI red. It is one
   assertion corrected by the same rule as its sibling, not feature work.

3. `el.tiles.hidden` was a no-op, removed with its false comment. `#tiles`
   declares `display: flex`, an author-origin rule that beats the UA sheet's
   `[hidden] { display: none }`, so the guard never hid anything. Worth
   knowing before restoring it: `#tiles` being ALWAYS laid out is why its
   height never changes and it stays out of the ResizeObserver feedback path.

Also `playwright.config.js` takes IMPROVISE_HARNESS_PORT (default 8099, so CI
is unchanged). The hard-coded port plus `reuseExistingServer` let Playwright
silently ADOPT a server left by another worktree and test THAT tree. It fired
in both directions in one session: a false negative (a fix reported absent)
and — the dangerous half — a false POSITIVE, 5 passed against a tree verified
to contain no #tiles at all. A failure sends you looking; a false pass prompts
nobody.

The evidence prose records all of it, plus the ResizeObserver acyclicity proof
that retires "argued, not tested" there: frameCost.cols is row header LEVELS
(independent of projected rows) and frameCost.rows derives from the column
window, so cols -> rows and nothing depends on rows. No cycle to oscillate.

Gates: 1359 Rust tests, rustfmt, comment-run and evidence linters, 21/21
Chromium harness (on a private port, so not another checkout's tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:03:14 -07:00
Edward Langley 959f249c4b feat(web): a click is resolved in Rust, and the sidebar obeys its flag
The user's rulings on the two beads PR #55 filed.

improvise-7ybz: the browser was keyboard-only by omission. Nothing outside an
editable grid cell had a click listener, so clicking the collapse triangle on a
category did nothing — which is what "I can't expand categories" meant, since
the keyboard path worked all along.

`improvise_protocol::resolve::click` now answers all three widgets under one
rule: **go there, then do what Enter does there.** A category row focuses the
panel, moves its cursor and activates (expand on a header, filter on an item);
a tile selects and cycles its axis; a cell moves the selection, with the page's
inline editor as the pointer's `i`. One rule rather than three inventions, and
it is the keyboard's own definition rather than a second one — `cat_tree_entry`,
`toggle_cat_expand`, `filter_to_item` and `tile_axis` each gained an `_at` twin
and the cursor-reading form is now a one-line call of it.

It lives in the protocol crate, not the wasm client, for the `flatten_cat_tree`
reason: a third front end must call this, not derive it. `Client::click_category`
/ `click_tile` / `click_cell` are thin wasm-bindgen wrappers that fold and
serialize exactly as `key` does, so `app.js` still composes no `Command`.

The subtlety worth knowing: a click's three intents are resolved in one pure
call, so the activation must be asked about the row *clicked*, never about
`cat_panel_cursor` — the `SetPanelCursor` ahead of it has not been folded yet.
Injecting that exact fault turns `ToggleCatExpand("Product")` into
`ToggleCatExpand("Region")`, which is the silent half and is now pinned.

improvise-wsaw: `RenderModel::category_panel_open` existed from the start and
`app.js` never read it, so the sidebar was drawn unconditionally and `C` moved
only the mode label. It now hides on load and toggles with `C`, matching
`src/draw.rs:322` and the two panels PR #55 added. A first-time visitor sees
one fewer thing; the user chose front-end consistency, and the grid is still
populated.

Tests: harness/tests/pointer.spec.js, six assertions, **all six proved by
running them against the bundle built immediately before this commit** — where
the sidebar is wrongly visible and every click lands on an element with no
listener. Plus four Rust tests on the resolver, one of them proved by the
injection above.

Note this branch is based on gh/main but MERGES fix/web-ui-correctness (PR #55):
click_tile needs a tile bar to click, and that is #55's. Rebase or re-merge once
#55 lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:35:47 -07:00
Edward Langley 96bb2e6f0c Merge remote-tracking branch 'gh/main' into fix/web-ui-correctness
# Conflicts:
#	context/test-evidence/clients.md
2026-09-20 12:23:14 -07:00
Edward Langley d65fcc5969 test(harness): the tile bar must move the grid, not only its own label
The first version of the tile test asserted that the tile read 'Product Col'
after 'c' — the bar reporting itself, which would pass against a bar that
displayed an axis the document had not taken. It now also asserts Espresso
moves from a tbody row header to a thead column header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:19:39 -07:00
Edward Langley 6f9cd6cad5 fix(web): the tile bar, the view panel and the formula panel are drawn
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.

Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.

The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.

So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.

The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).

Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.

Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:18:01 -07:00
Edward Langley 850ddcf0eb Merge gh/main (#48 active_view, #50 shared front end) into wave/b-projection-gaps
Eight conflicts, all resolved keeping both sides. The three in context/ were
append-vs-append. The five in code were the restructure #50 declared in advance:

- render.rs: render_model became a free fn over &ClientState and the mode label
  moved to improvise_protocol::mode_label. Took that shape and re-added the
  three doc_* fields.
- state.rs -> improvise-protocol/src/client.rs: only the import path conflicted.
  The file now lives IN improvise-protocol, so `crate::` is right, plus
  DocumentCache.
- keys.rs went 1387 -> 187 lines because resolve/step moved to
  improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every
  addition into resolve.rs: the Derived and Create arms of edit_or_drill, the
  or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its
  Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list.
- project.rs: kept the value-based numeric/error from improvise-tom1 with
  gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and
  view_in; kept our title-bar projection inside their per-view projection set.
- lockstep.rs: both sides appended tests and the hunks interleaved mid-function,
  so took gh/main's file and re-appended our page-turn block whole.

THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check
--workspace --all-targets` passed with three of our tests SILENTLY GONE — they
lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_
the_formula, the_page_keys_turn_the_odometer_as_the_command_does and
a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with
client_on now returning a ClientState rather than a Client.

One assertion changed meaning under us and is corrected: lockstep's
page_selections read workbook.active_view(), which since improvise-jytm is the
document's DEFAULT. SetPageSelection writes App::view(), the client's. They
coincide in a one-view fixture, so it was passing by fixture luck; it now reads
App::view() and still fails under the injection.

All four injections re-run on the merged code and all four still discriminate:
reversed odometer, dropped records formula_for lookup, title bar gated on Dirty,
and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt
clean, all eight wasm crates clean, both comment-run checks clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:15:14 -07:00
Edward Langley fc4035dbca Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend
# Conflicts:
#	crates/improvise-command/src/ui/reduce.rs
2026-09-20 11:54:09 -07:00
Edward Langley f66c4aff04 fix(protocol): records_named has one home, and the lockstep sees every cursor
Findings 1 and 2 of the adversarial review of PR #50, both demonstrated by
injection before being fixed.

**F1.** `resolve.rs`'s `structural`/`record_write` were a second derivation of
`reduce.rs::records_named`, and nothing tested them: `lockstep.rs`'s
`apply_on` restates the basis against its twin with `caught_up`, which
re-derives it with the SERVER's answer and throws the client's claim away
before anything is compared. Emptying `record_write`'s claim left the
workspace green.

Fixed by collapsing rather than testing, which is what this unit argues for
everywhere else. `records_named` is pure and `RecordHash` is already
re-exported by the protocol, so it moved DOWN into `improvise-protocol`
beside the enum it reads; `improvise-command` re-exports it; `resolve.rs`
has one `model()` that claims `records_named(&cmd)`. The claim is now right
by construction and the guard is the exhaustive match that was always there.
Verified by emptying the shared function's `SetCell`/`ClearCell` arm: five
server-side tests fail, where the client's copy had none.

What the old shape cost, which was not a compile error: `structural` accepted
any variant and claimed `[]`, so a new record-writing command compiled clean
on the client with the wrong answer and the symptom was a dead browser key,
refused as `RecordUnstated`, with nothing failing.

**F2.** Three resolver divergences the suite could not see, all now failing:
`delete_view_at_cursor`'s cursor step-back (its only decision — the test
pressed `n` and `Enter` while its doc comment said "create, switch and
delete"), and both Formula arms of `CommitKind`, Formula being the only kind
whose `on_commit()` is `Some`. `both_ends` now compares every cursor, not
only the document, mode and status. A fourth injection — `cycle_panel_focus`'s
`start` index — still survived, because `PanelKeymap` binds Tab to
`enter-mode normal` inside a panel so no key reaches those arms; they are now
driven directly by `panel_focus_cycles_from_the_focused_panel_not_the_first`.

Widening the commit test found a real divergence, pinned rather than fixed as
improvise-cl7y: the client folds `CommitBuffer`'s optimistic "Formula added"
and cannot parse, so a browser reports a formula it did not add.
`a_formula_the_client_cannot_parse_diverges_on_the_status_line` asserts both
sides, so a fix has to come there and change it.

F3 filed as improvise-thpe and deliberately not fixed here.
`apply_on`'s doc comment pointed at an evidence entry that did not exist and
now names `context/code/clients.md`, which has the argument.

1324 tests pass, up from 1322.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:48:26 -07:00
Edward Langley 414d02c4a6 feat(protocol): one home for UI strings (improvise-x7sw, ktv1, k099)
Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:

**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`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.

- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
  captions together. The client already holds the keymaps, which is the answer
  the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
  key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
  lines of content; `web/app.js` paints rows it does not author, and
  `harness/tests/shell.spec.js` asserts that `?` really draws them.

**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.

Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.

**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.

Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.

Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:18:17 -07:00
Edward Langley 10cebfffd9 docs(active-view): record the split, its evidence, and the two gaps it opens
`context/code/command.md` gains the derivation for improvise-jytm: what moved,
what projects per client, why `effect::SwitchView` is `ViewOnly`, and why
`adopt_active_view`/`Broadcast::ToOthers` are deleted rather than left.

`context/test-evidence/command.md` and `clients.md` follow the tests that were
replaced, each entry naming the fault injected to prove the new check can fail.

Two gaps filed rather than smuggled in: improvise-lltt (a view-scoped
`ModelCommand` names no view) and improvise-azhb (`Initial View` no longer
follows the TUI user).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:06:18 -07:00
Edward Langley 8b3de934fc feat(protocol): project the page-axis item lists so [ and ] work in a browser
improvise-p1ym. GridCache::page_coords carried (category, selected item), so
PageNext/PagePrev resolved to a command name the browser held but could not
compose an argument for. They reached Host::handle, which answers an empty vec,
so a smoke test found them inert on the live page.

The item list is not a second field beside the selection: page_coords becomes
Vec<PageAxis>, and `items` is Some exactly when the axis is Axis::Page. That
puts gotcha 27's split in the type — a Filter axis constrains without being
navigable, so it carries no list and no caller can compose a cycle for one.
GridLayout::page_coords stays filtering_cats(), which is right for the bar,
and navigation.rs::page_cat_data stops spelling the Page-vs-Filter rule a
second time: it reads the layout's list.

The browser's page_cycle mirrors PageNext/PagePrev line for line, odometer
carry included, and `[`/`]` leave the still-needs-the-host list.

Two decisions stated in context/code/core.md. The list is
effective_item_names — the same call that already chose the default selection,
so selection and list come from one place; page_cat_data used
ordered_item_names before, which for a _Measure page axis let the default
selection be a formula target `[`/`]` could not reach. And the list is NOT
filtered to visible items: the TUI cycles hidden ones, and making the browser
differ would be the divergence rather than the fix.

The lockstep test's first version was vacuous — it rebuilt the client's cache
from the host and compared it against that same host. It now drives two
independent documents and compares View::page_selection; the injection that
reverses the odometer's significance now fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:05:54 -07:00
Edward Langley 555d9f1ca7 feat(protocol): fifteen more bindings resolve on the client (improvise-e24v)
`cycle-panel-focus`; the category panel's `cycle-axis-at-cursor`,
`open-item-add-at-cursor`, `filter-to-item`, `toggle-cat-expand` and
`delete-category-at-cursor`; the view panel's `switch-view-at-cursor`,
`create-and-switch-view` and `delete-view-at-cursor`; tile select's
`set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's
`help-page-next`/`help-page-prev`; `commit-buffer` in all three kinds; and the
two cell writes ADR 002 lets a thin client name, `clear-cell` and `paste`.

Each mirrors its `Cmd` line for line, status sentence included — a client that
emitted the mutation without the sentence beside it would diverge on the status
line and nowhere else. Two orderings are copied rather than rationalised:
`CommitBuffer` writes its optimistic status first (improvise-6t3) and
`TileAxisOp` writes its axis first.

**A divergence found and fixed on the way.** `effect::HelpPageNext` clamps at
the last page and `HelpPagePrev` saturates at zero, while `reduce_view`'s
`Command::HelpPage` arm *wrapped*. Two derivations of one fact, disagreeing,
invisible because `h`/`l` in the help overlay had always been upstream keys and
nothing had ever sent the command. `walking_off_the_end_of_the_help_pages_
agrees_with_the_host` was written first and failed — client 0, host 4 — and the
wire was then changed to match the effect, since the effect is what ships in
the TUI. Whether both should wrap instead is improvise-trd3.

**Three names the ticket listed do need enrichment**, filed as improvise-4bwa:
`toggle-prune-empty` words its status off `View::prune_empty`, which is in no
cache; `delete-formula-at-cursor` needs `(target, target_category)` and
`FormulaCache` carries only the raw text; and `view-back` emits
`ApplyAndClearDrill`, which is deliberately off the wire (gotcha 38) and is
blocked on ADR 001 layer 2 rather than on a projection.

The lockstep harness is new and is the evidence: it applies what the client
composed to a **second authoritative host** and compares the documents as
`.improv` text (`Debug` prints the symbol table's `HashMap`s, so it differs per
process — gotcha 18). Four injected faults — a wrong axis, a wrong status
sentence, a constant view index, and `AddCategory` for `AddItem` — each fail a
named test; the second one did *not* until the fixture was made to sit on a
category header as well as an item row.

`the_command_names_that_still_need_the_host_are_these` shrinks by exactly what
closed. 1318 tests pass, up from 1311.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:05:17 -07:00
Edward Langley 0f14b2a39e fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.

Rather than a second "but not really" flag beside CellId (the two-answers
shape ADR 002 exists to forbid), a formula-owned cell now projects a different
kind of identity. Derived carries the formula's target and no surrogate, so
the write is unconstructible rather than refused.

The predicate is Model::formula_for(&CellKey), one home, which already existed
three times open-coded inside model/types.rs. The TUI lands in the same change:
EditOrDrill calls the same predicate and refuses, or the two front ends
diverge.

Two orderings are load-bearing and tested: Derived is asked before the record
branch (the coordinate is stored), and after the aggregate branch (gotcha 10 —
an aggregating formula cell is still drillable).

Two tests were passing while their names were false for the Profit column and
are renamed and two-sided now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:59:59 -07:00
Edward Langley 3d3bec43e0 docs(web,harness): cap the JavaScript and the browser harness
web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two
ordering rules — including the one nothing here can test, that an `async`
message listener does not serialize — are now prose in context/code/clients.md.
The harness keeps its own section: why a worker, why a report, the engine
coverage and what `wipe` deliberately cannot clear.

harness/tests/*.spec.js's comments were the record of what each assertion
proves; they are in context/test-evidence/clients.md under the exact test
titles, including the `test.fail` measurement that could NOT become a CI guard
because its trigger is the host's network stack, and the closed-invalid
improvise-xps8 report that a simulated fix appeared to confirm.

ws-server/src/main.rs's ```text fence was prose claiming the doctest
exemption, not a runnable doctest; the synopsis is now two lines and the full
form is in the prose file. No fences remain in this unit.

Verified: node --check on every JS file touched; the non-comment diff for both
'*.rs' and '*.js' is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:02:03 -07:00
Edward Langley 8ed3df8ce3 docs(wasm-client): move the fold, render and lockstep arguments to context/
lib.rs's crate statement, socket.rs's frame/wording/composition arguments
(including the "obligation stated but not discharged" note on `typed`),
render.rs's four silent-if-wrong decisions and state.rs's two dispatch
properties are now prose in context/code/clients.md. The test narratives from
both integration tests — the visual-vs-data row confusion Copilot found on
PR #23, the multi-command frame improvise-955n had to keep accepting, and the
lockstep fixtures' deliberate shapes — are in context/test-evidence/clients.md.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:57:39 -07:00
Edward Langley c386e34a2b docs(wasm-client): move the key-resolution doctrine to context/
keys.rs's 78-line module doc, the 55-line strands_the_user argument and the
per-arm reasoning move to context/code/clients.md; the ten test narratives —
including the two Copilot findings on PR #27 and the "an arm asserting
something false certifies what it was added to rule out" lesson — move to
context/test-evidence/clients.md, keyed by test name.

The named list of command names that still need the host stays in the test, as
gotcha 47 requires; the prose now says so explicitly.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:54:05 -07:00