Commit Graph

1259 Commits

Author SHA1 Message Date
Edward Langley 5e957e1f58 Merge remote-tracking branch 'gh/main' into wave3/protocol-session 2026-09-20 21:06:20 -07:00
Ed Langley 6c70dba910 Merge pull request #77 from fiddlerwoaroof/fix/render-frame-wave
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: six render/frame defects — group membership, leading headers, one run rule, and the hidden status line
2026-09-20 21:06:03 -07:00
Edward Langley 6f98bfd995 Review round 1: green CI, the SortData hole reopened, the paging regression removed
Four findings from the adversarial review of #78. Two blocked; all four are
real and one of them was a straight error on my part.

1. CI WAS RED AND I NEVER RAN THE CHECKS. Nine comment runs over the
   two-line cap, all mine, all introduced this PR; both checks exit 0 at
   8a559b2 and run in under a second. Behind it `--check-evidence` also
   failed, with two orphan pairs that are the direct product of my own
   renames: the consolidation renamed two tests and left their evidence
   naming the old names. All thirteen fixed. The renamed entries are
   rewritten rather than relabelled — each now records what the rename was
   FOR, which is the thing a relabel would have thrown away.

3. MY SortData CONCLUSION WAS WRONG, and the mechanism I removed was
   needed. I reported "could not build a fixture where SortData reorders".
   It reorders trivially. The cause was not the reviewer's guess (already
   sorted insertion) but better: ToggleRecordsMode pushes effect::SortData
   as its FIRST effect, so a records-mode fixture built by pressing R has
   an already-sorted store BY CONSTRUCTION and cannot demonstrate anything
   order-sensitive. My store was fine all along.

   The `layout.records` coords hash is restored, and
   `sorting_the_store_in_records_mode_invalidates_the_cursor` pins it with
   a fixture that sets the axes directly. It asserts the row COUNT and the
   LABELS are both unchanged, so it is the sharp case rather than a shape
   change wearing its name. Removing the hash again fails that test and
   NOTHING ELSE IN THE WORKSPACE.

   The process error is the part worth keeping: I concluded "cannot
   reproduce" from ONE fixture without varying it, then filed that
   conclusion as a finding. "I could not reproduce it" and "it does not
   reproduce" are different claims and I filed the second having earned
   only the first.

4. THE page_selections COMPONENT IS DELETED. With prune_empty off a page
   flip renumbers nothing and the cursor was still sent home — PR #76's own
   ruling is that this is worse than the hole improvise-7kvw names. My
   comment defended it with a basis argument for a cursor decision, which
   is how it got written. `paging_with_nothing_pruned_invalidates_no_cursor`
   is the negative arm that was missing; restoring the three lines fails it
   and nothing else, and the positive arm passes either way because
   `with_frozen_records` already applies pruning — which is exactly why the
   negative arm had to exist.

ALSO: context/code/command.md gains "Two observations, not one". My ni6k
reasoning lived only in a commit message, which design-principles.md §6
explicitly forbids — "a reason recorded only there is a reason nothing can
ever find wrong". `view_frame`'s doc pointed at a section that did not
exist; now it resolves. The stale "since improvise-9mo9 they call
reset_cursor" sentence is corrected: no effect calls it any more.

Finding 2 (the #77 merge conflict over AxisEntry::GroupEnd) is NOT in this
commit — #77 lands first and I merge main afterwards, handling the variant
deliberately rather than letting it fall into a `=> {}` that would make a
frame change invisible to the cursor.

Filed, not fixed: improvise-5ubs (a cyclic-successor all() can omit a
variant while its doc says it cannot — the reviewer's meta-finding, that a
consolidation owes a test its surviving derivation is TOTAL) and
improvise-9v0j (view_frame vs view_shape separated by example, not by
property). improvise-gfvj closed with the correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:03:40 -07:00
Edward Langley 970bae8ad5 docs: the geometry-visibility evidence said three and listed four
`every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint`'s entry
counted its `prepare` cases wrong, and the count is the load-bearing part: four
of the thirteen assert nothing without a precondition, so an entry that reads
"three" invites a reader to find the fourth redundant and delete it. Names why
each one is needed — `ToggleGroup` checks membership and is otherwise a no-op,
`TransposeAxes` on two identical axes is the identity — and states that clearing
`dirty` after `prepare` rather than before is what stops the preparation from
satisfying the assertion on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:01:06 -07:00
Edward Langley bd3967cc7c fix: guard improvise-mai9 at the call site, and correct §3's group_of
Review of PR #77 found the mai9 fix unprotected and one doc paragraph
contradicting another. Both were right; both are addressed here.

The guard was aimed at the wrong thing
--------------------------------------
The mai9 defect was in the WIRING — `draw_bottom_bar` built
`format!("{}{}", mb.prompt, buf)` plus the cursor glyph and never consulted
`status_msg`. Both tests called the composing helper `minibuffer_line`
DIRECTLY, so nothing exercised the call site. Reverting that one line restored
the bug in full with the suite green at 1411, reproduced here before rewriting.
The helper did not even go dead: the test module's `use` kept it alive while it
asserted nothing about the product.

`a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open` now renders
`draw_bottom_bar` through a real `Terminal<TestBackend>` and reads the row the
terminal received. That is the only assertion that can fail for the real defect.
`the_minibuffer_prompt_is_not_displaced_by_the_message` is beside it because a
"fix" that dropped to `draw_status` would satisfy the first on the message
alone. Injected fault: the pre-mai9 `format!` — both fail, the first naming the
missing message and printing the row it did compose.

And the negative arm could not fail, twice over
-----------------------------------------------
`fill_line` computes `pad = width - left.len() - right.len()`, so with an empty
status an unconditional `format!("  {msg}  ")` produces a BYTE-IDENTICAL row at
any width where the line fits. The `match status_msg.is_empty()` is observable
only in the band where the prompt fits but `left.len() + 4` does not.

Rewriting it against that band was not enough: the first attempt derived its
lower bound by calling `minibuffer_line` itself, so the injected fault moved the
bound with it and the loop skipped the failing band entirely. The bound is now
spelled locally. A bound read off the function under test is not a bound.

Injected fault: `right` built unconditionally. Now fails at width 16 with
"an empty status still composed 20 columns".

context/code/protocol.md contradicted itself, 270 lines apart
-------------------------------------------------------------
§3 still said `group_of` answers "nearest preceding header", DELIBERATELY, and
that encoding an end boundary "is filed rather than smuggled into" the anr9
migration — while §3a, added by this branch, says `GroupEnd` is the boundary. A
reader arriving at §3 first would conclude this PR smuggled in exactly what that
paragraph forbids. It did not; improvise-gd83 IS the filed ticket. §3a was
written as an addition where it needed to be a CORRECTION, and gotcha 48 turned
on its author — worse than the `bindings.rs` case only because both copies sit
in one file.

§3 now records that the rule changed and which ticket changed it, and §3a points
at the correction rather than restating the history. The neighbouring paragraph
was stale too: `body_from`'s argument no longer exists "only because the two
front ends scroll differently".

Filed, not fixed
----------------
  * improvise-1wl0: `#[serde(default)]` on `AxisFrame::first` forges the
    permissive claim — `first: 0` asserts "this frame leads the axis", which is
    what `body_from` acts on, so a frame that loses the field deserializes as
    leading. Same argument that gave `Basis` no `Default`. No skew ships today.
  * improvise-h0iv: `group_runs` coalesces by name alone where `FrameGroup` has
    `cat` to fix it. Right call for a consolidation — both prior copies did the
    same, and changing the rule while moving it would have cost the agreement
    test its evidence — but a single home reads as decided, which is the new
    risk and the reason to file.

Both are cited in `context/code/protocol.md` §3a, so the single home does not
read as settled.

1412 tests pass (was 1411; +3 wiring tests, -2 helper-only tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:01:02 -07:00
Edward Langley 87696d8b9b command: guard the page-flip no-op, and stop the geometry arm walking the cells
Two review findings on #76, both about code this PR itself introduced.

**`SetPageSelection`'s `moved &&` guard was unguarded.** Deleting it left the
whole workspace green at 1416, and it is not an edge case: `PageNext`/`PagePrev`
are odometer-style and emit a `SetPageSelection` for every page category on
every keypress, so the non-carrying categories are always no-ops and a single
one-item category re-selects the same item. Unguarded, each such keystroke bumps
`cursor_epoch`, sends the host cursor home and broadcasts `ResetCursor` to every
client on that view — improvise-75ld's "a no-op that refused everybody else",
one tier down. `selecting_the_page_already_selected_invalidates_no_cursor` fails
under exactly that deletion, and asserts the fixture is really on Q1 and the row
labels really did not move first.

**The `LayoutGeometry` arm no longer reads the wide fingerprint.** Pointing both
arms at `document_content` was an asymptotic change on the held-key path, not
the constant factor the first hand-back claimed. Benchmarked at 18,000 cells,
release, 200 batches after warm-up: a `SetCell` batch 8.07 ms → 10.21 ms (+27%,
a constant factor, since `record_fingerprints` already walked the cells), but a
`SetNumberFormat` batch 78 µs → **2.63 ms, 33×** — O(config) to O(cells+config),
covering `[`/`]`, axis cycle, hide/show, group toggle, transpose and prune
toggle, ~380/sec where they had been ~12,800/sec.

`document_config` comes back for that arm and `document_content` is now
`document_config` plus the cells and formulas, read only by `CellData`. It costs
no correctness because all five 748f-blind effects are `CellData` — but that is
a reading, and a reading is what this PR exists to distrust, so
`every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint` asserts it
over all thirteen. Dropping `view.number_format` from `document_config` fails it
naming `SetNumberFormat`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:55:58 -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 7f58800c0f Check improvise-lltt's deferral premise, and find it already false
improvise-lltt is deferred on a CONDITION its own note spells out — "it is
unreachable today ONLY because no shipped client emits Command::SwitchView"
— and asks for an interim guard asserting exactly that, so the silent
failure becomes loud at the moment the premise stops holding.

The guard fails on the first run. Two live paths, both in the browser's
own resolver (resolve.rs:359-368):

    VIEWS: switch-view-at-cursor   -> Command::SwitchView
    VIEWS: create-and-switch-view  -> CreateView + Command::SwitchView

and the other half was already known live: resolve.rs:331 composes
ModelCommand::TransposeAxes locally. So the scenario the note describes is
reachable today from the shipped view panel. lltt escalated P2 -> P1 with
the evidence on the ticket.

The guard lands as `the_keys_that_compose_a_switch_view_are_these`, an
assert_eq against those two names rather than an assert-empty: it pins what
is true, fails on a third live path, and says in its message that the list
is not an exemption list. When lltt is fixed the list empties and this test
goes with it.

Not fixed here on purpose — the honest fix is the protocol change lltt
describes, which is a work unit of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:46:42 -07:00
Edward Langley 585f25bf78 Invalidate the cursor by observing the grid's frame, not by a per-effect table
improvise-ni6k. Twelve effects called `app.reset_cursor()`; that table is
the shape gotcha 24 names as the disease, and it had a hole the day it
was written. The observation replaces it: `view_frames` fingerprints
every view's drawn frame, `apply_effects` diffs it per model effect, and
the cursor resets when the view it is in reframes. No effect declares
anything, and `adopt_cursor_epoch` reads the same set.

FOUR THINGS THE TICKET DID NOT PREDICT, each one a fixture or a design
question rather than a line:

1. The frame fingerprint and `layout_version` answer DIFFERENT questions,
   so they are two observations and not one. `AddCategory` of an empty
   category moves no row or column but does change the coordinates a
   client's CellKey hangs off — layout_version must bump, the cursor need
   not. `view_shape`/`last_reshaped_views` (config) is untouched and still
   drives `layout_version`; `view_frame`/`last_reframed_views` (drawn) is
   new and drives the cursor. Unifying them broke two tests in both
   directions, which is how the distinction was found.

2. The observation must run INSIDE the effect loop, not after it. At
   end-of-batch it clobbered deliberate cursor placement in the same batch
   — `[AddItem(measure), <place cursor>]` sent the cursor home, failing
   three records-mode tests. In-loop, a later effect wins, which is the
   same ordering property gotcha 45 gives `document_config`.

3. `GridLayout::new` does NOT apply `prune_empty`; `with_frozen_records`
   does. A frame hash built on `new` cannot see a pruned row, which is
   exactly the class improvise-7kvw is in.

4. Two per-effect no-op guards were carrying the whole weight and are now
   orphaned: `effective_items` and `item_is_drawn` are deleted, along with
   six inline `moved`/`drawn`/`real`/`existed` predicates.

COST, measured as the ticket required, `benches/reshape_observation.rs`
(new), one SetCell batch, same fixtures as `grid_projection`:

    typical   400 cells   172µs -> 206µs   (+20%)
    large    9.6k cells  3.70ms -> 4.17ms  (+13%)
    18k cells            7.09ms -> 8.41ms  (+19%)
    pathological 90k    35.2ms -> 39.8ms   (+13%)

The batch is dominated by `rebuild_layout`/`recompute_formulas`, not by
the fingerprint.

WHAT CLOSES AS A CONSEQUENCE: improvise-7kvw, asserted by
`paging_across_a_prune_that_drops_a_row_invalidates_the_cursor`.
AddFormula/RemoveFormula join the shared `reshaping_commands` table (a
formula target is a `_Measure` item, so it is a column — gotcha 7), each
proved reshaping by the table's own `assert_ne!` on the grid shape.

WHAT DOES NOT CLOSE, and this is the honest part: `SortData`. A pure
record reorder changes no label and no count, and records-mode `row_items`
are positional, so a row_items fingerprint cannot see it. I wrote the
mechanism (hashing `layout.records`' coords) and then REMOVED it, because
I could not build a fixture where `SortData` actually reorders anything —
records came back in key order whatever order the cells went in. Shipping
a confident comment over a mechanism I could not demonstrate is this
repo's named defect, so it is reported instead. See the hand-back.

Negative arms unchanged and still passing, including the refused-command
arm and `SetNumberFormat`; disabling the observation fails the whole
reshaping table, which is the proof it is what does the work.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:41:27 -07:00
Edward Langley 8f3a9768b7 fix: group membership, leading headers and one run rule on AxisFrame
improvise-gd83, improvise-0o6x and improvise-8qlr. One commit because they are
one change to `AxisFrame`/`AxisEntry` — a variant, a field and a method — and
all three move `tests/grid_frame_agreement.rs`, whose two ends must be changed
together or the test stops comparing anything.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:57 -07:00
Edward Langley 94a3d1754b fix: the minibuffer row carries a pending status message (improvise-mai9)
`draw_bottom_bar` rendered the minibuffer INSTEAD of the status line, so a
status message written while any text-entry mode was open was never drawn — not
truncated, not overwritten, simply never rendered. A test asserting
`app.status_msg == "..."` passed in every one of those cases, which is why this
survived improvise-6t3's careful ordering of `CommitBuffer`'s effects so a
failure message would win: it won a race to a surface nobody could see.

Of the ticket's two candidates, the row is shared. `minibuffer_line` composes
the prompt and buffer on the left and any pending `status_msg` right-aligned,
so the bottom bar stays ONE row and the exact-frame geometry does not move.

The alternative — making the caller structurally unable to report while its
minibuffer is open — is what `CommitSave` does by hand today, and generalising
it means repeating that at every text-entry commit. The ticket calls that a
workaround; sharing the row costs one function and covers all eight modes at
once, including the `:w /bad/path` path Copilot found on PR #27.

The failure is now loud: `a_status_message_set_while_a_minibuffer_is_open_is_
on_the_bottom_row` asserts the composed row, not the field. Its negative arm is
not decoration — a composition that appended unconditionally would pass the
first test and draw two spaces of nothing on every minibuffer keystroke.

Injected fault: the `false` arm returning `String::new()`, i.e. the old
composition. The first test fails naming the missing message; the second passes.

Known and out of scope: `fill_line` pads on `left.len()`, which is bytes, so
the right-hand text sits two cells right of its mark whenever the left carries
the multi-byte cursor glyph. `draw_status` has always done this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:39 -07:00
Edward Langley fc059c7ce1 fix: mode_style matches AppMode exhaustively (improvise-zx8t)
The repo map promises of `AppMode` that "add a variant and the compiler names
every one you must fill in". That was true of the five enum methods and false
of `src/draw.rs::mode_style`, which swept eleven variants into a `_ =>`
wildcard — so `SavePrompt` took `ExportPrompt`'s DarkGray by accident rather
than by decision, and the next person adding a variant would have trusted the
sentence.

Of the ticket's two options, the first: `mode_style` is now exhaustive and
stays in `draw.rs`, because `Style` is a ratatui type (gotcha 26). The second —
a semantic colour method on `AppMode` in `improvise-protocol`, the
`AxisColor`/`MinibufColor` shape — is NOT taken here, and deliberately: the
ticket says to decide it together with improvise-k099, which asks the same
question of `hint_text`/`label` and is not in this unit. The repo map's promise
needed exhaustiveness; the semantic enum is a separate, larger answer and stays
open.

No colour changes. The fourteen default-chrome variants are spelled out with
the DarkGray they already had, so this is the wildcard becoming a list.

The guard is the compiler, not a test — a test enumerating today's variants
would not fail when a fifteenth is added. Verified by deleting the
`AppMode::Help` arm:

    error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered

Also removes the doc comment calling the wildcard "a known defect", which this
commit makes false, and updates `context/code/tui.md`, which said the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:28 -07:00
Edward Langley 17e59335eb fix: one answer to "is this category virtual" (improvise-bidq)
`View::on_category_added`'s early return read

    cat_name.starts_with('_') || self.is_records_layout()

— the same expression mixing both derivations one operator apart, the left a
name prefix and the right `CategoryKind`. A reader who checks this line sees
the enum being used and has no reason to look at the operand beside it. The
second site, `holds_a_regular`, had the same split.

`Model::add_category` does not validate names, so a user category literally
named `_Foo` was `CategoryKind::Regular` to `records_columns`, `cell_id` and
`is_empty_model`, and "virtual" to these two lines — seated on `Axis::None`,
never appearing in a pivot, while every other consumer treated it as ordinary.

Decided explicitly, as the ticket asks: `add_category` is NOT made to refuse a
leading underscore. The three reserved names are the ones
`CategoryKind::of_name` knows, `_Foo` is a legal document, and a user-visible
restriction is a bigger change than making one answer out of two.

Both sites now ask `CategoryKind::of_name(..).is_virtual()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:40:04 -07:00
Edward Langley 05a28b0d45 Point the geometry test at the production hash, and test both abort arms
improvise-1xf4: `layout_geometry_effects_actually_change_view_config`
hand-listed 6 of the 13 LayoutGeometry effects and measured them against
`view_config_fingerprint`, a THIRD fingerprint existing only in test code
beside the production `document_config` and `view_shape`. Since PR #70
decides `model_state.dirty` for LayoutGeometry solely from
`document_config`, an effect writing something the test-only hash sees
and `document_config` does not would pass and never mark the document
dirty — i.e. never be saved.

It is now `layout_geometry_effects_actually_change_document_config`,
measured against `document_config` itself and listing all 13, counted off
the `impl Effect` blocks rather than trusted. Two fixture facts the
rewrite exposed and the old six never could: `ShowItem` on a visible item
and `ToggleGroup` on a nonexistent group are no-ops, so `grouped_app`
supplies a hidden item, a real group and a second view.

`view_config_fingerprint` SURVIVES, deliberately, and is not a third
derivation any more: it is used only by the NEGATIVE arm
(`view_only_effects_leave_shared_state_alone`), where a hash stricter
than `document_config` is what that test wants. The doc comment says so.

improvise-2avl: PR #70 moved the `layout_version` bump out of the effect
loop. Gotcha 40 records the in-loop position as load-bearing, and the
argument that end-of-batch is safe for `layout_version` specifically was
written into context/code/command.md and never tested. Both arms now are:
a batch that reshaped before aborting must bump, one that aborted before
reshaping must not. Proved independently — bumping unconditionally fails
only the second, skipping `observe_reshape` on abort fails only the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:38:35 -07:00
Edward Langley 1ea067c9ab command: dirtiness, aborts and the cursor — five edges in the effect layer
improvise-748f — a drill exited with no edits, and view-back on a document with
no drill, marked it dirty. `Effect::kind` takes no `App`, so the classification
cannot be state-dependent; the answer is to observe. The ticket proposed
observing the per-record raw-cell fingerprint, and that is **unsound**: five of
the ten `CellData` effects change the document without writing a raw cell —
`AddItem`, `AddItemInGroup`, `SortData` (order is in the file), `AddFormula` and
`RemoveFormula`. Observing raw cells would have left every one reading clean,
which is a lost edit rather than a spurious prompt. So `document_config` is
widened into `document_content` — cells in iteration order and formulas as well
as the categories, items, groups and view configuration it already carried — and
`CellData` joins `LayoutGeometry` in being read against it, in the loop, at the
position gotcha 45 requires. `WholeWorkbook` stays declared, and that is the
remaining over-report: a `LoadModel` does not move `file_path`, so the in-memory
document really does differ from the file the path names.
`start_drill_and_apply_clear_drill_with_no_edits` pinned the old behaviour and
is inverted.

improvise-z05r — `CommitExport` did not bail on an empty path where
`CommitBuffer`'s three kinds all do, and `ExportCsv` did not abort the batch
where `Save` and `SaveAs` both do. All three writers now report through one
`report_file_write`, and the test names the three so a fourth has to come there.
The commit reorders its batch too: the mode change goes at the head, because an
abort at the tail strands the user in `ExportPrompt`, where the minibuffer is
drawn *instead of* the status line and the export error is never seen (gotcha 21).

improvise-7kvw — `SetPageSelection` renumbers the grid under `prune_empty`,
because which rows survive pruning is a function of the page. It now resets the
cursor in exactly that case, and only when the selection really moved: sending
the cursor home on every `[`/`]` with pruning off is worse than the hole.

improvise-pubt — the ticket argued the drill arm of `display_value_in` was
probably unreachable with a foreign layout. It is reachable: records mode
auto-detects from the axes, so an ordinary saved view gives `GridLayout::new` a
records layout whose `cell_key` returns the synthetic key the arm matches. The
test fails on the old code. `staged_drill_edit` now consults `drill_state` only
for the host's own layout, by pointer identity, which is the question `grid_in`
is actually asking.

improvise-h074 — `resolve_view`'s fallback carries back the `.expect` message
`Workbook::active_view()` had. The second half, the host-side `DeleteView`
bypass of `adopt_deleted_views`, is untouched: it is a decision about
`Sessions`/`Host`.

Every guard here was proved by injecting the fault it names and watching it fail
by name; `context/test-evidence/command.md` records each injection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:38:18 -07:00
Edward Langley 472f7e5548 command: one Panel→flag mapping, and a guard that can see a permutation
improvise-y4xg and improvise-qsmp are two shapes of one defect. `registry.rs`
mapped `Panel` to a `CmdContext` field in two places and `App::cmd_context`
copies the three flags out of `ViewState` in a third, and all three were
demonstrated unguarded: swapping Formula and Category at the registry sites
left the workspace at 1400 passing, and hardcoding the `category_panel_open`
propagator to `true` left it at 1394.

The registry mapping is gone — both sites call `CmdContext::panel_open(panel)`.
The remaining two permutable sites are guarded from a path neither controls:
the tests write `view_state`'s three fields **by name** and read back through
the enum accessor on a context built by the real `App::cmd_context`. PR #70's
review caught the weaker shape of this, where zeroing and reading through the
same accessors passed a consistent permutation.

Both faults were injected and both fail all three new tests by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:38:08 -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 bc77e68f40 One classifier for a typed string, one exhaustive walk over Rejection
improvise-j7v6: the rule turning a typed string into a `CellValue` was
written twice — `resolve::typed` (already moved into improvise-protocol
since the ticket was filed) and `commit_regular_cell_value`'s
if/else-if/else on the host's key path. The host now calls
`improvise_protocol::typed`, which is `pub` for that reason.

Per doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished),
`a_typed_string_is_classified_as_the_host_classifies_it` did not survive
as a comparison: type identity replaced its claim. It is re-scoped to the
thing this crate still owes — the FRAME the string composes — and renamed
`a_typed_string_composes_the_frame_that_carries_it`.

The ticket's flagged risk (gotcha 34: a shared classifier that disagrees
with the persistence layer is a third answer) is now asserted rather than
argued: tests/typed_string_agreement.rs round-trips what `typed` calls
text and what it calls a number through format_md/parse_md. Proved
discriminating: dropping `pipe_quote` from the writer fails it.

improvise-8uj8: `every_rejection_message_says_the_edit_did_not_land`
drove a hand-written five of seven `Rejection` variants while claiming
the fact held "over all of them". `Rejection::all` (the same cyclic walk
`AppMode::all` uses) and `Rejection::refused_an_edit` (exhaustive) now
drive it, and its claim is re-scoped to what is true: a rejection that
refused an EDIT says nothing was applied, and the two Save answers —
which refused no edit — say the document was not written. Renamed
`every_rejection_message_says_what_it_refused`.

Proved both ways: wording SaveFailed as "hmm" fails the test, which the
old list could not have seen; adding a Rejection variant fails the build
at basis.rs:101 and :126.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:36:51 -07:00
Edward Langley e89b6467e1 Derive the AppMode lists from AppMode, and close minibuffer()'s wildcard
improvise-e1z5 (half) and improvise-g9jb.

`AppMode::minibuffer()` ended in `_ => None` where its sibling `panel()`
is exhaustive, so a text-entry mode could be added and be invisible to
`EnterMode`'s clear and to the `TextEntry` builder. It is now exhaustive.

`AppMode::all()` and `AppMode::text_entry()` are the one enumeration.
`all()` walks a total, cyclic `after()` rather than listing: with no
`None` arm, the only way to answer for a new variant is to name another,
which is what splices it into the walk. `text_entry()` filters `all()`
by `minibuffer().is_some()`, so the set is derived from the thing that
makes a mode text-entry rather than restated.

Four hand-written lists removed, enumerated by grep before the claim:

  1. protocol/src/help.rs::all_modes()                 17  removed
  2. command/src/ui/app.rs::tests::all_modes()         16  removed — it was
     missing SavePrompt, latently
  3. protocol/src/resolve.rs::tests::text_entry_modes() 8  removed
  5. wasm-client/tests/lockstep.rs::text_entry_modes()  7  removed — also
     missing SavePrompt, latently

One list survives and is not a copy: command/src/command/keymap.rs's
`text_entry_modes_with_exits` carries commit/leave KEYS, which are not
derivable from AppMode. Its mode set now comes from `AppMode::text_entry()`
and only the keys are stated, in a match with no wildcard.

e1z5's second half needs no change: `strands_the_user` was deleted by
improvise-rmvx and replaced by name-driven `declined`. Only a stale doc
reference to it survives, at resolve.rs:1948.

Verified by injecting a throwaway `AppMode` variant: the build fails at
lib.rs:220 (`minibuffer`) and lib.rs:266 (`after`), neither of which
could have failed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:35:01 -07:00
Edward Langley 922eb82d6b One home for the published basis, one fold for a mirror's active view
improvise-23qd: `ClientSession::basis_update` and the `#[cfg(test)]`
`server_truth` each derived "which records does this client's grid name"
from the same filter, and `server_truth` is the yardstick the fold tests
measure against — so a change to which records get published moved the
thing and its measure together. Both now call `published_basis`, and the
property has a yardstick that is spelled out rather than derived:
`the_published_basis_names_the_records_the_grid_draws_and_no_others`
names North/South/East by hand and asserts an aggregate grid publishes
none. Proved discriminating by injection: `.take(2)` on the one surviving
filter fails that test and nothing else in the crate.

improvise-w3bf: the four writers of `ClientSession::view.active_view` —
`apply_switch`, `adopt_deleted_views`, and `reseed`/`connect` through
`fresh_view` — each reproduced `reduce_view`'s one-line `SwitchView` arm.
All four now run that arm. Proved by injecting a second statement into
the arm and watching a probe read "now Default" from a pre-fix
`apply_switch` and "now Second" after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:32:49 -07:00
Ed Langley 8a559b2686 Merge pull request #75 from fiddlerwoaroof/docs/orchestrator-batch
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
docs: the wave skill, the Copilot gate, and six doctrines a hard reset discarded
2026-09-20 20:01:19 -07:00
Edward Langley 9976486d69 docs: the help entry no longer contradicts itself
Found by review on #75. Renaming the heading to the test PR #72 restored made
check-evidence green, and left the body three paragraphs down still saying
"The test no longer exists" and "nothing replaced the assertion" -- one entry
making mutually exclusive claims about whether the coverage exists.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 19:55:31 -07:00
Edward Langley d2a54ac320 kb: restore six doctrines a hard reset discarded, and record how
Consolidates four doc branches and restores what resyncing lost.

I wrote doctrines to kb.pl on local main, pushed each as a branch, then ran
`git reset --hard gh/main` twice to resync -- discarding six clauses that
existed only in the unmerged local commits. Nothing failed: kb_consistent
passed on the reduced file, the counts simply read lower, and the loss was
found only by grepping for clause names I happened to remember writing.

That is the same shape as everything else this wave found. A count that looks
plausible is not evidence, and the check that would have caught it -- diff the
clause NAMES, not the total -- is the one this repo already applies to tests
after a merge.

doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch) states
the two rules: write a fact once, on the branch that will carry it to main, not
on a local main that is going to be resynced; and after any reset or force-move
of a branch holding knowledge, re-count and diff clause names.

Restored: every_wave_agent_gets_its_own_worktree,
the_orchestrator_delegates_substantial_work,
a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,
run_the_gate_ci_runs,
an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,
a_siblings_unmerged_pr_is_invisible_to_bd_search.

Also carries: the wave skill, the Copilot-gate correction to AGENTS.md, the
help evidence rename, and the code-review skill's missing -D warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 19:40:33 -07:00
Edward Langley 3d34b32085 Merge remote-tracking branch 'gh/docs/help-evidence-rename'
# Conflicts:
#	prolog-sessions/kb.pl
2026-09-20 19:40:02 -07:00
Edward Langley 65e8f2fb42 Merge remote-tracking branch 'gh/docs/copilot-gate' 2026-09-20 19:40:02 -07:00
Edward Langley 3c5836c9d9 Merge remote-tracking branch 'gh/docs/wave-skill'
# Conflicts:
#	prolog-sessions/kb.pl
2026-09-20 19:39:51 -07:00
Edward Langley debad21556 docs: run the gate CI runs, and bindings.rs is run
Two one-line corrections the wave's last review surfaced, both about a claim
being stated in more than one place.

.claude/skills/code-review/SKILL.md told every reviewer and every wave agent to
run `cargo clippy --workspace --all-targets` with no `-D warnings`. Clippy exits
0 on warnings, so the committed local gate was green on exactly the input CI is
red on. This is not one person's shell mistake, it is the instruction: a
reviewer following the skill as written would report clean and be wrong, which
happened on PR #71. Also adds --no-fail-fast, since fail-fast hides blast radius.

README.md was a sixth copy of "bindings.rs is never run", which PR #70 corrected
in five places -- its grep missed README, and PR #72 then rewrote the sentence
four lines above and carried the wrong half forward. Neither PR is at fault
individually; it is an interaction between two branches, and it is the exact
failure the corrected fact is a monument to.

Plus doctrine(a_weak_check_and_a_strong_one_agree_until_the_defect): five
instances this wave, one mistake -- a check written against the failure you
imagined rather than the outcome the user sees. The weak and strong checks agree
on every input anyone tried and diverge only on the input that is the defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:24:17 -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 df8ee0d135 fix(protocol): Keymap is a test-only import, and my clippy check was not CI's
CI's `cargo clippy --workspace --all-targets -- -D warnings` failed on an
unused `Keymap` import: `bound_commands` takes a `&Keymap` and lives in the
test module, so the non-test lib build has no use for it. Moved into
`mod tests`.

Worth recording WHY I did not catch it locally: I ran clippy without
`-D warnings` and grepped its output for `^(error|warning)\[`, which matches a
lint with a NAME in brackets and misses a bare `warning:` — which is exactly
what `unused_imports` emits. A check whose filter cannot see the failure reads
as a clean run, which is the shape this repo keeps finding. Run the gate CI
runs, not one that looks like it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:13:38 -07:00
Edward Langley 5fa554d712 docs: the help evidence heading names a test that exists again
PR #61 deleted the help spec; PR #69 demoted its evidence heading to prose as
Retired; PR #72 restored the coverage under a DIFFERENT name. So main briefly
carried a heading whose claim was false in both directions -- the prose said
retired while the test was back, and restoring the heading verbatim would have
named a test that does not exist.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 17:07:39 -07:00
Edward Langley cd094b6dfb fix(kb): the dj1l cases discriminate, and a replaced rule cannot silently become an add
Five findings from the second review round, and the first is this PR's own
standard applied to its own P1.

**The two fixes improvise-dj1l names had no discriminating test.** Reverting
`has_key` to unification, or dropping `canon_key` from the duplicate gate, left
kb-test at 27 passed. Case 11 was carried by `check_total` refusing with a
DIFFERENT message, and case 10 by the other half of the fix. Both now assert
which refusal fired (`said()`), plus two cases aimed at each half: a rm key with
a variable in a non-final position must be refused as ABSENT rather than
matching everything, and a keyed FACT whose key holds a variable must ADD
without excluding the siblings it would unify with. Each proved by reverting
exactly one fix and watching exactly that case fail.

**Keying a rule on its whole clause made `replace` on an edited rule silently
become `add`** -- both arms live, the stale one still firing, and `check_total`
cannot see it because `NO + NF` is correct arithmetic for an add. Reachable on
the 15 `kb_error/1` arms and the `kb_text/2` rules, so editing the check
improvise-2f97 adds is the first thing to hit it. `check_rule_replace` refuses
and names the route (rm, then add); the negative arm -- replace of a rule
present verbatim -- is tested, or the refusal would just be "replace never
works on rules".

**The `decision(` arm was dead code.** `cited_name` took the first field, and
the repo spells `decision(<ticket>, <name>)`, whose ticket id fails
`name_shaped/1` on the hyphen -- so that marker could never fire for a real
citation. A second `cited_name` clause takes the second field. All three
markers are now exercised.

Minor, from the same round: `_JS_TEST` no longer accepts `test.only`, which
disables every other test in its file, with a case asserting a heading cannot
be satisfied by one; and `node-test-test.sh`'s last case no longer duplicates
an earlier one.

Filed rather than fixed: improvise-w8ih, extracting `web/assemble.sh`'s gate so
its own absence is visible.

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

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

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

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

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

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

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

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

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

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

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

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

Two more review findings:

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:54:57 -07:00
Ed Langley 40fc0c7dc7 Merge pull request #72 from fiddlerwoaroof/wave/harness-coverage
test(harness): the help overlay, a grouped fixture, the landing page, and Firefox
2026-09-20 16:51:22 -07:00
Edward Langley 67ce2df421 docs: the Copilot gate is gone, and AGENTS.md told agents to wait for it
Measured: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61, #66, #68
and #70 carry none. Both request paths report success and register nothing --
the GraphQL mutation returns clientMutationId null, the REST call returns 200
with requested_reviewers empty, and no review_requested event appears.

So "merge only once Copilot passes" was an instruction to wait forever on a
review that was never requested. An absent review and an unrequested one look
identical from outside, which is review_is_requested_not_inherited seen from the
requester's side. Review is now an adversarial agent the orchestrator dispatches,
naming the head SHA it reviewed; the Copilot mechanics are kept for the day it
returns.

Found by the PR #70 agent, which noticed its own review request had produced
nothing rather than assuming the review was queued.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:43:35 -07:00
Edward Langley 0b7a2351b5 fix(tooling): a barren file is refused by node's own report, not by grepping for test(
Two findings from the PR #69 review, one class: a lexical `test(` scan is
satisfied by things that execute no assertion -- a commented-out call, the
words in a string, `test.skip`/`test.todo`, and an empty `test.describe`.

scripts/node-test.sh drops the scan and reads node's report instead. `# pass`
is 0 for a skip-only file, a todo-only file and an empty suite; and a file that
registered no test is reported as one passing test WHOSE NAME IS THE FILE PATH,
which is both how a barren file is detected and why `# tests` alone reads as
the file count. That naming convention is an assumption about node, so the
self-test pins it: a node that stops doing it fails the case rather than
letting the guard go quiet. Six cases added, including a barren file hiding
among passing ones.

--check-evidence had the parallel hole: _JS_TEST accepted `.skip`, `.todo` and
`.describe`, so a quoted heading could be satisfied by disabled coverage or by
an empty suite. Now runnable leaves only (`.only` included), with three cases
asserting that a skipped, todo or suite-only name leaves the heading orphaned
while a test nested in a suite still satisfies one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:41:09 -07:00
Edward Langley 5c3c796308 docs: a wave skill, so the brief stops being retyped
Four agents were dispatched without worktree isolation and all edited the same
working tree: one git status held one agent's effect.rs, another's
harness/build.sh and a third's scripts/node-test.sh. Any commit would have swept
up three units' half-finished work.

The brief had carried that rule before and lost it, which is what a retyped brief
does. It is now a skill: worktree isolation first, partition by crate rather than
ticket count, the base commit by name, the baseline, the never-write list, the
injection discipline, pr-watch --expect, and attribution.

Two doctrines: every_wave_agent_gets_its_own_worktree records the tell and the
per-agent recovery (copy only what an agent can PROVE it touched -- two units can
legitimately both be in improvise-protocol); the_orchestrator_delegates_substantial_work
records the user's rule and the non-obvious half of it, that orchestrator work
lands in the shared primary tree and collides with agents who own that ground.

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

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

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

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

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

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

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

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

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

Playwright: 26 passed + 3 skipped, 34.4s / 33.7s (was 17 + 3, 15.1s); the
landing page's cast playback is most of the difference. node --test web/tests
26, cargo test --workspace 1394, both unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:31 -07:00
Edward Langley ab96f3651f test(harness): Firefox runs the standalone deployment, and only that
improvise-bck's criterion (1) reads "Chrome and Firefox" and the standalone
deployment was the evidence offered for it -- but shell.spec.js ran on
Chromium alone (improvise-zkkw).

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

So the mocked-peer describe is tagged and the firefox project widens its
testMatch with grepInvert: /@mocked-peer/ -- a tag, not a third copy of the
file. Measured: 2.0s for the pair, 10/10 green under --repeat-each=5. Nothing
was quieted; had it flaked, the answer would have been to leave it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:31 -07:00
Edward Langley b748bb680c test(harness): the landing page is fetched, played and followed
docs/index.html is copied to the site root by web/assemble.sh, so the harness
has served it all along with nothing asserted about it (improvise-bgtf).

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

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

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

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

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

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

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

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

The test-name diff 78v6 asks for finds a fourth loss: "a click on an item row
filters, as Enter does". Only #status distinguishes the item arm of
click_category_row from the category arm, so a click wired to the wrong one
passed the whole file. Restored, and proved by injecting the mis-wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 16:34:18 -07:00