Commit Graph

62 Commits

Author SHA1 Message Date
Edward Langley ef7775efc0 Merge remote-tracking branch 'gh/main' into wire2/j4qt
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 21:28:26 -07:00
Edward Langley 3039ce8e58 Merge gh/main into fix/df2w-i0v9-browser-page
Conflict only in context/test-evidence/clients.md, both appended at EOF:
kept df2w's two entries and ktlk's section, whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:12:41 -07:00
Edward Langley a77e3a9146 A read-only tab refuses edits instead of painting what it cannot save (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:12:24 -07:00
Edward Langley 942211b921 df2w: an open, empty formula panel is drawn
views/formulas were empty-means-closed, so an open panel over a document
with no formulas built the same RenderModel as a closed one. Option<Vec<_>>
carries both facts in one field; the page hides on null.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:54:26 -07:00
Edward Langley 334e218c7b s8h7: i on an aggregate names drill, in help and in the browser
In the TUI, EditOrDrill hands an aggregated cell to DrillIntoCell, so `i`
on an aggregate is drill by another door. The help page called it only
"Edit current cell", and the browser refused it in words unrelated to `>`'s,
so a user could not tell the two were one missing feature.

- help.rs: the edit-or-drill row says it drills into an aggregate.
- help.rs: Normal-mode Enter was captioned "Edit current cell (same as i)"
  but is bound to enter-advance alone, which only moves down; the row moves
  to Cursor movement and says so.
- resolve.rs: the Aggregate arm now reads "an aggregate is edited by
  drilling in — " + not_yet("drill"), the same sentence `>` uses.
- context/code/clients.md and the evidence entry reverse the recorded
  "different sentences on purpose" rationale, which was false about the TUI.
- harness/tests/drill.spec.js follows the new wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:53:18 -07:00
Edward Langley 76f69ce671 Merge branch 'main' of github.com:fiddlerwoaroof/improvise into fix/tg27-narrow-window
# Conflicts:
#	context/test-evidence/clients.md
2026-09-22 16:00:00 -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 253b84b2da fix(protocol): a refused frame is put back by the fold that made it (improvise-nxah)
`Client::key` folds a locally-resolved batch before sending it, so the page
redraws on the keystroke. A refusal left those folds in place: measured against
a real `Host`, a refused commit left the client one row down with its edit
buffer cleared while the host's mirror of that same client was still at the
origin holding the user's typing.

What rolls back is `ViewState` — the thing the fold produces — never a cache. A
`Command::Model` is a no-op in `reduce_view`, so the optimistic fold never
touched the caches, and the bug report's own evidence (`page_coords`) cannot
diverge this way: it moves only under `Projection::Grid`. What diverges is the
mode, cursor, status and buffers that ride along with the model command.

The rollback needed a positive acknowledgement first, which the protocol did not
have: with only `Rejected` on the wire, a client holding two frames in flight
cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that —
emitted once per accepted mutating frame, last, after everything the frame
produced — the same shape as the basis-publication gap ADR 001 originally left.

Two existing facts make the rule usable rather than a convention: only
`Command::Model` can be refused, so a frame carrying none is answered by neither
and needs no entry; and `Rejection::refused_an_edit` already separates the two
`Save` refusals, so a failed write cannot consume the answer owed to an edit.
A refusal drops one frame and replays the rest, because a single saved snapshot
is unsound under pipelining — it would discard a later frame the host applied.

This does not do improvise-3al1's force, and it does not contradict it: the
2026-09-21 wave recorded the two tickets as contradictory on the ground that a
rollback would wipe the staged typing. It does the opposite — the typing frames
carry no model command, are never refused, and have already settled, so rolling
back the commit restores the buffer to the typing. Asserted, not inferred.

Reasoning: context/code/clients.md, context/code/protocol.md §4.
Evidence: context/test-evidence/clients.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:29 -07:00
Edward Langley 7e6cac0885 fix(protocol): a key can ask its host to save (improvise-jz6w)
`ClientFrame::Save` landed with improvise-368h and `Host::handle` has answered
it ever since, but `KeyOutcome` was `Local | Upstream | Ignored`, so no
resolver could say "send this frame". `save` therefore sat on
`the_command_names_that_still_need_the_host_are_these`, resolved to `Upstream`,
and `Host::handle` answers `Upstream` with an empty vec — gotcha 47's black
hole. An implemented server feature that no key could reach.

`KeyOutcome::Request(Request)` closes it, with `Request::Save` the one member
and `Request::frame` the one mapping to an envelope. The variant carries a
narrow enum rather than a whole `ClientFrame` on purpose: a resolver handing
out arbitrary frames could compose a `Viewport` (the page's fact, not the
keymap's) or a `Commands` batch, which would be indistinguishable on the wire
from a `Local` while skipping the local fold `Local`'s contract requires.
`requested` is checked first in `step`, so no name is in two lists, and a
request cannot travel inside a `Binding::Sequence` — a sequence is one frame.

`save` leaves the host-needed list in the same commit, as gotcha 47 requires.

The guard is end to end, in `browser_round_trip.rs`: Ctrl-S on a real `Client`
yields `ClientFrame::Save`, and a real `Host` over a real `App` writes the
document to storage. Two half-tests that never meet is exactly what left this
gap open. Reasoning in `context/code/clients.md`; evidence and the injected
rivals in `context/test-evidence/clients.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 13:52:36 -07:00
Edward Langley b0739326f8 review: two sibling passages went stale when the new section landed
ChatGPT Codex on PR #128, correct on both, against head d124f18 and still live
at aa86a48.

The bd9o section says create_column is now explicitly dead. Two older passages
describing the same fact were not updated and still read as though a user key
reached it:

  - clients.md's "classes that remain" bullet: "the records-mode commit — EXCEPT
    the two creating columns". True of `commit`; false as a statement about what
    a browser can do, since the decline moved to the entrance of edit_or_drill.
  - the evidence entry for a_locally_committed_creating_column_agrees_with_the
    _host: its injection is described as failing at "the client must answer this
    key locally", which reads as a user key. The fixture sets RecordsEditing
    itself, so what the test still pins is commit's OWN narrowing and its advance
    behaviour — kept, so that reviving the door is a change to edit_or_drill
    alone and not a rediscovery of gotcha 23.

Neither is reverted; both are scoped, and both point at improvise-x0xd.

The lesson is the one already in gotcha 48 and it bit in its sharpest form: a
fact stated in three places is changed by a grep, not an edit — and the edit that
created the drift was the one that ADDED the third place. Writing a new section
is exactly when the other two stop being searched for.

Out of scope and reported rather than fixed: context/code/protocol.md:470 says
"No client composes it yet" about CellId::Create and cites improvise-gcvb as an
open gap. That predates gcvb landing and is stale independently of this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 01:10:59 -07:00
Edward Langley aa86a48af1 review: the "grid, not mode" clause was argued and unchecked
Adversarial review of d124f18 found that the clause the fix was argued on had
nothing behind it. Both mode-shaped guards were injected and the WHOLE WORKSPACE
stayed green at 1659:

    if state.view.mode.is_records()   1659 passed, 0 failed
    if mode.is_records()              1659 passed, 0 failed

`the_tile_bar_route_into_records_mode_does_not_strand_the_user` cannot see it,
and not by accident: it enters records mode the way a user does, where the grid
and the mode AGREE. It is standing next to the question rather than on it —
a_careful_comment_is_weak_evidence_that_anything_checks_it, with the comment
being the one that argued hardest.

The separating input is a client whose mode has DRIFTED off its own records
grid, which is the fixture `a_records_commit_is_declined_off_the_grid_and_not_off
_the_mode` already built for `commit`. This adds its twin for the door, with the
same regular-grid control so the decline is not merely "this client never edits".
Both injections were re-run against it and both now fail at its first assertion.

Two prose corrections from the same review, each an overclaim:

  - "the toggle-records-mode decline is now TRUE" — it is not. The wording is
    "records mode is not available", and the tile bar still puts a browser in
    RecordsNormal with a records grid drawn. The fix closes the SILENCE, not the
    route; what is now true is that EDITING there is unavailable. So the
    stranding rule is still violated by the tile bar, and this guards the exit
    rather than the door — deliberately, since the room is worth being in and
    only the writes are missing. Saying otherwise was the same defect one layer
    up.
  - The CellId::Create arm's justification named a state that cannot exist:
    project.rs::cell_id emits Create only inside `if layout.is_records_mode()`,
    so the entrance guard takes every such cell and the arm change is a
    functional no-op with no test behind it. Kept, but no longer read as covered.

And the evidence file's column table stopped at 2-3 for a five-column fixture,
so a reader reconstructing it got four columns. The `+` row is restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 01:04:48 -07:00
Edward Langley d124f18c2e fix(browser): the tile bar reaches records mode, where every key was silent
`declined()` answers `toggle-records-mode` out loud and the code read as though
that closed the route. It did not. The tile bar carries the virtual `_Index` and
`_Dim` (`View::tileable_cats`), `set-axis-for-tile` composes locally, and records
mode auto-detects from `_Index` on Row plus `_Dim` on Column — so a handful of
undeclined keys land a browser in `RecordsNormal`, where `i` did nothing and said
nothing.

The silence had TWO sources, and the ticket predicted one. On a measure column
the cell is a `CellId::Record`, so the editor opened and `Enter` died in
`commit`'s `grid.records` early return. On a COORDINATE column the projected cell
carries no `CellId` at all, so `edit_or_drill` short-circuited before reaching any
arm — improvise-fdk1 seen from the key path, since a coordinate rename names a
record the `RecordHash` surrogate cannot address.

So the guard goes at the entrance of `edit_or_drill`, before the cell is looked
at, rather than on the `Record` arm: a guard on the arm answers "is this cell one
we cannot commit" where the question is "is this grid one we cannot commit in".
It reads `grid.records` and not `mode.is_records()` for the reason `commit`
already did — the grid is the observed fact and the mode settles from it.

`CellId::Create` now declines instead of returning `None`, so a creating column
reaching this function is a door rather than a hole. `create_column`
(improvise-gcvb) was already unreachable and is now explicitly so.

The regression guard walks every column of the records row and prints each
cell's `CellId`, because a fixture on the measure column — the one the ticket's
transcript stands on — cannot see the other two. Two rivals were injected and
each fails on a different assertion; both are recorded in the evidence file.

Closes improvise-bd9o.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 00:54:22 -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 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 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 0930e9e360 Merge remote-tracking branch 'gh/main' into ui/shell-polish
# Conflicts:
#	context/test-evidence/clients.md
2026-09-21 22:34:08 -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 6b28e58352 Give the browser a key that reaches the view history (improvise-cfly)
PR #115 put `Command::ViewBack`/`ViewForward` in the view half, made
`reduce_view` fold all three view effects, and gave every client its own
`view_back_stack`. `resolve.rs` was owned by another agent that wave, so
`declined()` still refused `view-back`: the merged wave held the capability
and no key, and `<` in a browser reported a feature that was fully supported.

Delete the decline together with its comment, whose stated reason ("it
commits a drill first, so composing it would commit another client's typing")
was false by the time it shipped. `ApplyAndClearDrill` is off the wire, so a
resolver cannot compose it; and no browser path stages a drill at all —
`drill-into-cell` is declined and `edit_or_drill`'s `Aggregate` arm answers
out loud. The divergence from the `Cmd` is confined to a state this client
cannot enter.

The asymmetry that IS real is a different one: `reduce_view`'s `stepped` is
silent on an empty stack where `ViewNavigate` emits "No previous view" /
"No forward view". `step_history` mirrors both arms, so a client that has
nowhere to go hears the same sentence the TUI does.

Found on the way and not predicted by the ticket: `view-forward` is bound in
NO keymap, in either front end. The arm is added and tested by name, and the
test asserts the absence of a binding (with `view-back`'s presence as the
control) so that adding one has to come there and say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:46:56 -07:00
Ed Langley 9eb13119ae Merge pull request #114 from fiddlerwoaroof/wave/s-resolve-keys
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: five category-panel and axis defects, four of them P1 (koh6, g3no, uej8, z336, 25bf)
2026-09-21 21:26:26 -07:00
Edward Langley aeff017626 fix(panel): a singleton category's item reached the escalation anyway (Codex P1)
A ChatGPT Codex review of #114 found the cursor fix wrong for a category with
exactly ONE item, and it is this PR's own argument turning against it: that is
the configuration where the conditional retreat degenerates into the
unconditional step-back the PR argued was worse than the bug. No sibling below,
so it stepped back onto the category's own header -- one d from RemoveCategory.

Reproduced on both paths first, asserting the CONSEQUENCE rather than a landing
index: rebuild the post-deletion tree, seat the cursor where the command put it,
and ask whether a second d composes a RemoveCategory. It did, on both.

The enumeration is what makes the second fix total. CatTreeEntry has two
variants and flatten_cat_tree emits a header then its items, so after deleting
the item at i: below is a sibling -> the list closes up, stay; above is a
sibling -> step back; NEITHER -> the category is now empty and both neighbours
are Category rows, so there is no safe ADJACENT landing at all. That third case
is structural, which is why the rule could not be patched in place.

The landing is therefore the nearest Item row, preferring above then below --
never a header. Cost, stated rather than hidden: the cursor may cross into
another category, and a held d then deletes one item per press, each with its
own status line, walking upward. It never reaches RemoveCategory, which is the
criterion the review asked for. Landing on the emptied category's own header
was rejected even though RemoveCategory there is cheap -- an empty category has
no cells, and a non-measure formula target IS an item, so no formulas. That is
a harm-magnitude argument where the repo prefers the bad state unreachable.

Residual, stated: if the deleted item was the only item in the whole tree there
is no Item row anywhere and the cursor does not move. Every category is empty
then, so no landing exists that would avoid it.

Both fixtures are fenced so they cannot pass for the wrong reason. The browser
one expands BOTH categories, because a first version expanded only the
singleton's and the cursor fell off the shortened tree, where the delete arm
answers that there is no category to delete -- green for a reason unrelated to
the fix. The TUI one seats the two-item category FIRST, so the backward search
(the preferred direction) finds a real landing.

Injected the pre-review rule on both paths: each new test fails, and the two
adjacent-case tests still pass. The singleton is the discriminating input and
nothing else in the suite sees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 21:21: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 e2417414ad docs: the arguments the new comments point at, and the test evidence
context/code/core.md gets the two model rules (why removing a name removes
the formulas targeting it, and why a required virtual category refuses) and
the axis rule (why leaving the constraining axes forgets the page selection,
and why the predicate is filters_data rather than 'is it Page').

context/code/clients.md gets improvise-koh6's decision in full -- including
why the state-dependence rule is about Upstream and not about outcomes --
plus the cursor rule and the shared axis sentence.

Evidence entries are module-qualified where they must be: three leaf names
are answered by a test in both resolve.rs and panel.rs, and one in both
model/types.rs and workbook.rs.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:15:49 -07:00
Edward Langley 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 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 47a070fec4 docs(clients): three "free" bindings priced, and what a parsing client costs
improvise-4bwa and improvise-cl7y. Neither can be CLOSED from this crate — both
land in `improvise-protocol` and `improvise-command`, which another agent owns
in this wave — so what lands here is the part that was actually missing: the
design of each, and a measurement nobody had taken.

improvise-4bwa. `view-back` is DECLINED in writing, which its own acceptance
criteria allow and ask for. It is not an enrichment at all: `ViewNavigate
{ forward: false }` emits `ApplyAndClearDrill` first, whose operand is
`drill_state.pending_edits` — session-local residue routed with no `SessionId`
— so a client that mirrored it would commit somebody else's uncommitted typing,
the exact thing improvise-3x3u removed. Blocked on ADR 001 layer 2.

The sharper reading, which the ticket did not have: `mirror_the_cmd_line_for
_line` is a rule about RESOLVERS, and a `Cmd` whose first effect is
unaddressable is not a `Cmd` a thin client may mirror at all. The doctrine does
not conflict with the decline — it does not reach it.

The other two are recorded with their shapes. `toggle-prune-empty` is one bool
on a per-view projection; the `ModelCommand` is composable today and it is the
SENTENCE beside it that a client cannot write, so mirroring it diverges on the
status line in half of all presses. `delete-formula-at-cursor` needs
`FormulaCache`'s ELEMENT TYPE to carry the target and its category — a `targets`
vector beside `formulas` would be two answers to "what is in the formula panel",
improvise-edp's disease and the one improvise-anr9 had to pay a type change to
undo.

improvise-cl7y. Its note leaves the dependency route's cost unmeasured, so it is
measured: `improvise-formula` added to this crate, `parse_formula` called from
an exported method so nothing is stripped, bundle built both ways.

    today                      503,854 raw   191,613 gzipped
    with improvise-formula     634,515 raw   238,973 gzipped
    cost                      +130,661       +47,360
                                (+25.9%)       (+24.7%)

A quarter of the download for a status string — evidence for the other route,
the host addressing a status back to the composer as `ClientFrame::Save`'s
rejection already does. What the number does not settle is the more interesting
half: a parsing client is a SECOND DERIVATION of whether a formula is well
formed, agreeing with the host only while both call the same function. So it is
a floor, not the price of a feature.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:37:44 -07:00
Edward Langley be01339039 Merge gh/main (4e48a6e, PRs #70 and #72) into wave/browser-keys
Two doc conflicts, both pure appends at the same place, both resolved by
keeping BOTH sides:

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:55:04 -07:00
Edward Langley 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 f9580a33af docs: what the harness now reaches, and the evidence for each assertion
The engine-coverage and measurement passages in context/code/clients.md, the
boundary table (four boundaries become six), and README's caveat -- which
named the grouped and multi-level DOM as never executed, and it now is. A fact
stated in more than one place is a grep, not an edit (gotcha 48).

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:33:56 -07:00
Edward Langley ec03f950b3 Merge remote-tracking branch 'gh/main' into fix/web-panel-clicks 2026-09-20 14:36:19 -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
Edward Langley 692aa0b1d3 docs(clients): compare test time, not two invocations' wall clocks
The eleven deleted per-widget tests and the three contract tests replacing
them were compared at 3.3s against 3.4s, which reads as a near-tie. It is not
one: those were two separate `npx playwright test` invocations, each paying its
own cold start (~0.6s and ~1.9s), so the wall clocks measured startup as much
as tests and flattered the replacements. The per-test sums are 2.7s against
1.55s.

The conclusion is unchanged and has more margin: eleven tests at 2.7s cannot
buy a twenty-second saving, so the deletion of the eleven bought shape and the
two real-timer watchdog waits bought the speed. It also explains, without
needing anything else, why the contaminated baseline's clock barely moved —
1.55s of contract tests, warm inside a full suite, lands under a second against
a 36s total.

Same discipline as the row above it in the table, applied to the other side of
the comparison: two numbers are only a comparison when they were measured the
same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:06:00 -07:00
Edward Langley 481f1da444 docs(clients): measure the baseline on a clean tree, and quote only that
Third correction to one number, and this time by measurement rather than
arithmetic. `1af51c9` reached by a FULL checkout — which removes a tracked
file the target does not have, where `checkout -- <paths>` leaves it — then
built and run twice on a private port, and the same for this branch in the
same session:

  before  23 chromium, 26 passed + 3 skipped, 36.2s / 36.3s
  after   14 chromium, 17 passed + 3 skipped, 15.1s / 14.9s

Three things that changes.

The `before` row was internally inconsistent: it paired a corrected chromium
count (23) with the contaminated run count beside it (29), and 23 + 3 firefox
is 26. A row whose columns do not add up is a row built from two trees. The
note now says the columns must agree, because that identity is what caught
this every time it was caught at all.

`32.8s` is gone. It was not measured by anyone who could hand me its
provenance — I attributed it to the reviewer, who had only ever counted
`1af51c9` statically and never ran a full pre-PR suite — and it does not
reproduce here. Quoting an unattributable number inside the paragraph that
exists to explain an unattributable number was the wrong repair.

And the contamination's cost was the COUNT, not the clock: the contaminated
run read 29 against the clean tree's 26, but 36.9s against 36.2s, because the
three extra tests cost under a second between them. A wall clock absorbs a
contaminated tree without a trace; an arithmetic identity does not. That is
now the stated lesson, and the same error surviving three corrections — into
PR #61's body as "26 chromium", then through a fix that corrected one column
and left its neighbour — is the worked example of `clients.md`'s own rule that
a fact stated in more than one place is changed by a grep, not an edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:05:04 -07:00
Edward Langley bf01469cf8 docs(clients): label the test counts, and correct a contaminated baseline
Review of the fix found the speed table quotes one number where there are two:
every spec but `opfs.spec.js` is Chromium-only, so a count read off a run is
three larger than the count of tests in the files, with three more skipped. The
table now has a chromium column and an all-projects column, and says so — the
same "two numbers, not one" the Rust suite learned in design-principles §7.

Chasing that ambiguity turned up a worse error in the number itself. The
`before` figure was measured with `git checkout 1af51c9 -- harness web` inside
the working branch, which restores TRACKED files and leaves untracked ones
where they are — so `contract.spec.js` stayed behind and the "baseline" ran
main's 23 tests plus three of the branch's own. It read 29/36.9s, which looked
entirely plausible. The honest figure, measured on a clean tree, is 23/32.8s.
A baseline taken by checking paths out over your own work measures a tree that
never existed, and that is now written down where the number is.

Also records the probe for the open question the faked clock left. A peer that
opens, stays silent past the watchdog window and is closed afterwards produces
a byte-identical `#status` transcript under `page.clock` and under a real
nine-second wait — four states, same order, same text. So `runFor` does not
flatten that ordering. What stays uncovered is an event landing strictly INSIDE
the collapsed window, which a synchronous `runFor` cannot deliver and which is
a race the page promises nothing about. Stated rather than left to be
re-derived; the probe itself is not kept, being a characterization of
Playwright's clock rather than of this repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:01:16 -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