Commit Graph

57 Commits

Author SHA1 Message Date
Edward Langley a55e7dd5b7 Merge branch 'main' into wave/n-browser-boot 2026-09-21 14:51:37 -07:00
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00
Edward Langley 72b77566c5 fix(web,harness): the demo's second view was the phantom improvise-m4rv removed
CROSSES TWO BOUNDARIES OUTSIDE THIS AGENT'S GROUND, deliberately and reported:
web/demo.improv and harness/tests/contract.spec.js. Dropping the invented
Default view takes the shipped demo from two views to one, and the spec
asserted the phantom in two places -- improvise-m4rv's acceptance criteria
name the spec directly.

The load-bearing half is the improvise-iioi click test, which needs the view
panel to hold two rows so that a click can name a DIFFERENT row than the
cursor does; with one view both readings give the same answer and the test
stops discriminating. The phantom was supplying that second row by accident.

So the demo gains a real second view, Season -- a genuine alternate pivot
(Quarter by measure, paged by Product) rather than an auto-arrangement. Plan
stays first and stays Initial View, so the cursor still starts at index 0 and
tests/web_demo_model.rs is unaffected: every visible cell of the ACTIVE view
is still a Record (gotcha 46). Verified by cargo.

The spec's axis assertion at index 0 moves from the phantom's "R:Product
C:Quarter" to Plan's own "R:Product P:Quarter", derived from
viewmodel::view_axis_summary rather than guessed.

NOT VERIFIED: harness/ has no node_modules and web/pkg is unbuilt here, so
Playwright could not be run. These edits are mechanical substitutions and
need a browser-job run before they are believed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:24 -07:00
Ed Langley f152d105e6 Merge pull request #87 from fiddlerwoaroof/wave/harness-generator-gp
Wave G: the generator's real cost, the artefact families, and two harness gaps
2026-09-20 22:10:19 -07:00
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 3542d6f717 fix(tooling): a leaf is not an identity, and a convenience flag was a bypass
Both blockers from the adversarial review of #83, both in the mode this PR
rewrote, both producing a pass while the gate did not do what its prose said.

1. cargo_test_names reduced every listed path to its last segment. Six leaf
names collide in this workspace and two are cited in evidence, so a heading was
satisfiable by a test in another module answering another question -- renaming
ui::project::tests::a_load_that_fails_is_not_a_rebase, the one its entry
describes, left the checker at exit 0 with the ui::host namesake answering for
it. Paths are kept whole; a bare leaf resolves only when unique, and an
ambiguous one is REFUSED with both candidates named. _RUST_NAME accepts `::`, so
a qualified heading is a claim rather than prose, and a citation is matched on
the leaf so qualifying a heading cannot orphan its own test. The two colliding
entries are qualified. Neither half suffices alone: rename one of a colliding
pair and the leaf becomes unique again, which is why qualification is the fix
and the refusal is what forces it.

2. --rust-tests called cargo_test_names with one argument, so the --ignored
subtraction never ran, and it printed a byte-identical success line. It now
requires --rust-tests-ignored (one alone exits 2) and the line names its source.

Also, from the same review: a missing context/test-evidence/ exited 0 while 170
citations pointed at nothing (early return, now gone); the doc-site "runs last"
check accepted a trailing `echo` that merely NAMED the generator, so it asks for
an invocation; bundle_freedom gains an arm for a transitive `import "../app.js"`,
which reaches the bundle while naming nothing forbidden; and two prose overclaims
are corrected -- the prose-heading count is a convenience rather than a guard
(git diff is the mechanism), and the ratio rule says "above the run MEDIAN",
which is defensible where "above the others" is false against second place.

scripts/check-ci-graph.py makes claim 6's rule a gate: pages-deploy must `needs`
every other job in ci.yml. The rule was stated in the last commit and a
zz-new-check: job added during review was still ungated with nothing noticing.
README now states the rule and lists no jobs, the second copy being what went
stale twice.

Every fault re-injected and observed; figures in context/test-evidence/tooling.md
rather than only here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:42:07 -07:00
Edward Langley 29cdab1a4a Merge remote-tracking branch 'gh/main' into worktree-agent-a97453f7fa65d51b9 2026-09-20 21:36:21 -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 8af37d44a1 feat(ci): two conventions become gates (improvise-w8ih, improvise-4ktt)
The stamp gate is scripts/assemble-stamp.sh, taking FILE PATTERN pairs, and
scripts/assemble-stamp-test.sh is its check_the_checker. Deleting the final
`test -z "$missing"` makes four of its eight cases fail; before the extraction
that same deletion was invisible, both misses printing while assemble.sh exited
0. It is a separate script because exercising it inside assemble.sh would need a
fabricated web/pkg, which is a second answer to "what does the site assemble
from" (improvise-vvl3). Wired into the `build` job, first, because it needs
nothing and guards a step in that job.

web/tests/bundle_freedom.test.js refuses a flat web/tests/ file that names the
bundle directory -- the constraint keeping the `js` job at seconds rather than
~4m30s, stated in prose and checked by nothing. It scans its own filename too,
so it cannot become the place a violation hides, and its companion asserts
web/tests/bundle/ still holds a test that DOES use the bundle: a scoping rule is
uninteresting if nothing falls outside it.

Both faults were injected and watched to fail; evidence in
context/test-evidence/tooling.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:11:08 -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
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 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 3bb484b1f3 fix(web): the stamp guard names every missing stamp, not the first (improvise-x4wv)
`stamped` exited on the first failure, so breaking two substitutions named
app.js and never reached worker.js -- and the comment ("names itself when it is
the one missing") read as though it did not. It fails safe, which is why it was
filed rather than fixed in PR #47; the code is fixed rather than the comment
because the diagnostic is paid for in wasm-pack rebuilds, the slowest loop in
this repo.

Each stamp is still verified on its own -- the property PR #24's finding bought
-- but `stamped` records and a gate after the four calls fails the build.
Proved by injection both ways: two broken sedi patterns now name both files and
exit 1, and deleting the gate prints both failures while exiting 0, which is
the check-that-does-not-check the comment now warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:55 -07:00
Edward Langley ec03f950b3 Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks 2026-09-20 14:36:19 -07:00
Ed Langley 74ccd6f030 Merge pull request #65 from fiddlerwoaroof/fix/socket-close-routing
fix(harness): restore the watchdog close-routing guard PR #61 deleted
2026-09-20 14:31:28 -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 ad160b8b54 Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks 2026-09-20 14:24:18 -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 1a0f04856e fix(docs): the review's four rendering defects, and the check that could see one
Review of PR #62 found four live defects and four smaller ones. The most
valuable is the first, because of what it says about the rest.

A TOC entry wrapping a heading whose rendered text already contained a link
emitted <a href="#anchor"><a href="github...">...</a></a>. The parser closes
the outer anchor early, so the entry became an empty unclickable stub and
clicking "src/draw.rs" in the TOC navigated to GitHub. 19 occurrences over 10
pages. Both hrefs resolved perfectly, so the link check, the whole-site link
check and a screenshot pass all missed it -- it answers "does every href
resolve" and this is not that question. So the fix is two things: the TOC keeps
a tag-free variant of each heading, AND a new check asserts no <a> appears
inside another <a>. Proved by reverting the first half: 66 nested anchors
reported, assembly exits 1. Clicking that TOC entry now lands on #src-draw-rs.

esc() escaped & < > but not the quote it was interpolated into title="..."
with. Five shipped bead titles contain one, and Chromium parsed the resulting
tag as having three junk attributes. Now escapes " and ' as well. Today's
corpus being friendly is not a reason to interpolate tracker text unescaped.

Seven pages overflowed at 390px by up to 92%. The reviewer attributed it to the
TOC; the TOC was real but not sufficient, and the first fix measured clean by
getBoundingClientRect while the page still overflowed, because the heading text
overflows its box without widening it. Only per-element scrollWidth names the
culprit: hundreds of snake_case test names as headings. overflow-wrap on the
headings and on .toc a, plus dropping display:table from .tickets, which had
been cancelling the mobile overflow-x guard. 0 of 23 pages overflow at 390 and
1280 now, measured.

table() split rows on every pipe before inline parsing, so `|Time Period|` --
this repo's own quoting syntax, in a corpus whose subject is that syntax -- was
shredded into extra columns. A row that still parses, so no column count could
see it. Now walked, respecting code spans and \|. Proved with a fixture: 9 tds
with the fix, 12 with the old splitter reinstated.

The four smaller ones: the link check counted 48 links it never verified behind
a comment promising they were "checked below" when there was no below -- those
are the links leaving the docs tree, and they are now resolved against the
filesystem, which is why assemble.sh runs the generator LAST, once its siblings
exist. The gotcha jump bar's hardcoded 48 is derived from the emitted anchors,
because it failed closed on deletion and silently on addition, and the list only
grows. context/code/doc-site.md cited improvise-xxxx and so broke the audit the
surrounding paragraph describes; the repo-wide audit is now 189 ids, all
resolving. And the Conventions paragraph's own example path now goes through
inline(), so the sentence demonstrates the linking instead of asserting it.

The ticket index keeps all 180 rows, open bugs and titles included, by the
user's ruling: the data is already public in .beads/issues.jsonl, the page only
makes it legible, and filtering it while the lede links the raw JSONL would be
theatre.

Gates: 1378 rust tests, 26 node --test, 16 playwright (3 skipped, webkit OPFS),
all three check-comment-runs modes, 3460 site links / 0 broken, 0 nested
anchors, 189 bead ids all resolving.

improvise-djoo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:56:56 -07:00
Edward Langley f30d512169 Merge remote-tracking branch 'gh/main' into feat/doc-site 2026-09-20 13:50:42 -07:00
Edward Langley f1011d7f6f feat(docs): publish the context/ prose as a site folded into Pages
The landing page is the site root and the app is /app/; the prose now sits
at /docs/, assembled by the same web/assemble.sh the Pages job and the
browser harness both run, so what ships and what is tested are one tree.

Published: design-notes, design-principles, repo-map, the three ADRs, the
seven context/code/ arguments, and the 501 test-evidence entries as their
own clearly-labelled section. Not published: context/plan.md, which is an
agent prompt about a launch rather than a description of the program, and
context/cloud-agents.md, which is addressed to nobody who visits a website.
Neither is referenced by a published document outside a code fence, so
leaving them out dangles nothing. The reasoning is context/code/doc-site.md,
which is itself published.

Two things the corpus needed that a generator would not have given it. The
~190 improvise-xxxx citations resolve to a generated ticket index built from
the already-public .beads/issues.jsonl rather than to an invented URL that
would 404; an id the export does not yet carry stays plain text. And a code
span that is wholly a repo path links to GitHub at the assembled ref, but
only when the file exists on disk, so a stale citation degrades to text.

The generator link-checks its own output and exits non-zero on a break, and
assemble.sh runs under set -eu, so a dead cross-reference fails the assembly
rather than shipping. It earned that on its first two runs: it caught a
fence-close regex comparing a three-character marker to a one-character
string, which swallowed every document from its first code block onward, and
a list parser that ended the gotcha list at the first blank line, emitting 1
of the 48 anchors the jump strip links to. Proved by injection afterwards --
renaming the anchors reports 48 missing anchors, pointing the ticket links at
an ungenerated page reports 763 missing pages.

No framework, no node_modules for the site, no build step beyond one
zero-dependency node script, matching the landing page's posture.

improvise-djoo

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:37:20 -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 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 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 82ef93d4cb Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend 2026-09-20 11:48:28 -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 2ccd21f3e8 docs: the landing page is the front door, the app moves to /app/
User ruling on improvise-kh8, overturning the arrangement the previous commit
shipped: "The landing page should be served at the root of the repository and
link to the in-browser demo." The argument for the other arrangement is left
standing in the ticket rather than deleted — it is overruled, not wrong to have
considered — and the root is now `docs/index.html`.

The app moves under `app/` WHOLE, both bundle directories included. Every path
in `app.js` and `worker.js` is relative, so moving the bundle together keeps
every one of them correct and the wasm-pack generated glue untouched;
`worker.js`'s `fetch("demo.improv")` resolves against the worker's own URL, so
the seed follows for the same reason. The four `stamped` guards in
`assemble.sh` follow the files: they are the only check that the cache-bust
substitution worked, and a guard aimed at a path nothing writes fails in the
direction that looks like success.

`harness/tests/shell.spec.js` navigates to `/index.html` six times, including
the `?ws=` and `?doc=` variants, and every one of those now lands on the
landing page. All six are repointed at `/app/index.html`. That file is outside
this unit's declared scope and is the second deliberate stray in this PR.

VERIFIED AGAINST THE ASSEMBLED TREE, not against `docs/` directly, because a
path move is what re-arms the trap this page already caught once. With real
bundles from `web/build.sh`:

  * `npx playwright test tests/shell.spec.js --project=chromium` — 6 passed,
    with `/app/{index.html,app.js,style.css,worker.js,demo.improv}` and both
    `?v=` stamped bundles all served 200;
  * the four casts play 37 lines each at the site root, no failed request and
    no page error;
  * the full journey: load the root, click the call to action, land on `/app/`,
    and the grid draws out of the in-page worker — 12 cells, all editable,
    a "Revenue" header, `#status` reading "in-page worker · demo.improv".

The call-to-action check was proved able to fail by pointing the link at
`./aap/`: 404, while the page still looks entirely correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:12:43 -07:00
Edward Langley 161e36ea60 docs: a landing page, served beside the app rather than in front of it
improvise-e61. Vanilla, single file, 192 lines, no build step: the four casts
improvise-d4w recorded, played by asciinema-player pinned from jsdelivr.

improvise-kh8 recorded a collision to resolve before starting — Pages already
serves the standalone web app at the root, so a landing page either replaces
it, sits beside it, or is dropped. It sits beside it at `/landing/`, because
kh8's own note is right that the live app is the better front door, and a page
the deploy never copies is a file nobody can reach. `web/assemble.sh` copies
it, so the browser harness serves the same tree the deploy publishes and there
is still one answer to "what ships".

The player config deviates from the tickets, deliberately. improvise-d4w's
description and improvise-e61's criteria both say 100x30; every cast header
says 120x37, so the recordings never complied with their own ticket. A 30-row
player clips the bottom seven rows of a 37-row cast, which is exactly where
the tile bar and the status line are, so the page declares the geometry the
casts actually have.

e61 also asked for the casts to be checked against the current UI before
embedding. They were: the tile bar's short_label vocabulary and the mode line
still match, but the casts predate improvise-2zq's `+cat` column by five
months and every one of them opens on the recorder's shell prompt. Filed as
improvise-2oxt rather than silently embedded as current.

Verified by driving the page in Chromium: four players construct, each renders
37 terminal lines after play, no failed request and no page error — against
both `docs/` directly and the assembled `site/landing/`. Counting the players
alone was proved worthless first: with autoPlay false the cast is not fetched
until the click, so a page whose every cast path 404s constructs four players
and throws nothing. Breaking `./casts/` to `./cats/` passes the construction
check and fails the play check with four 404s. improvise-bgtf carries that as
a harness spec, since nothing in CI asserts any of it yet.

README: `cargo install improvise` was false — the crate is not on crates.io —
and the Pages description named two of the three gates a deploy waits on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:04:00 -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 22360d46e5 Merge main into wave/a-grid-frame
Two conflicts, both expected.

**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.

**`lib.rs`'s composition test.** Main's deletion taken: 18189c1 moved
`client_showing`, `published_basis` and
`a_client_composes_a_write_from_the_id_it_was_given` into `socket.rs` when
it consolidated `set_cell_at` and `typed`.

Main's new `GridUpdate::RowLabels`/`ColLabels` call sites are converted, and
five doc comments naming the retired spellings with them — a fact stated in
several places is a grep, not an edit.

1327 passing, 0 failing. clippy/fmt clean, wasm gate clean, both bundles
build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:10:15 -07:00
Edward Langley d9a4790d4d fix: an empty group name is a name; and stop reserving the group rows
Copilot's seventh pass. One applied, one applied by *withdrawing* the
previous fix rather than patching it again.

**`RenderColGroup::label` is an `Option<String>`.** `""` was the
ungrouped-run sentinel, but an empty string is a legal group *name* —
`Category::add_item_in_group` accepts one and the TUI draws its indicator
like any other — so a real group serialized identically to no group at all
and the page dropped its indicator on a truthiness test. `None` is now the
run in no group, and `app.js` checks for null.

**Inline group rows are no longer reserved in the viewport, and the reason
is written down.** Copilot was right twice in a row here, about two
opposite mistakes. Counting the current group rows is a feedback loop (a
smaller window crosses fewer groups and asks for a larger one). Keeping the
maximum is a ratchet: one group header per data row stores an oversized cost
on the first oversized projection and clamps the grid to a single row
*permanently*. Both were on this branch.

The third option — invent a density heuristic — is the one this repo warns
against, in a file no test here can execute. So the reservation keeps only
what depends on the axes and not on the window: the header lines, the group
line, the row-header width. The residue is an over-request bounded by the
groups in view, and the off-screen-selection gap it leaves is neither new
nor specific to groups — nothing in this page calls `scrollIntoView` at all.
Filed rather than guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:45:08 -07:00
Ed Langley 8528843341 Merge pull request #24 from fiddlerwoaroof/wave/j-browser-harness
WU-J: a headless browser harness, and the first execution of OpfsFs (improvise-vvl3, improvise-vhcz, improvise-hmws; improvise-xf1 stays open for Safari).

storage_contract had been cited for months as the written-down spec OpfsFs must satisfy in a browser. The first time OpfsFs was handed to it, it failed on its first assertion.
2026-09-19 23:44:15 -07:00
Edward Langley fb2122f23d fix: commit the high-water mark unconditionally, coalesce groups by name
Copilot's sixth pass; both correct, and the second is a front-end
divergence of exactly the kind this PR exists to remove.

**The high-water mark was committed only when the extent changed.** A group
count that rose while a header line fell by the same amount left `next.rows`
equal, so the larger count was discarded and a later repaint under-reserved
— the DOM-scroll bug again. `frameCost` is now assigned unconditionally and
only `sendViewport()` is gated on the extent moving.

**`col_groups` started a new run when `collapsed` differed; the TUI does
not.** `src/ui/grid.rs` breaks a run on `group_name != prev_group` alone.
Collapse is category-scoped, so two adjacent groups from different
categories may legally share a name and differ in it, and the browser would
then have drawn a second indicator the TUI suppresses. Coalesced by name,
with the run taking its first group's indicator, as the TUI paints it.
`column_groups_coalesce_by_name_as_the_tui_does` pins it; restoring the
`(name, collapsed)` key splits the span and fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:37:50 -07:00
Edward Langley ff351ee014 fix: reserve the group rows monotonically, and bulk the ancestry compare
Copilot's fifth pass; both correct.

**Inline group rows are back in `frameCost`.** Omitting them was the same
failure I had just fixed for the header block: they take real table height,
so any viewport containing one asked for more data rows than `#grid` could
show, and the DOM takes its own scroll position while the client's cursor
offsets come from the larger window. Copilot's own suggestion answers the
oscillation objection — the reservation is a **high-water mark**, which
cannot oscillate because it only rises, and it rises in the safe direction:
over-reserving leaves blank space, under-reserving is the bug. It resets on
a view change so one dense document does not make every later view
pessimistic.

**`same_prefix` is replaced by `AxisFrame::shares_ancestry`**, the bulk
form, for the same reason `header_labels` and `groups` are bulk: called per
(column, level) it scanned the frame twice apiece, quadratic in the columns
drawn and growing with the horizontal offset, where the pre-anr9 widget's
materialized lookup was O(1).

`same_prefix` is **deleted** rather than kept beside it — two answers to one
question is the thing this PR exists to remove, and keeping a per-position
form that nothing calls would be an invitation. `shared_ancestry_widens_as_
the_level_rises` checks both arms, because a version comparing whole tuples
passes the leaf arm and a version comparing only the outermost label passes
the outer one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:29:48 -07:00
Edward Langley 9aef855d5f fix: the viewport reserves the real frame, and a fallback width counts cells
Copilot's two "previously missed" findings, both correct.

**`extent()` reserved one header row and one header column.** That was right
until this PR: the column header is now one line per level plus an optional
group line, and the row header is `row_levels` columns wide. Announcing a
viewport the page cannot show matters because `#grid` has `overflow: auto` —
the DOM would take a scroll position of its own while the client's cursor
offsets were computed from the larger window, putting the selected cell off
screen. The cost is measured off the painted model and re-announced when it
changes.

Inline group *rows* are deliberately **not** counted, although they occupy
space: they depend on how many rows the window asked for, so counting them
would be a feedback loop — a smaller window crosses fewer groups and asks
for a larger one. The three facts that are counted depend only on the axes.
The residue is a little over-fetch, the direction `ROW_PX` already errs in.

**`fit_to_labels` measured its fallback width in Unicode scalars.** It is
documented as the width a column is drawn at while `ColWidths` catches up,
and the authoritative widths are in display cells, so `日本` came out two
cells wide where it needs four. Now measured with `UnicodeWidthStr`, like
`header_widths` and like the TUI.
`a_column_without_its_width_yet_is_measured_in_display_cells` pins it, and
restoring `chars().count()` fails it with `[2]` against `[4]`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:22:30 -07:00
Edward Langley 5bfa747942 fix(assemble): verify each cache-bust stamp on its own, and rename a test
Two Copilot findings on PR #24, one applied as asked and one applied only
in part, with the measurement that says why.

**The combined `grep` was a real hole.** `grep -n "?v=" a b c d` succeeds
when *any one* file matches, so under `set -e` three failed substitutions
still produced a "successful" assembly. Those greps are the only guard
against the generated wasm-pack glue changing shape — which is the exact
failure this cache-busting exists to end, twice, during improvise-9x6 — so
each stamped reference is now checked on its own and names itself when it
is the one missing. Inherited from the inline CI step rather than written
here, but it became mine when it became a script. Proved by pointing one
`sed` at a pattern that cannot match: the old form exited 0, the new one
exits 1 naming the file.

**The repaint count was the right objection to the wrong remedy.** Copilot
asked for "count the relevant grid mutations/repaints and prove the
assertion by removing the guard". Done, and it does not discriminate: with
`if (closed) return` removed from `edit`'s `close`, `close` still runs
exactly twice, `typed_cell` is still called exactly once, `#grid` still
takes exactly two childList mutations, and the final text is identical. The
only difference is one extra *entry* into `repaint()` that produces no DOM
change — because the second close is `close(false)`, so it does not
re-send, and everything else it does is idempotent. Which is exactly what
improvise-hwpi meant by "harmless before, but only by accident".

Counting `repaint()` entries would need a test hook inside `web/app.js`: a
page that knows it is being tested. So the half of the finding that stands
is the **name**, which claimed more than the body checked — the same defect
as `open_and_refresh_see_an_empty_origin_root` two commits ago. Renamed to
`committing an edit sends one frame and shows the host's value`, and the
measurement is recorded in the comment so nobody repeats it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:54:13 -07:00
Edward Langley 7c4b90c537 fix: four Copilot findings on the grid frame
All four were right; two more were right and are declined on the threads.

**A frame's pre-window headers were O(offset).** `axis_frame` accumulated
every group header above the window and flushed the run at the first
in-window item, so a deep scroll past many groups serialized headers that
`body_from` discards on arrival. Only the *nearest* one can ever be answered
with, so only that one is kept.

**`web/app.js` captured the row counter, not its value.** `r` is one binding
shared by every click-handler closure, so after the loop every editable cell
would have called `edit` with the last data row. The group-aware counter was
right; capturing it was not. It is a bug this PR introduced — the old code
took `r` as a `forEach` parameter, which is per-call.

**The agreement suite drove one arm.** It only read `rows`, and its fixture
had no grouped column axis, so `col_levels` and `col_groups` could regress
behind a green suite
(`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`).
The fixture now groups two of three months under `Q1` and leaves `Sep`
outside, with `_Measure` giving a second column level, and
`the_two_front_ends_draw_the_same_column_header` checks the levels against
the painted header lines and the spans against the column count. One span
per column, and a dropped level suppression, were each injected and watched
to fail.

**The group branch did not compare the label.** It asserted only that the
painted text began with an indicator, so any group name passed. It now
compares the whole painted header against the browser's glyph-plus-label.

The column test also *demonstrated* the finding this PR declines: `Q1` spans
all six columns including the two under the ungrouped `Sep`, because
`expand_category` emits a header on entering a group and no boundary on
leaving one. That is `GridLayout`'s rule and the TUI paints the same span.
It is asserted as it is, with the defect and the corrected expectation
written next to it, and stated on `AxisFrame::group_of`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:52:18 -07:00
Edward Langley 7e991360df test(harness): a headless browser, and the first execution of OpfsFs
improvise-vvl3, absorbing improvise-xf1. The condition the user set when
deferring browser testing — "WE HAVE TO HAVE SOMETHING TO TEST" — has been
met: after improvise-9x6, djm, hwpi and the demo model there is a complete
self-contained application whose entire untested surface is what a harness
covers.

`python3 -m http.server` plus one Playwright project, in `harness/`:

  harness/opfs-contract/   a wasm crate that runs
                           `improvise_io::storage::contract::storage_contract`
                           against `OpfsFs` — the shared function, not a
                           transcription of it
  harness/www/opfs/        the page and the dedicated Web Worker it runs in;
                           `createSyncAccessHandle` exists nowhere else
  harness/tests/           two specs
  harness/build.sh         site + harness bundle -> harness/serve

It lives at the repo root rather than under `web/`, because `web/` is the
*source of what ships* and every file in it is copied to the site. The
harness serves the **assembled** tree (`web/assemble.sh`, extracted from
the CI workflow in this commit so there is one answer to "how is the site
arranged" rather than two) plus its own `/opfs/` — a harness that runs
against a different arrangement of files is testing something nobody
deploys. `harness/opfs-contract` is deliberately not a workspace member: it
only builds for wasm32, so a member would break `cargo test --workspace`.

What this runs that nothing has ever run:

- `storage_contract` against `OpfsFs`, at `""` **and** at `"docs"`. Two
  bugs fell straight out — improvise-vhcz's `list` path shape, whose fix
  the `"docs"` run guards (reverting it fails with `lists the one
  document`), and the zero-byte entry fix in the previous commit, which
  this found by failing on `starts empty`.
- The `keys()` async-iterator walk in `refresh`, hand-rolled through
  `js_sys::Reflect` and named by xf1 as the least certain code in the file.
  Three entries written out of order, so one passing by accident cannot
  carry it.
- `FileSystemReadWriteOptions`' `at` on read and write: a long write then a
  short one, which is where an append or a non-zero start shows up.
- `commit_deletes`, both halves — queued but still in OPFS before, gone
  after — and **across a reload**, which is the only way to ask whether
  OPFS persisted. That reload is also improvise-bck's persistence criterion
  at the storage layer.
- improvise-hwpi's three one-assertion shell tests, which that ticket said
  it could not write: status after a bad frame, status with no peer, one
  repaint on commit. Plus the first-frame watchdog, which was found by
  reading and only because someone went looking.

Check the checker, throughout, because a harness that passes against a stub
— or that silently skips because the worker never started — is exactly this
repo's characteristic defect wearing a green tick:

- `run_canary` deliberately fails, through the *same* reporting path every
  real check uses, and the spec asserts it is reported as a failure with its
  message intact.
- The spec asserts the report contains exactly the expected check names, in
  order, so a battery that quietly shrinks is a failure.
- Every shell assertion was injected and watched to fail: removing the
  bad-frame catch, the watchdog's `setTimeout`, the socket `error` listener
  and the `transport.send(frame)` on commit each fails its own test and
  only its own.
- The commit test types `20000` and asserts `20,000`, so a page that echoed
  what was typed instead of drawing what the host returned would fail.

One live bug is pinned rather than fixed. `a peer that cannot be reached`
asserts the *history* of `#status`, because improvise-m18f is real and
reproduced here: the socket's `close` listener overwrites the precise
"cannot reach" with "disconnected". That is filed and deliberately not the
same fix as the watchdog's (gotcha 48), so the test asserts both halves and
will speak up whichever one moves.

CI gets a `browser` job that can fail the build, and `pages-deploy` now
needs it. Playwright's Chromium download is ~80 MB and well under a minute
on ubuntu-latest, next to the two wasm-pack builds the job already needs —
so "impractical in this CI" does not arise. A harness nobody runs is worse
than none, because it reads as coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:45:03 -07:00
Edward Langley 301a74821a feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by
`GridLayout::row_label`, which slash-joins a multi-level tuple — the exact
rendering `two_row_categories_produce_cross_product_labels` asserts must not
appear. So the protocol carried a form of the labels the TUI itself
considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely
that form: the browser showed `Food/Alice` where the TUI showed two header
columns, with no groups and no collapse indicators (gotcha 44).

Both fields become one `AxisFrame` per axis, carrying the five facts gotcha
44 enumerates: the categories (hence the header levels), the label *tuples*
and the inline group headers, the group a position sits in, and each group's
collapse state. `record_col` stays off — session-local, keys pending drill
edits, correct to keep off.

It is one type and not a `row_levels` beside `row_labels`, because two
answers to "what are the labels" is the disease (improvise-edp). The rules
that used to be written twice are methods on it and are now called from both
front ends: `shows_level` (repeat suppression, improvise-jjx's fix),
`group_of`, `body_from`, and `header_widths` — the row-header width rule,
which existed twice already, inline in `render_grid` and again in
`viewport::compute_row_header_width` (improvise-1125).

`GridWidget` loses its `&View`: nothing in it reads the view any more.

Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization
over a grid with row groups, column groups, a page axis, multi-level headers
and a total row — passes byte for byte, so the TUI is unchanged. The new
`tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and
the browser client through a `Snapshot` of the *same* cache, and compares
what each draws; re-introducing the slash-join on either side was injected
and watched to fail before the test was kept.

`tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is
deleted: it guarded the gap this closes, and a consolidation that keeps its
compatibility test is unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:43:47 -07:00
Edward Langley 16471d7def docs: the proxy is the open door, not the closed one (Copilot review)
Both Copilot findings on PR #21 applied. The first is the serious one and it
is a defect in documentation this PR itself introduced.

`improvise-ws-server` has NO authentication -- a deliberate MVP limit its own
module doc states -- so anyone who can open a socket to it can rewrite every
document it serves. Loopback binding is the only thing standing between that
and the network, and terminating TLS in front of it REMOVES EXACTLY THAT
PROTECTION. The previous text not only omitted the warning, it actively
reassured the reader: the `--bind` note explained that keeping loopback means
the port "is not reachable from anywhere else", which is true of the port and
false of the deployment, because the proxy is public. TLS buys
confidentiality, not access control, and the two read alike in a recipe.

So the warning leads the section, the Caddyfile carries `basic_auth` with a
comment saying it is not optional, the `--bind` note now says what it does and
does not buy, and the tunnel line says cloudflared/ngrok print public URLs and
authenticate nothing by default -- an unguessable URL is not a control.

Second finding: the watchdog comment was ambiguous in a file whose only
verification is reading, which makes an ambiguous comment a real defect. It
said a drop before the first frame "leaves the watchdog armed", meaning the
state on ARRIVAL; it reads as a claim about the state after the call, which is
the opposite of what `setShellFailure` does. Reworded to say the call disarms,
why the opened path needs that (the watchdog is armed only after
`transport.ready` resolves, i.e. on `open`), and that the never-opened path
makes it a no-op.

No open ticket covers server auth -- improvise-j31x is the multi-document
registry and is closed -- so nothing is cited here and one is proposed in the
hand-back rather than an ID invented.

1184 passing, unchanged. `node --check` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:43:41 -07:00
Edward Langley d120596a10 docs: the supported way to reach a TUI-hosted document over https
Per the user's ruling on improvise-o8fa: document the reverse-proxy path
properly, implement no TLS.

The existing text already named the symptom correctly -- an https origin
refuses `ws://` as mixed content, `improvise-ws-server` has no TLS, so
`wss://` is unobtainable from it -- but it gestured at "a reverse proxy or a
tunnel" and stopped, which is not something a reader can follow. So README
gains a worked Caddy example (Caddyfile, both commands, and the `?ws=` value
to reload with), the nginx difference that actually bites (`proxy_http_version
1.1` plus the Upgrade/Connection headers, without which the handshake is
answered as an ordinary request), the cloudflared/ngrok one-liners, and the
note that `--bind` should be left at loopback behind a proxy on the same host.

It also says the cost rather than papering it: the documented path to sharing
involves a second piece of software, which is a poor answer for a tool whose
selling point is being one binary. And it says what that cost buys against --
the standalone worker deployment is the point of the Pages site and needs no
server at all, and `http://localhost` pairing needs none of this either.

Native `wss://` is recorded as DECLINED WITH REASON rather than left as a gap.
The substance is the certificate, not rustls: a self-signed cert makes the
browser refuse the socket with no usable error, because a websocket handshake
has no interstitial to click through. A real cert or mkcert are the only
pleasant options and both are a second piece of software too -- so the choice
is not one binary versus two, it is two either way, with one option
additionally shipping a TLS stack in a pivot-table binary.

web/index.html is the second home for the same fact and is updated to point at
the worked example rather than restating it (gotcha 48: a fact stated twice is
a grep). Both were checked for text the correction left stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:38:28 -07:00
Edward Langley d3fe4aa041 fix(web): a socket that never opened did not disconnect
The `close` listener wrote "disconnected" unconditionally, so a connection
that failed loudly -- the `error` listener having already written "cannot
reach <url> -- is improvise-ws-server running?" -- was immediately followed
by a close event replacing it with the vaguer of the two facts. The user saw
the least informative thing that happened.

This is the watchdog bug's family and deliberately NOT its fix. The watchdog
is for silence only, so it is disarmed; suppressing `close` outright would
hide a genuine mid-session disconnect, which is the one thing that listener
is really for. So the two cases are split rather than one being guarded.

A CORRECTION TO THE TICKET'S SUGGESTED MECHANISM: it proposed reading
`readyState` at close time, and that cannot work. `readyState` is already
CLOSED by the time a close listener runs, whichever happened, and the
CloseEvent cannot separate them either -- a refused connection and a
mid-session network drop are both code 1006 with `wasClean: false`. The only
discriminator is whether `open` ever fired, which the page must remember for
itself. Hence the `opened` flag.

Both arms go through `setShellFailure`, because a drop before the first frame
leaves the watchdog armed and "disconnected" is more precise than the guess
it would fire 8s later.

The header comment stating the `#status` vocabulary is updated in the same
commit: that fact is stated twice, and gotcha 48 says changing it is a grep.

VERIFICATION IS READING, and that is the whole budget. There is no browser
and no headless DOM here, so this is `node --check` (proved discriminating by
appending an unbalanced paren and watching it fail) plus confirmation that
every call app.js makes is in the generated .d.ts at the right arity:
on_frame/1, on_key_event/5, render_model/0, set_viewport/2, typed_cell/3.
Both wasm bundles still build. The listener itself has never been executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:37:53 -07:00
Edward Langley cf086f8c04 Merge: one keymap table, shared by the TUI and the browser (improvise-bpq2 successor)
The browser's hand-written key classifier was a second spelling of
default_keymaps(), held honest by a lockstep test over a hand-written list --
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)'s
exact shape. So the TABLE moved to improvise-protocol and improvise-command
keeps only what needs a CmdRegistry. Evidence is binding_dump_diff, not the
suite: 4021 resolved (mode, key, modifier) lookups over sixteen modes,
BYTE-IDENTICAL, with the dump kept as an opt-in test and proved
discriminating by renaming one binding.

The boundary that did NOT move is the better one: key -> intent is shared,
intent -> Command stays client-side, and the named list of what still needs
the host is now a list of COMMAND NAMES rather than keys -- so a rebinding no
longer invalidates it.

AND A LOCKSTEP TEST FOUND A LIVE BUG, BY A COMMA. CellDisplay::text has been
through format_f64; App::display_value_at_cursor has not. Seeding an editor
from the drawn text and committing turns a stored 1200 into the string
'1,200' -- display rounding fed back into the model, which design-principles
4 forbids outright. CellDisplay::edit_text puts the unformatted value on the
wire, and it repairs web/app.js's click-to-edit, which had been seeding its
input from td.textContent since it was written. That bug is on the live page
today for any value with a thousands separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:36:47 -07:00
Edward Langley 59cc6720b5 feat(browser): the page draws the mode's hint instead of spelling keys
`?` now enters `Help` — the keymap says so and this client resolves
`enter-mode` — but the page has no help overlay: `src/ui/help.rs` is 600
lines of ratatui and nothing shared carries its content. Landing the key
without this would have swapped the mode label to HELP over an unchanged
grid, which looks broken rather than unfinished.

`AppMode::hint_text()` already exists in the protocol crate and is what the
TUI's footer draws, so `RenderModel::hint` relays it. Every mode now states
its own keys in the browser, the page spells none of them, and the modes
this client can enter but not yet draw say what they are. A real help
overlay needs the content moved into the shared layer first, which is the
same move design-principles §5 asks for and is filed rather than faked.

`web/*` is unrun; `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:35:54 -07:00
Edward Langley e13635638c feat(browser): the browser reads the TUI's keymap, and i opens an editor
Follows the keymap move. `keys.rs`'s hand-written per-mode classifier is
gone; `resolve` now looks the key up in `Keymaps::default_keymaps()` — the
same table the terminal dispatches against — and what is left here is the
narrow residue: turning a bound command NAME into a `Command`, for the
subset a client holding no `CmdContext` can compose.

That is a better seam than the one it replaces. The drift risk was "which
key does what": 170 bindings, no direct test, invisible when wrong. It is
now "which command names can I execute", which is a list — and
`the_normal_mode_keys_that_still_need_the_host_are_these` becomes
`the_command_names_that_still_need_the_host_are_these` to match, so a
rebinding no longer invalidates it and a new binding for an
already-executable command reaches the browser with no change here.

Several gaps close by sharing rather than by code. Search-mode typing
resolves locally for the first time (the table binds `append-char
["search"]`, and "search" is `ViewState::search_query` — gotcha 11's pair is
mirrored and nothing else is). `Esc` resolves as the `Binding::Sequence` it
is, all-or-nothing. A modified character in a text-entry mode now types,
because the `AnyChar` fallback being reached for is the host's own — the old
"unmodified only" rule was a hedge against a table this client no longer
guesses at. And a key nothing binds is `Ignored` rather than forwarded into
`Host::handle`'s empty vec.

What is newly executable, and so what a user gets back: `edit-or-drill`
(`i`/`a`), `commit-cell-edit`/`commit-and-advance-right` (Enter/Tab in
`Editing`), `enter-mode` (`?`/F1, Esc), `enter-tile-select` (`T`),
`toggle-panel-visibility` (Ctrl-F/C/V), `toggle-panel-and-focus` (F/C/V) and
`transpose` (`t`) — four of which an independent browser smoke test had just
found inert.

`i` on an **aggregate** does not go upstream. Every upstream key is a black
hole, and a keypress that visibly does nothing is the worst of the three
answers, so it answers with a `SetStatus` naming drill. A view-half command
is folded into the asker's own mirror and broadcast to nobody, so that
message reaches this user and no one else.

The write itself is not composed twice: the keyboard commit calls
`socket::typed_cell_command`, the function click-to-edit already used, so
Enter and a click cannot disagree about what `""` or `"42"` means.

## `CellDisplay::edit_text`, found by lockstep

`tests/lockstep.rs` drove `i` through the host and the client over one grid
and they disagreed by a comma: the host seeds its edit buffer from
`App::display_value_at_cursor` (the stored `1200`) while the client had only
`CellDisplay::text`, which has been through `format_f64` (`1,200`).
Committing that stores the *string* `"1,200"` and the number is gone —
display rounding fed back into the model, which design-principles §4 forbids
outright.

So the projection is enriched rather than the client taught to un-format:
`CellDisplay::edit_text` is `display_value_of` on the wire, and it repairs
**click**-to-edit too, which had been seeding its `<input>` from the drawn
text all along.

## Making the edit visible

`RenderModel::editing` carries what the user has typed so far, or `None`
when no edit is open. The decision — is this an editing mode, and which
buffer does it type into — is made in Rust; the page draws the string in the
selected cell. Without it the mode indicator would read INSERT while every
keystroke vanished into a buffer nobody painted.

`web/app.js` and `web/style.css` change accordingly, and are **unrun**: there
is no browser here (gotcha 48). `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:35:14 -07:00
Edward Langley 94d26d8705 fix(web): default the socket to TLS, and cache-bust the deployed bundles
?ws= now takes a host:port and the page supplies the scheme: wss:// when it
is itself served over https, ws:// when over http. An explicit ws:// from an
https page is UPGRADED rather than passed through, because an https origin
refuses it as mixed content -- so passing it through can only produce a
failure that reads like 'the server is not running'. The scheme is not the
caller's to get wrong.

And the deploy now stamps ?v=<sha> onto both bundles. Pages serves everything
max-age=600 and the wasm-pack filenames carry no content hash, so for ten
minutes after a deploy a returning visitor runs the PREVIOUS build -- which
made a shipped fix look like it had not shipped, twice, during the first real
browser debugging session.

BOTH halves are stamped, because a fresh glue file does not pull a fresh
.wasm: the glue locates it with new URL('..._bg.wasm', import.meta.url), and
a URL built against a base does not inherit the base's query. Stamping only
the import specifier would refresh the wrapper and keep the stale binary --
exactly the failure this is here to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:17:08 -07:00