Commit Graph

108 Commits

Author SHA1 Message Date
Edward Langley d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00
Edward Langley a913390322 feat: the page bar becomes a control, and [/] reach the modes about the document
Three tickets on one subject: paging.

improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.

improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.

improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.

Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
  - `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
    binding. Composing the command anywhere else would put a derivation in the
    page, which design-principles §5 forbids; `Command`/`ModelCommand` are
    untouched.
  - `improvise-command/src/command/keymap.rs` gains three tests. The two
    existing guards were the mechanism that forced the wiew argument, so they
    had to be answered where they live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:24:38 -07:00
Edward Langley 52f615d14d fix(web): an error cell must not lose its colour to the dimming or the selection
Found by reviewing the restyling before pushing it, and demonstrated before
it was fixed.

`td.error` and `td:not(.editable)` used to set DIFFERENT properties — colour
and opacity — so both applied and a dimmed error cell was a dimmed red. The
restyling made the dimming a colour too, at the same specificity and below
`td.error` in the file, so an error in an aggregate cell rendered plain grey:
rgb(107, 103, 95) where the error colour is rgb(168, 50, 45). `td.selected`
and `td.typing` did the same by setting `color: var(--fg)`.

`#grid td.error` is the only place on this page where colour carries MEANING
rather than emphasis, and it is the one a reader takes at face value: a
circular reference or a `mixed` aggregate reading as an ordinary number is
worse than it reading as nothing.

So the dimming moves ABOVE the semantic colours, and a selection is a ring
and a tint and never a colour. That is the class of defect a stylesheet has
and a program does not — two rules that were independent become one property,
and nothing in any language fails.

Also: an expanded notice now caps at 40vh and scrolls. `main` is free to
shrink to nothing, so on a short viewport opening it could have pushed the
grid out entirely.

The test is aimed at the STYLESHEET rather than at a document, because the
question is which rule wins and a fixture per combination would be three
documents testing three documents. It appends a <td> carrying each
combination to the real page's real table and compares two controls taken the
same way, rather than pinning absolute colours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:33:02 -07:00
Edward Langley 8ad5c0806d feat(web): dismissable notice, a raised minibuffer, and a styled shell
Three things the user reported after looking at the deployed page, in the
order they cost a visitor something.

The deployment notice was ten dense lines of configuration reference —
?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the
category tree and the grid on every visit: about 230px of an 820px window.
It is now a <details>, so the collapsing is the BROWSER's (keyboard
operable, and correct with app.js absent or still loading) and the only
thing the page adds is remembering the answer. It starts COLLAPSED, and
collapsing withholds nothing: the one fact a first visitor needs — the
document lives in this tab, is saved to this browser's private storage and
never leaves the machine — is the <summary>, which never collapses.

What a stored value means is a decision and lives in shell.js; touching
localStorage is a browser API and stays in app.js, wrapped, with every
value that is not one of the two spellings read as "unanswered".

The minibuffer was a 14px line in the page's bottom-left corner, up to
800px from the cell being typed into. It is now a raised bar over the
grid. The CONTENT is untouched — RenderMinibuffer is built from
`state.view.mode.minibuffer()`, the same semantic value the TUI draws — so
this is position, weight and contrast, and there is no second derivation of
what a prompt says. MinibufColor still resolves here exactly as
draw.rs::minibuf_fg resolves it for the terminal.

style.css stops being "legibility only": layered surfaces, one accent, a
filled badge for the mode, chips for the two axis strips, one selection
treatment, tabular numerals in the grid, and a focus ring on the one
keyboard surface the page has. The palette is the landing page's, so the
two read as one product.

Asserted (harness/tests/contract.spec.js, Chromium): the notice starts
collapsed and remembers, in BOTH directions across a reload; it opens from
the keyboard, which the page's own keydown handler must not claim; the
minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static
bottom bar cannot satisfy — and is larger than body text. Each was proved
discriminating by injecting the fault and watching it fail.

NOT asserted, and named as such in context/code/clients.md: colour,
contrast, spacing, weight, the grouped and multi-level header DOM, either
theme, and the localStorage-throws path. Those are argued and read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:29:18 -07:00
Edward Langley a93d668e5e feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.

`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).

`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.

improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.

All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.

Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:57 -07:00
Edward Langley 836323cafa style: cargo fmt the harness crate, which --all does not reach
`harness/opfs-contract` carries its own [workspace] table, so it is not a
member of the root workspace -- deliberately, since it names
improvise_io::opfs and a member would break `cargo test --workspace`. The
consequence is that `cargo fmt --all -- --check` passes over it without
looking, so every gate I ran was honest and green while the file I had
edited was never visited.

It needs its own gate, as CI runs it:

    cd harness/opfs-contract
    cargo fmt -p opfs-contract -- --check
    cargo clippy --target wasm32-unknown-unknown --all-targets -- -D warnings

The gap is bounded: grepping for crates carrying their own [workspace]
table finds exactly this one and the root.

And a fmt failure is the FIRST step of that job, so the clippy behind it
never ran and nobody knew whether it was clean. It is -- verified locally
at this head, no warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:22:47 -07:00
Edward Langley a633613e56 test(browser): improvise-ociy had no test; its own review found that
The send guard was written, argued in a comment and never run, which
AGENTS.md forbids: a fix needs a test that failed before it. Proven
against the pre-fix silent drop -- status stays "disconnected" and the
edit vanishes.

The first version of this test was VACUOUS and failed for the wrong
reason. It pressed ArrowDown, which on a client with no snapshot composes
no frame at all, so transport.send was never reached and the guard under
test never ran. Enter is forwarded upstream and composes a frame
regardless of client state. doctrine(an_injected_fault_must_be_the_fault
_you_named) seen from the other side: the TEST must reach the path.

The closing assertion keeps improvise-taxx intact -- the drop must still
be named first. A loud send may add "your edit did not go", never replace
"disconnected" with something vaguer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:20:07 -07:00
Edward Langley 4c15f081d3 test(browser): improvise-i0v9's two-tab case, end to end in both engines
The P1's headline scenario was still argued rather than run: the read-only
path's components were verified (the harness probe, the Rust unit tests)
but the two-tabs-one-document flow it was reported as was not.

Two tabs in one context. The assertion that matters is that the second
one PAINTS -- boot succeeds without a handle -- then that it says
read-only and names another tab, then that the reason still stands past
the 8s window rather than being replaced by a watchdog blaming the
browser. `timesArmed() > 0` first, or that denial is vacuous.

Chromium and Firefox, because the exclusivity semantics are the engine's
and the two word the refusal completely differently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:55:10 -07:00
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
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 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 0889166fc7 Merge branch 'main' into worktree-agent-a471cf08030a9af4c 2026-09-20 21:11:31 -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 8f3a9768b7 fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are
one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and
all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed
together or the test stops comparing anything.

improvise-gd83 — AxisEntry carries a group-end boundary
-------------------------------------------------------
`expand_category` emitted a header on ENTERING a group and no boundary on
leaving one, so `group_of` answered with the nearest preceding header rather
than membership, and the TUI painted that span. Copilot was right on PR #23;
this is the filing gotcha 44 said the fix needed.

`AxisEntry::GroupEnd` is the boundary, carried onto the wire as
`FrameEntry::GroupEnd`. Two arms, and a fix can close one and miss the other:

  * within one expansion — Jan[Q1], Feb[Q1], Sep — a `Some -> None` transition;
  * across expansions — `cross_product` expands the inner category once per
    prefix and each call starts with `last_group = None`, so Food/Bob[Family]
    is followed directly by Clothing/Carol with no transition to hook onto.
    That needs a `GroupEnd` at the END of an expansion whose last item was
    grouped.

`GroupEnd` is NOT emitted between two groups: the next header already ends the
previous run, and emitting one there moved `data_row_to_visual_skips_headers`
and its column twin for no gain. Those two are untouched, which is the evidence
the boundary is as narrow as it claims. A trailing marker is trimmed, since the
axis itself ends the last run. Nothing draws a `GroupEnd` and it consumes no
data index.

`THE_MEMBERSHIP_CORRECT_SPANS` and its `assert_ne!` tripwire are gone, and the
value they predicted is now asserted element for element — it was right to the
element, which is worth recording.

improvise-0o6x — a leading group header is drawn
------------------------------------------------
`body_from(idx)` started at the first ITEM, so at the top of an axis a leading
`FrameEntry::Group` was dropped on the way to the screen, in both front ends. A
collapsed group first on its axis therefore had its rows hidden and its header
nowhere: no affordance to get them back.

It is a boundary condition as the ticket suspected, but the boundary is not
`idx == 0`. The windowing rule carries the nearest header ABOVE a scrolled
window for `group_of` and `body_from` discards it — correctly — and a frame
cannot tell that from the top of the axis using `idx` alone, because the
browser's frame is already windowed and always passes `0`. So `AxisFrame`
gained `first`, the absolute data index of its first item, and the rule is
`first + idx == 0`. Without it the browser would draw an extra header row at
every scrolled position that the TUI does not.

RE-PINNED DELIBERATELY, the improvise-q0vb acceptance shape: `GROUPED_FRAME` in
`src/ui/grid.rs` moves by exactly one line — the leading `▼ Co…` now draws
above `East` and everything below shifts down one. The group line, the column
headers, the separator, the data rows and the totals strip are byte-identical,
which is the evidence this is the boundary condition and not a reshuffle.

`harness/tests/grouped.spec.js` asserted TWO group rows against a fixture with
three, naming this ticket on the line and saying the count becoming 3 would be
the record of when it was fixed. It is 3, verified in Chromium. Three
assertions in that file took "the first tbody row" to be a data row; a
`firstDataRow` helper now skips the header, which is the same re-pin.

improvise-8qlr — the run rule lives once
-----------------------------------------
Column-group run coalescing was derived in `src/ui/grid.rs` and again in
`improvise-wasm-client/src/render.rs::col_groups`, held in step by a comment
saying so. `AxisFrame::group_runs`/`group_run_heads` is the one home and both
ends call it. Runs still coalesce by NAME, which is what both ends did before
and is preserved rather than quietly changed.

The TUI keeps one divergence, stated: it labels its first VISIBLE column even
mid-run, or a scrolled grid draws an unlabelled group line.

The agreement test's TUI arm was `group_line.contains("▼ Q1")` — the unpinned
arm the ticket names. It now compares the painted group line cell for cell
against spans derived from the browser's own runs. The test is fixed, not
deleted.

Injected faults, each failing by name
-------------------------------------
  * gd83: delete either `GroupEnd` emission — the two new layout tests fail
    with the leaked group, and the column-header agreement test fails with them.
  * 0o6x: force `body_from`'s match to `false` — `the_whole_frame_is_unchanged`
    and the new leading-header agreement test fail.
  * 8qlr: the ticket's own recipe, forcing the TUI to emit the group label on
    every column. `cargo test -p improvise` was GREEN before this commit and
    now fails with ["▼ Q1","▼ Q1","",...] against ["▼ Q1","","",...].

What the ticket did not predict
-------------------------------
A collapsed leading group has no item to carry its header into a window, so it
reaches the frame only through `axis_frame`'s `pending` queue. The first
version of the windowing change had `GroupEnd` CLEAR `pending` above the
window, which deleted exactly that header — the affordance 0o6x exists to
restore. `GroupEnd` is now queued like a header and clears nothing. Found by
driving the new agreement test in both collapse states; the uncollapsed arm
alone passes against the broken version.

1411 Rust tests pass (baseline 1405, +6). Playwright 28 passed + 3 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:57 -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 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
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 ab96f3651f test(harness): Firefox runs the standalone deployment, and only that
improvise-bck's criterion (1) reads "Chrome and Firefox" and the standalone
deployment was the evidence offered for it -- but shell.spec.js ran on
Chromium alone (improvise-zkkw).

The recorded reason is that routeWebSocket is slower and flakier across three
engines. That argument is about the four mocked-peer tests and was never made
about the two beside them: the standalone block mocks nothing, and the
engine's OPFS is already exercised by opfs.spec.js.

So the mocked-peer describe is tagged and the firefox project widens its
testMatch with grepInvert: /@mocked-peer/ -- a tag, not a third copy of the
file. Measured: 2.0s for the pair, 10/10 green under --repeat-each=5. Nothing
was quieted; had it flaked, the answer would have been to leave it.
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 b748bb680c test(harness): the landing page is fetched, played and followed
docs/index.html is copied to the site root by web/assemble.sh, so the harness
has served it all along with nothing asserted about it (improvise-bgtf).

Counting the four .ap-player elements proves nothing: autoPlay is false, so no
cast is fetched until play is pressed, and a page whose every cast path 404s
constructs four players and throws nothing. The spec asserts that no .cast has
been requested BEFORE pressing anything, then presses play on each of the four
and waits for rendered lines and the recorded session's own banner -- and
separately that four DISTINCT casts answered 200, which the rendering cannot
say. Re-injecting ./casts/ -> ./cats/ fails it at the first player.

The second test follows the footer link rather than asserting its text:
whether ./app/ resolves is a fact about the assembled tree. Injecting a typo
into the href fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:25 -07:00
Edward Langley 09ada9c1b5 test(harness): a grouped, multi-level fixture, and the first browser run of anr9
web/demo.improv has no groups and one category per visible axis, deliberately
(gotcha 46), so the multi-level header, the group span, the collapse indicator
and the inline group row -- exactly the code improvise-anr9 added -- had never
been rendered anywhere a test could see (improvise-plcx).

harness/fixtures/grouped.improv is served beside the shipped demo by
harness/build.sh; web/demo.improv is untouched. Two categories on each visible
axis, three row groups with one collapsed, two column groups.

A missing seed is NOT an error -- web/worker.js answers a 404 with an empty
model -- so every assertion is about labels the fixture really carries, never
that a grid exists.

Five injections, five distinct failures, patched in harness/serve so no
rebuild stands between the fault and the run: slash-joining the level tuples;
colSpan = 1 on the group headers; a stray cell on the inline group row;
indicator(false) hard-coded; and the collapse removed from the document.

One thing the ticket did not predict: the row frame's FIRST group header is
never drawn, so the spec asserts two group rows where the document has three,
with improvise-0o6x named on the line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:24 -07:00
Edward Langley ba5c0ee059 test(harness): the help overlay is asserted as content, and a lost click guard returns
PR #61 deleted "? opens help, and its rows are the shared ones" and nothing
replaced it (improvise-78v6): paintHelp, help, help_page and help_titles
reached no assertion at all. What contract.spec.js carried instead said only
that a dt was VISIBLE, and an empty dt is a visible dt (improvise-igxp).

help.spec.js reads the <dl> as the pairs it is and asserts text: the five
titles, Welcome's prose rows (which carry no dd at all, by design), and three
key/description pairings off the keymap. The second test asks whether the
string is TRUE rather than present -- it reads the key for "Jump to last row"
out of the DOM, closes the overlay, presses it and asserts the selection, so
the spec holds no second copy of the keymap.

contract.spec.js's two help lines are strengthened from toBeVisible to text.
Injecting the fault igxp names -- blanking keys.textContent in paintHelp --
left all three contract tests passing before; it now fails that file and both
tests here.

The test-name diff 78v6 asks for finds a fourth loss: "a click on an item row
filters, as Enter does". Only #status distinguishes the item arm of
click_category_row from the category arm, so a click wired to the wrong one
passed the whole file. Restored, and proved by injecting the mis-wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:18 -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 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
Ed Langley 3bf240d187 Merge pull request #59 from fiddlerwoaroof/fix/web-drill-interim
fix(protocol): the advertised drill key says something true
2026-09-20 14:10:55 -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 7e9b418845 fix(protocol): the advertised drill key says something true
`>` is advertised in the Normal hint line as ">:drill", and it was a name
`resolve::step` did not handle — so `KeyOutcome::Upstream` -> `ClientFrame::Key`
-> `Host::handle`'s empty vec, a black hole rather than a slow path (gotcha 47).
Pressing the key the app tells you to press did nothing at all, which is why
drill read as broken rather than as refused.

It now answers "drill is not available in the browser yet". That wording is
deliberate and is the whole risk in this change: routing `>` through
`edit_or_drill`'s existing aggregate arm would have produced a TRUE-SOUNDING
sentence about the WRONG THING — drill is refused whatever the cell is, and a
message about aggregates would stop the reader looking. Two refusals, two
subjects.

This is an interim under the current architecture, not a design decision.
improvise-gd28 owns how drill crosses the wire (ADR 001 layer 2, addressed
command form); whether the advertised key says something true is not part of
that design, is strictly better than silence, and is reversible the moment
gd28 lands.

`>` therefore leaves `the_command_names_that_still_need_the_host_are_these`.
That list's own failure message says it is "where it stops being a gap", so
removing an entry is the mechanism working rather than a test being edited to
pass.

Guarded at both layers, each proved by injecting the exact fault warned about
— replacing the new message with the aggregate sentence:
  - `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons` fails on
    the assertion that `>` must not talk about aggregates;
  - `harness/tests/drill.spec.js` fails in Chromium on the same string, while
    its sibling (the `i` refusal) stays green — so neither passes for the
    other's reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:18:57 -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 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 9838fc0211 test(harness): the help overlay follows the app to /app/ (#47)
The merge of `gh/main` was clean and that is the hazard: #47 moved the app
from the site root to `/app/` and rewrote all six `goto` calls, while my help
test — added on a branch that did not exist when #47 was written — still
navigated to `/index.html`, which is now the landing page. Git had no reason
to call that a conflict.

Also folds in a cosmetic fix held back from the previous push: `step` had two
`── panels ──` section headers after `cycle-panel-focus` landed in its own
block. One block now.

Verified on the merged tree: 1324 tests, clippy, fmt, both comment-run modes,
wasm for all eight gated crates, and 7/7 Chromium shell tests against the
assembled site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:50:09 -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 757abf3b88 docs: REVERT the mobile fix (user ruling), caption the stale drill cast
Read the revert first, because this commit undoes the headline change of the
one before it and a subject line saying "caption" would have hidden that.

**REVERTED: 3fcb88b's `@media (max-width: 900px)` block.** User ruling: the
mobile defect is to be FILED, not fixed in this PR. It is improvise-o31u, P2,
carrying the measurement, the proposed fix (`overflow-x: auto` on `.player`
plus `fit: false` or an explicit `terminalFontSize`), and the note that a
working fix was written and measured here before being reverted, so the next
person starts from evidence. Measured both ways at 390x844, so the cost of the
revert is on the record rather than implied:

    3fcb88b as committed   font 11.9457px   player width 880
    this commit             font  4.83258px  player width 356

`document.documentElement.scrollWidth` is 390 and equal to `clientWidth` in
both, so the revert does not introduce horizontal page scroll — it restores an
unreadable font, and nothing else.

**F1, the stale drill cast, captioned rather than re-recorded or dropped** —
also a user ruling. `drill.cast` shows the narrow records format with a single
`Value` column, which improvise-2zq and improvise-c5v replaced with a column
per measure plus the `+cat` and `+` columns, so it demos a UI the software no
longer has, now at the site root. The caption is `p.dated`: body size, body
colour, set off with a rule, and ABOVE the player rather than under it —
verified by measuring its computed style against `body` and its box against the
player's, because "captioned" and "captioned in grey 10px under the fold" are
the same word and different things. improvise-2oxt stays open; improvise-e61
stays closed, with its notes now saying plainly that the caption is an honest
label on an UNMET criterion rather than a met one.

**F7: the harness readiness probe** was `/index.html`, which used to be the app
and is now the landing page — so it would answer 200 against a tree with no
bundle assembled at all. Now `/app/index.html`. Proved discriminating rather
than assumed: served a directory containing only the landing page, and the old
probe returns 200 while the new one returns 404.

`docs/index.html` is now 213 lines, past improvise-e61's "under 200". The
criterion is reinterpreted on the ticket rather than the file crammed to fit:
the number was a proxy for single file, vanilla, no build step, no framework,
and all four hold.

Also corrected, in the record rather than the tree: improvise-bck's evidence
cited the deployed root for its page-load and bundle-size measurements, and
this PR moved the app out from under those URLs.

Verified: 6/6 harness shell specs pass with the new probe, four casts still
play 37 lines each at the root, call to action still 200, cargo test 1311,
both comment checkers clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:23:57 -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 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 6f9efcf2e8 docs: improvise-xps8 describes a bug that does not exist — turn it into a guard
Review findings on PR #30, all three applied.

1 [P1]. The claim that the handshake watchdog overwrites a genuine mid-session
disconnect 8s later is FALSE on any tree that has PR #21. `close` routes BOTH
arms through `setShellFailure` (app.js:248-250), which clears the timer
synchronously (app.js:165-169).

It was believed because it was measured honestly — against a tree predating
PR #21 — and then reported as "surviving" that PR on the strength of a
simulation that re-created only the `opened` flag and not the other half of the
fix, the routing. A simulated fix that is not the fix you named reproduces a
bug the real code does not have, and reads exactly like a confirmation.

Rather than delete the claim, execute the property it denied: the
open-then-dropped test now runs past HANDSHAKE_TIMEOUT_MS + 2s and asserts
"sent no document" never appears. Proved discriminating by removing
`setShellFailure`'s `clearTimeout` — the assertion then fails with exactly the
message xps8 described. gotcha 48 and the spec's comment block corrected; the
ticket is cited in neither. Recorded as
doctrine(a_simulated_fix_must_be_the_fix_you_named), the sibling of
an_injected_fault_must_be_the_fault_you_named that fails the other way: a wrong
fault proves a check that cannot fire, a wrong simulated fix proves a bug that
does not exist.

2 [P1]. The previous commit created the contradiction it was meant to remove:
design-principles recorded the measured ~2.2s while kb.pl still said "under 2
seconds". The ambiguity between per-binary and wall clock is what let it drift,
so both now state two numbers — no single binary over 2s, wall clock is the
sum, measured 1309 tests / 33 binaries / ~2.2s / longest 1.42s — in kb.pl,
design-principles and repo-map alike. Also 16 -> 17 in doctrine(binding_dump_diff).

3 [P2]. The new audit exclusion was unanchored, so `improvise-gp` was a prefix
match that would silently exclude a real future ticket. Anchored `^…$`. It
immediately caught the example id the explanatory paragraph itself spelled out
— a recipe is code, twice over.

CLAUDE.md: declined, with evidence. Both copies of the duplicated tail sit
inside `<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->`
markers written by `bd setup claude` and `bd setup codex` and refreshed against
that hash. A process change there is one tool invocation, not two hand edits,
and a pointer makes `bd setup <recipe> --check` report it modified. Recorded as
kb_issue_found(claude_md_tail_is_generated_not_duplicated) so it is not
re-proposed.

kb.pl: doctrine 54->55, kb_issue_found 11->12; invariant 51, decision 47,
location 34 unchanged. kb_consistent passes.

cargo test --workspace: 1309 passed, 0 failed, 2 ignored.
harness (Playwright): 12 passed, 3 skipped (webkit has no OPFS).
Dead-ID audit with the anchored recipe: 157 tokens, all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 01:11:46 -07:00
Edward Langley f12a2d3517 docs: reconcile the docs with the code after wave 1
An independent review checked every factual claim in the docs against the
code at 22d43b2, after eight PRs landed in one wave. This applies its
replacement text.

context/repo-map.md
  - gotcha 44 rewritten: the cache DOES frame the grid now (improvise-anr9,
    AxisFrame/FrameEntry/header_level_widths). The surviving half — the TUI
    builds at offsets zero, so a cache position is absolute — is kept,
    because four code comments cite gotcha 44 for exactly that.
  - gotcha 45: ClientFrame::Save exists (improvise-368h); MarkDirty is gone
    and dirtiness is read off EffectKind::Model(_); Rc<T>: Storage lives in
    improvise-io (improvise-hmws); OpfsFs::list joins dir (improvise-vhcz);
    OpfsFs is run by harness/ and bindings.rs is now the dark corner.
  - gotcha 47: Host::commands answers the frame whole (improvise-955n); the
    open hole is clause 4, not a per-command Checked. set_viewport "must"
    -> "does".
  - gotcha 43: the frame, not the command, is what is answered; two of three
    tiers hoist.
  - gotcha 21 widened: three effects abort, and tidy-up goes at the head.
  - gotcha 48: web/app.js is executed by harness/tests/shell.spec.js;
    improvise-m18f -> improvise-taxx (fixed); the open half is improvise-xps8.
  - gotcha 42: the never-rendered / improvise-hwpi sentence pair deleted.
  - gotcha 18: live_records and analyze_records are both decided now.
  - gotcha 1: the records columns are [coords…, +cat, measures…, +].
  - AppMode is 17 variants, ModeKey is 17, eight text-entry modes; the keymap
    table moved to improvise-protocol/src/keymap.rs (six references).
  - the Wasm section: the mirror is gone; the wasm gate covers eight crates.
  - the File Inventory regenerated rather than patched (57/79 rows were
    stale); the duplicate ws-server/host.rs collapsed to a pointer at the
    23-line shim; harness/, protocol keymap.rs/viewmodel.rs, improvise-gp
    fit.rs/dataset.rs, cmd/fit.rs, model/record.rs and the integration tests
    added.
  - the suite speed re-measured rather than re-asserted.

README.md — the four "no browser here" sites narrowed to what is still true;
17 modes; the browser section names the Playwright harness.

AGENTS.md — the dead-ID audit recipe was itself defective: two paths, a
character class blind to a commit SHA, and a two-name exclusion list. Widened,
made runnable under the worktree shell guard, and its own prose no longer
spells any id.

prolog-sessions/kb.pl — seven invariants replaced in place (they were false),
five location/3 facts re-sited plus three new, six doctrines and seven
invariants added. doctrine 48->54, invariant 44->51, decision 47 unchanged,
location 31->34. kb_consistent passes.

Comment-only Rust changes: a duplicated sentence in worker.rs, records_columns'
doc omitting NewCategory, improvise-709f2df -> commit 709f2df, and three more
copies of "OpfsFs has never been run".

cargo test --workspace: 1309 passed, 0 failed, 2 ignored — unchanged.

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