88 Commits

Author SHA1 Message Date
Cursor Agent b872923331 fix(improvise-2mqz): export the requesting session view
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-27 16:51:50 +00:00
Cursor Agent e0f04ee334 feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads
This commit implements the download flow for SaveAs and ExportCsv per the
USER RULING on improvise-2mqz. The correct shape is:
- A request frame asks the host for BYTES
- ServerFrame returns the bytes (or Rejected)
- Browser triggers downloads via Blob + <a download>

Key changes:

Protocol layer (improvise-protocol):
- Add Request::DownloadDocument and Request::DownloadCsv variants
- Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv
- Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes
- Add KeyOutcome::LocalThenRequest variant for operations that need
  both local fold (mode change, buffer clear) AND a server request
- Update stranding() to unstrand SavePrompt and ExportPrompt modes
- Update keymap to use rebind() instead of commit() for commit-save
  and commit-export (because LocalThenRequest can't be in a Sequence)

Host layer (improvise-command):
- Handle new ClientFrames in host.rs returning bytes or Rejected
- Add Rejection::ExportFailed variant for CSV export failures
- Update CommitSave and CommitExport to clear their buffers
- Make render_csv() public in improvise-io for host use

WASM client layer (improvise-wasm-client):
- Add PendingDownload type with filename, bytes, and kind
- Store pending downloads for JavaScript retrieval via take_pending_download()

Browser layer (web/app.js):
- Add triggerDownload() function using Blob + <a download>
- Poll for pending downloads after frame handling

This unstrands SavePrompt and ExportPrompt which were previously refused
by stranding() due to missing wire forms.

Refs: improvise-2mqz

Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-25 19:15:40 +00:00
Edward Langley 9eb83538fe A message beside its open editor stands in the browser too (improvise-1x9h)
The toast hid every status after six seconds, so a refusal the user was
still fixing vanished with its prompt open: su4u's bug on a timer. The
browser now asks the TUI's question. EditorSession moves into
improvise-protocol, ClientState records which editor a status was
written in, and RenderModel::status_stands tells the toast to hold it
until that editor closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:08:26 -07:00
Edward Langley 35013b6bda Evidence, docs and the bundle surface for the mouse editor's seed and first edit (improvise-ugj4, improvise-jibp)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:52:26 -07:00
Edward Langley 8747074e52 Seed the mouse editor from its target, and mark the target on the first edit (improvise-ugj4, improvise-jibp)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:33:40 -07:00
Edward Langley a8737f3fa5 One page rule for both ends; the help overlay shows how to page
improvise-5xbq: resolve::page_rows is now the one home of the page-scroll
rule; the TUI's Move calls it instead of repeating visible_rows * 3 / 4.
A lockstep case pages (PgDn/PgUp, Ctrl-d/Ctrl-u) on a non-square window,
8 rows by 3 columns, and pins the TUI's landing rows as well as comparing
host and client, since one home moves both ends together. Both faults
the ticket injected (page_rows = window_rows / 2; paging by window_cols)
now fail at the first PgDn, before and after the move to one home.

improvise-1c5m: the help overlay draws the mode's own hint (hint_line,
the string #hint carries) in a sticky footer, so a narrow window whose
app bar wraps under #help still says how to page. Playwright case at
600px, red at base, and red again with the footer made non-sticky.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:41:03 -07:00
Edward Langley 691c6c69d1 Pin edit_target in the page's client surface (improvise-au6c)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:46:30 -07:00
Edward Langley 170d94807b Merge remote-tracking branch 'gh/main' into wire2/2wu3 2026-09-22 22:44:47 -07:00
Edward Langley c960713f8c The worker's status claims success only on its first frame (improvise-i0v9)
The worker transport wrote "in-page worker · <doc>" when it spawned the
worker, before anything had answered, so a tab whose worker could not start
showed a success-looking status over a blank page. It now writes "starting
the worker · <doc>…", and createShell's arm() takes an `answer` that sawFrame
writes on the first frame, but only while the watchdog is still armed. A
ReadOnly or SeedMissing notice lands BEFORE the Snapshot, and fail() has
already disarmed, so the claim cannot overwrite the precise reason.

The two-tab spec now logs every #status and denies both the premature claim
and "did not answer" before the page clock is walked. A new spec pins the
exact status sequence on the writable path. Reasoning:
context/code/clients.md, "The watchdog".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:10:29 -07:00
Edward Langley 384e04acbc The inline editor commits by record identity, not viewport position (improvise-2wu3)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:37:56 -07:00
Edward Langley 1ee1f26c0d WIP df2w: an open empty panel is Some(vec![]), closed is None
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:50:48 -07:00
Ed Langley a9cad15827 Merge pull request #137 from fiddlerwoaroof/fix/f9qf-pin-the-shell
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix(scripts): one shell and one environment for every checker (improvise-f9qf)
2026-09-22 16:15:27 -07:00
Edward Langley c8d715346f fix(web): a narrow window must not widen the document (improvise-tg27)
The report was "a narrow window clips grid columns without usable horizontal
scroll", and the ticket suspected a flex `min-width: auto` trap swallowing
`#grid`'s overflow. It was not that: `#grid` has `overflow: auto`, which
already zeroes a flex item's automatic minimum. What could not shrink was the
app bar.

Measured at 22ab58f on the grouped harness fixture, `documentElement.scrollWidth`
read 1011 at every window narrower than 1011px — a floor, not a slope, which is
the signature of a sum of `flex: none` reserves. Below it the PAGE grew a
horizontal scrollbar, and what it scrolled to was the bar's own right-hand end
sitting outside the window, while the grid slid off to the left.

`header` takes `flex-wrap: wrap`, chosen over a `@media` breakpoint because a
flex item wraps exactly when the line cannot hold it, so the desktop bar is
untouched by construction rather than by a number someone picked. `main` gets
the `min-width: 0` the ticket asked about, and the three side panels are capped
at 40vw so the grid keeps the larger share of a phone window.

What this deliberately does NOT do: make `#grid` scroll to columns beyond the
announced extent. The browser's Viewport is a real window (gotcha 44/47), so
those columns are never projected and no scrolling can reveal them; the
keyboard already reaches every one, and a spec now asserts that.

`harness/tests/narrow.spec.js` asserts `scrollWidth <= clientWidth`, which is a
question a browser answers exactly. `body { overflow-x: hidden }` was rejected
for making that assertion vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:35 -07:00
Edward Langley cb6866b2c6 fix(scripts): one shell and one environment for every checker (improvise-f9qf)
`scripts/pr-reviews-test.sh` passed 19/19 locally and 2/19 in CI on the same
commit, because a multi-assignment `local` is not left-to-right on bash 5
while macOS's bash 3.2 tolerates it. The obvious half of the fix is pinning
the shell. The half that decides the design is not.

.envrc DOES already put a nix bash 5.3.15 on PATH, which is CI's semantics --
and it is not enough. `pkgs.mkShell` exports `name`, `out`, `outputs`,
`system`, `shell`, `builder`, `stdenv`, `phases` and about twenty more
generically-named variables that CI does not have, so a script reading one by
accident under `set -u` is SILENT in the dev shell and fatal in CI. That is
exactly what happened: the broken clause read `$name`, and in the dev shell
`$name` was `nix-shell-env`. Measured at 051f541^ on one machine:

  macOS /bin/bash 3.2                             19 passed, 0 failed
  direnv exec . ./scripts/pr-reviews-test.sh      19 passed, 0 failed
  the same, with `name` unset                      2 passed, 17 failed

Two reasons for green, one of them the correct interpreter. A dev shell is a
third environment, not a reproduction of CI.

A nix shebang was measured and declined, on availability rather than speed:
warm, `nix shell nixpkgs#bash --command true` is 61.8ms against an ambient
5.1ms, and `nix-shell -p bash --run true` -- the manpage form -- is 675.2ms,
132x, inside git's merge machinery for beads-merge-driver.sh. But only the
`kb` job installs nix; build, test, wasm, js, harness-lint and browser have
none, so a nix shebang needs an installer step on three more jobs to reach a
bash those runners already ship. zsh is not a shebang change and the runner
does not have it either. Both declined in context/code/tooling.md section 5.

What lands instead pins interpreter and environment together, in one place:
scripts/checkers.sh is the one entry point for every check_the_checker, used
verbatim by CI and by the local gate. It pins a bash >= 5 (ambient first, nix
only when the ambient one is older, exit 3 when neither), scrubs the
environment to an allowlist, and refuses any shell shebang in scripts/ that
is not `#!/usr/bin/env bash`. The pinned bash goes on the front of PATH so
inner invocations get it too -- which is where the fault actually lived.

The second undetected split is closed with it: the four `#!/bin/sh` and one
`#!/usr/bin/env sh` scripts are now bash, and the six places that ran a
script through an explicit `sh` -- overriding the callee's own shebang, which
is the same disease -- now invoke it directly.

Verification, injected against the real script: restoring pr-reviews'
multi-assignment `local` leaves `sh scripts/pr-reviews-test.sh` at 22/22 and
`direnv exec . ./scripts/pr-reviews-test.sh` at 22/22, while
`./scripts/checkers.sh pr-reviews` reports 2 passed, 20 failed, exit 1 --
matching the 2/19 CI actually reported. scripts/checkers-test.sh is twelve
cases, each injecting rather than asserting; the pair that carries it runs
one fixture both ways, so the `set -u` case is a test of the runner and not
of `set -u`.

The old local gate was `for t in scripts/*-test.sh; do sh "$t"; done`, which
overrides every shebang with /bin/sh -- so it ran every suite under bash 3.2
in sh-mode regardless of what the file said. It is replaced by
`./scripts/checkers.sh`.

Workspace suite unchanged at 1689 passing over 38 binaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:14 -07:00
Edward Langley 1f222e6215 style: centre the view chip in its reserve, as the mode pill already is
Found by looking at the screenshots rather than by a test, which is the
only instrument that reaches this: `#view-name` is now a fixed 16ch, and
"Plan" left-aligned in it read as a pill that had failed to shrink rather
than as a deliberately reserved slot. Centring is what makes the reserve
legible as a reserve.

No test moves -- the header spec measures boxes, and text alignment inside
a fixed box changes none of them. That is the honest scope of the guard and
not a gap in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:38:19 -07:00
Edward Langley 66693602c3 review: the two-facts claim was stated in four places, and I changed one
Self-review against the code-review skill, and it is gotcha 48's own third
lesson turning up in the change that cites it: "when a fact is stated in
more than one place, changing it is a grep, not an edit."

`context/code/clients.md` said `#status` composes two facts in three further
places -- the shell.js inventory, the transports section and the two-numbers
section -- all still describing the `.` join this branch deletes. A reader
arriving at any of them would have been told the opposite of what the code
does. The minibuffer section also predated `#bottom-dock` and said nothing
about what happens when a toast and the prompt coincide.

Also drops `createToasts().showing()`, which nothing calls. It was
speculative API on a single-use object (CLAUDE.md section 2), and an
unexercised accessor on a timer is one more thing that can be wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:22:12 -07:00
Edward Langley 6de56e6ba1 Merge branch 'main' into ui/header-stable 2026-09-22 00:17:27 -07:00
Edward Langley d750b433b8 fix: the app bar is reserved, not fitted, and a message is a toast (improvise-n6t8)
The rule is the deliverable: nothing a user expects to be fixed may move.

Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.

Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.

The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.

ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.

Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.

Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:17:22 -07:00
Edward Langley faa2f28f29 Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default 2026-09-21 23:50:06 -07:00
Edward Langley 9156b48f12 fix: the page menu must not claim a modified key (ChatGPT Codex, PR #123)
The menu's early return sat ABOVE `app.js`'s `metaKey` guard, so with the menu
open `Cmd-R` reached `menuKey`, was answered `ignore`, and was then defaulted
away by `if (!e.ctrlKey && !e.altKey) e.preventDefault()` — true for Meta.
Reload was blocked. `Ctrl-j`/`Alt-Home` had the mirror fault and MOVED the menu,
two lines below a comment saying Ctrl and Alt stay the browser's.

Ordering plus one bail: the `metaKey` guard goes back above the menu branch,
`pageMenuKeydown` returns on `ctrlKey || altKey`, and what remains claims
unconditionally — every unmodified key, `ignore` included, which is the rule the
menu exists to keep.

The harness assertion installs its `defaultPrevented` probe AFTER load so it
runs second and sees what the page decided, and ends on an unmodified `j`
asserting `["j", true]` — without which "nothing was prevented" is satisfied by
a page that claims nothing. Proved by restoring the reviewed code: it fails on
`Control+j` moving the active row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:36:43 -07:00
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 0a920f1d54 fix: declare click_page_item on the wasm-surface ratchet
`web/tests/bundle/wasm_surface.test.js` pins the "handful of calls" the page
reaches the client through, and its own comment says a new one has to come and
say so. This is that. The reason it belongs on the list rather than above the
seam: the page holds no `Basis`, so `SetPageSelection` cannot be composed there.

It needs a built `web/pkg`, so it lives in `web/tests/bundle/` and is outside
the `web/tests/*.test.js` glob the brief's gate names — it only ran in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:29:49 -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 926396352f fix: seat the build stamp in #120's redesigned bar, and stop it faking a ticket id
Three things the merge with #120 (ui/shell-polish) actually required, none of
which a clean auto-merge could have told us.

MOVED, not preserved. #120 left <header>'s markup alone but gave every child
explicit styling and a stated reading order -- "what this is, which document,
which view, which mode, what keys, what the peer is doing". A build identifier
is "what this is", so the span moves from after #status to beside <h1>. Keeping
it at the tail was loyalty to my own first draft: it read as metadata trailing
#status, and with #hint's `flex: 1 1 auto` fighting for the slack a 40-char
commit SHA there is the thing that crowds the bar.

STYLED, because unstyled is illegible-by-default in a design system. It takes
`var(--dim)` -- the same token #hint and #status use, so it tracks both themes
by construction rather than by a hand-picked colour -- at 0.72rem, and clamps
to 14ch. Measured under Chromium on the merged tree: a tag renders whole
(scrollWidth == clientWidth, 69px), a 40-char SHA truncates to a 13-character
prefix with an ellipsis, and `header` does not overflow in either case
(scrollWidth == clientWidth == 1280). Contrast of rgb(107,103,95) on
rgb(244,241,234) is ~5.0:1, above AA for small text. Selecting the text still
copies the whole SHA -- text-overflow is a rendering clamp, not a truncation.

NOT AN ID. `improvise-web.tar.gz` puts the token `improvise-web` in the tree,
and AGENTS.md's audit matches `improvise-[a-z0-9]{3,8}` -- so the artifact name
resolved to no ticket and read exactly like a dead id, which AGENTS.md calls
worse than one. `improvise-installer.sh` escapes only by being ten characters,
which is luck rather than a rule, so the exclusion list gains `web` and the
paragraph gains the general case.

RELEASING.md (which arrived in this same merge, from improvise-gx5d) listed
what a tag publishes and was made wrong by omission; it now names the web
capture and says plainly that it is not what Pages is serving.

Verified on the merged tree: 1631 cargo tests over 37 binaries, node --test
47 -> 50 (so #120's three new shell tests survived the merge rather than being
silently dropped), 42 Playwright tests green under Chromium, and the stamp
gate re-proved by injecting the missed substitution again -- a header rewrite
does not carry the old fault injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:01:08 -07:00
Edward Langley 1ac2a13b6c Merge main (#120 ui/shell-polish) into feat/oujb-web-in-release
#120 restyled the page and rewrote #backend-notice as a <details>; <header>'s
markup is untouched, so #build-version lands where it was written. What the
merge actually has to answer is the CSS, and the stamp gate after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:56:32 -07:00
Edward Langley de24c65119 fix: the build stamp's check must not depend on a title attribute
Self-review of the previous commit. The assemble-stamp pattern spelled the
whole opening tag, including the title text, so a purely cosmetic edit on
ui/shell-polish -- which is rewriting this header right now -- would have
failed the assembly with a message about a missing cache-bust stamp.

`id` moves last in the tag and the pattern is `id="build-version">TOKEN<`,
which still verifies the token is inside that element and nothing else.
Re-proved discriminating by injecting the missed substitution: exit 1 with
`assemble: refusing to ship; unstamped: .../app/index.html`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:49:13 -07:00
Edward Langley 90ccfb0154 feat: attach the assembled site to every release, and let the page name its build
improvise-oujb. A tagged release carried four native tarballs and nothing for
the web; the site shipped only by ci.yml's pages-deploy, on a push to main.

Pages keeps tracking main (user, 2026-09-22: "I like Pages showing the latest
main for now"), so this is a CAPTURE, not a second deployment: the tarball is a
self-hostable copy of the site as of the tag and a rollback target, not what a
visitor to the published site is running. pages-deploy is untouched.

Mechanism is dist's own [[dist.extra-artifacts]], so it survives `dist init`:
`dist plan` under the pinned 0.30.4 lists improvise-web.tar.gz beside the four
platform tarballs and `dist generate` leaves release.yml byte-identical. The
build command is scripts/build-web-release.sh, which calls web/build.sh and
web/assemble.sh -- the same two scripts ci.yml and a developer run, so there is
no second way to build the bundle. Its toolchain pins come from
scripts/web-toolchain-versions.env, which ci.yml's build job now reads too.

`github-build-setup` was measured and does NOT help: it injects into
build-local-artifacts only, which is the wrong job for a global artifact.

The page now names the build it was assembled from in #build-version, stamped
by web/assemble.sh with the same token as the bundles and verified by the same
scripts/assemble-stamp.sh gate. An unassembled tree honestly reads "unstamped".

Verified by running `dist build --artifacts=global` locally: the script ran,
the site assembled, and dist placed improvise-web.tar.gz in target/distrib.
Not verified: a real tag push, which would require cutting a tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 22:37:05 -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 56b3058136 docs(browser): the arguments for the page bar, the totals and the delegation
Self-review findings, applied.

The word `Total` is now spelled twice — `src/ui/grid.rs` and
`web/shell.js::gridPlan` — which fails design-principles §5's own test: would a
third front end write this again? Yes. It is kept local on the `indicator()`
precedent (the collapse glyph is spelled in both ends while the FACT is shared)
rather than built into a protocol string table for one word, and it is now
NAMED as debt at both the call site and in `context/code/clients.md` instead of
being left for the next reader to find.

`frameCostOf` now counts the totals strip, and that is a change to a rule
`context/code/clients.md` already states — so the section says why the strip is
reserved where the inline group rows deliberately are not: it is present
exactly once, never interleaved with data, so counting it is neither the
feedback loop nor the ratchet that argument rules out. The page bar needs no
rule at all, being outside the box `extentOf` measures.

The evidence file claimed three injections into `dispatch.test.js` that had not
been run. They have been now, and the claim is rewritten to match what actually
failed rather than what was planned — the third was `navigable: true`
unconditionally, not the `position !== ""` reading the text described.
`doctrine(an_injected_fault_must_be_the_fault_you_named)` applies to the
description as much as to the fault.

Injections run and read, all against the served copy or the source:
  gridPlan takes the table row index    -> both round-trip tests fail
  whole() drops its empty-string guard  -> both "means nothing" tests fail
  navigable: true                       -> the Filter test alone fails
  grid bound to `click`                 -> 4 Playwright tests fail, including
                                           the pre-existing improvise-0x9u guard
  paintPages never called               -> the page bar assertion fails
  the tfoot block never runs            -> the totals assertion fails
  page_coords deleted from render_model -> the Rust page-bar test fails
  totals_strip returns Vec::new()       -> the Rust totals test fails, and the
                                           new cache-parity guard reports it
  PageAxis::cycle replaced by items.is_some() -> the cycle index test fails

Two residuals named rather than discovered later: the `Total` spelling above,
and the page bar being display-only — clicking an entry does nothing, because
composing a page change from a click needs a `click_page` on the wasm surface
that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:26:57 -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 a55e7dd5b7 Merge branch 'main' into wave/n-browser-boot 2026-09-21 14:51:37 -07:00
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:46:24 -07:00
Ed Langley f152d105e6 Merge pull request #87 from fiddlerwoaroof/wave/harness-generator-gp
Wave G: the generator's real cost, the artefact families, and two harness gaps
2026-09-20 22:10:19 -07:00
Edward Langley 22bb621dd8 test(harness): close the stale-assemble hole, and decide the dropped assertion
improvise-669b. PR #65 single-homed HANDSHAKE_TIMEOUT_MS in web/shell.js, but
the home is single in the TREE: the spec imports web/shell.js and the page
loads harness/serve/app/shell.js. createShell now exposes timeoutMs() beside
timesArmed(), and outlastTheWatchdog requires the running page's value to equal
the one it imported. Injected with the ticket's recipe (8000 -> 100 in source,
no rebuild): 5 passed / 1 failed where it was previously 6/6 green.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:56:52 -07:00
Edward Langley 3542d6f717 fix(tooling): a leaf is not an identity, and a convenience flag was a bypass
Both blockers from the adversarial review of #83, both in the mode this PR
rewrote, both producing a pass while the gate did not do what its prose said.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:37:44 -07:00
Ed Langley 279bf3a848 Merge pull request #71 from fiddlerwoaroof/wave/browser-keys
fix(web): an advertised key is never silent, a formula is visible, and +cat commits
2026-09-20 17:23:23 -07:00
Edward Langley d1ce2ecbc3 fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:30:55 -07:00
Edward Langley ec03f950b3 Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks 2026-09-20 14:36:19 -07:00
Ed Langley 74ccd6f030 Merge pull request #65 from fiddlerwoaroof/fix/socket-close-routing
fix(harness): restore the watchdog close-routing guard PR #61 deleted
2026-09-20 14:31:28 -07:00
Edward Langley 89f67afd4c fix(harness): make a vacuous watchdog denial fail, and single-home the window
PR #65's review found this PR had planted a fresh instance of the defect it
exists to repair. Both blocking findings fixed, each demonstrated.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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