Commit Graph

40 Commits

Author SHA1 Message Date
Claude (agent-hier) e09ec75226 docs: group hierarchies and fold lines, with the evidence for each rival (improvise-felo.6)
The harness fixtures open their groups explicitly now that groups start closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 21:54:23 -07:00
Edward Langley ffc7d0aecc Merge gh/main 08ad7b81 into fix/wpxr-empty-record-view
Every write the merged PRs added now names a view, chosen deliberately:

- The keyboard commit names OpenedCell::view, captured when the editor
  opened, as EditTarget does for the mouse (context/code/clients.md).
- RenameRecordCoordinate names no view. Its record must hold cells, so it
  resolves in the store alone: NamedRecord::Stored, as does the rename
  destination records_at_stake adds. named_records replaces cells_named as
  the exhaustive list records_named is read off.
- #252/#265's non_item_refusal moves into Grids::unwritable. The strict view
  lookup (a missing view is RecordGone) and the Grids memo are kept.
- Test fixtures name a view their document has: Default for host() and
  host_on_a_fold(), the records view for the rename pair, and the viewing
  client's own view in browser_round_trip.

Evidence files: both sides kept, every heading of both parents verified
present. Test names: the merge holds every test of both parents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 12:53:03 -07:00
Edward Langley 46896a7512 Merge PR #250 (3e417729) into the 2026-09-24 integration wave
Conflicts with #294 in render.rs and project.rs resolved as tested:
#250's side of every hunk (levelled GroupEnd { level }, groups()/group_of,
axis_frame's carried headers, the try_from level check), then #294's shape
(Item { tuple, .. }, FrameEntry::item(..), the DataItem { tuple, collapsed }
arm, and GroupEnd { .. } in folds). Evidence EOF appends keep both sides.
2026-09-24 02:49:32 -07:00
Edward Langley cfa8bf1e0a Refuse a frame whose level names no category; lend the open-run stack
Two ChatGPT Codex findings on the #250 stack.

- #250, P2 (improvise-e265): a FrameGroup.level from the wire sized the
  open-run stack in AxisFrame::groups, so a huge level drove a huge
  allocation. AxisFrame now deserializes through UncheckedAxisFrame and
  refuses any group or GroupEnd level not below cats.len(), naming it.
  context/code/protocol.md §3b.
- #273, P3 (improvise-0r01): the lazy open_runs still cloned a stack per
  skipped row under .nth(idx). walk_open_runs lends one stack to a visitor
  that can stop the walk, so a lookup copies nothing for earlier rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 23:41:48 -07:00
Edward Langley d79a8bd5b0 improvise-wpxr: argue the named view in context/, and record the evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:03:42 -07:00
Edward Langley 66bd3fcbf2 Tile select ranks by CategoryKind, and the docs and tests catch up (oplp follow-ups)
improvise-bqm0: opening_tile fell back to tile 0, _Index, when a view had
no Regular category, so T Enter still emptied that grid. TileEntry now
carries the host's CategoryKind in place of `regular: bool`, and
opening_tile ranks Regular, then _Measure, then Label, then _Index/_Dim,
leftmost on a tie, through an exhaustive match. A bool could not express
the fallback without a client sniffing names. CategoryKind is re-exported
from the protocol so the wasm client names it through its one dependency.

improvise-52od: in every fixture the first Regular tile was also the first
tile on Row, so a rival `position(axis == Row)` passed the workspace. A
protocol unit test on a records-shaped bar and a lockstep case in records
mode now fail that rival; a no-regular unit test and lockstep case fail
#231's own `first regular, else 0`.

improvise-q69m: context/code/command.md said tile 0 is _Index and that
the fix would be the tile order, and clients.md's Rule 3 counterexample
named the leftmost tile. Both now describe opening_tile, and command.md
says why placing the cursor beat reordering: click targets are indices,
and the offered order is #192's pinned characterization.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 08:48:54 -07:00
Edward Langley f75b58af91 The drawn keys rows must equal what the builders asked for, in order (aszt)
A count let a bypass that also drops a real row pass; content does not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:41:06 -07:00
Edward Langley 66398f163f Merge branch 'fix/help-keys-checks' into fix/w7qd-ssoe-help-guards 2026-09-22 22:37:19 -07:00
Edward Langley 95bd593dbe Merge remote-tracking branch 'gh/main' into fix/help-keys-checks
# Conflicts:
#	context/test-evidence/clients.md
#	context/test-evidence/protocol.md
2026-09-22 22:30:19 -07:00
Ed Langley d1c4884399 Merge pull request #196 from fiddlerwoaroof/fix/projection-identities
improvise-exdg/7xyx: a coordinate cell is CellId::Coordinate; FrameGroup::cat deleted
2026-09-22 22:28:36 -07:00
Ed Langley 608ba6a6c8 Merge pull request #195 from fiddlerwoaroof/wire2/59q8
Source scanner: bound test-only items by brackets, evaluate cfg predicates (59q8)
2026-09-22 22:15:24 -07:00
Edward Langley 8772db2e7b A keys row is drawn only if the builders asked for it (ssoe)
KeyRow::looked_up is private to key_row, whose keys() is the lookup
pages() hands the builders; the reviewer's direct call is E0624. A new
test asserts the drawn keys rows equal help_bindings(), which catches
the remaining self::keys bypass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 22:08:42 -07:00
Edward Langley 25a06d52ff improvise-7xyx: delete FrameGroup::cat and the echo it promised
The field's doc comment said a client echoes it back in ToggleGroup, but
resolve.rs has no toggle-group arm and neither renderer read it. Deleting
it is the minimal honest answer: composing `z` in a browser needs its own
design (the TUI places the cursor on the layout the toggle will produce),
and the operand should come back with that consumer. The h0iv test now
asserts whole-header equality, which is stronger than the (cat, name)
pair it compared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:56:53 -07:00
Edward Langley 7935b006eb Drop the Welcome page's key tip rather than deriving it
As a derived keys row it gave Welcome a row with a description, and
harness/tests/help.spec.js holds Welcome to prose; CI failed on it at
544744c. Help mode's status hint already derives next-page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:56:38 -07:00
Edward Langley 43ba5e6151 Bound test-only items by brackets and evaluate cfg predicates in the source scanner (improvise-59q8)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:55:32 -07:00
Edward Langley 327fa176ad Evidence for 6562 and v29m
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:43:35 -07:00
Edward Langley 3c6001ac7b Merge remote-tracking branch 'gh/main' into fix/6oy4-float-roundtrip
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/protocol.md
2026-09-22 21:41:49 -07:00
Edward Langley c5e72e889f Merge remote-tracking branch 'gh/main' into fix/help-keys-checks
# Conflicts:
#	context/test-evidence/protocol.md
2026-09-22 21:37:17 -07:00
Edward Langley da8c67109a CellDisplay equality is the wire's, so a NaN cell quiesces (improvise-4w09); docs and evidence for both 2026-09-22 21:37:12 -07:00
Edward Langley 3cf3718764 Evidence for wk18, 3t5x and jhdf
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:15:17 -07:00
Edward Langley 77c2471bcc WIP: docs and evidence for h3y7 forward guard, hkbc and c7mu
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 20:56:44 -07:00
Edward Langley 21184bbf64 Delete the invented Command::ClearBuffer, and guard the class
improvise-m0sx. `Command::ClearBuffer(String)` was declared on the wire and
composed by nothing in production: its only occurrences were the enum, the
fold arm, and two of reduce.rs's own tests. Its fold arm removed the buffer
key, while the only two implementations of its own name — the host's
`clear-buffer` command and resolve.rs::write_buffer — both compose
`SetBuffer { value: "" }`, which inserts. So the variant was invented rather
than derived and disagreed with both ends.

Deleted rather than given an effect. Adding a ClearBuffer effect to justify
the variant reverses decision(improvise-cqi, command_set_derived_not_invented),
and it would buy nothing: both readers of the map end in unwrap_or_default(),
so an absent key and an empty one are indistinguishable to every reader.

The guard is the generalisation. vocabulary_is_composed.rs reads the variant
list off serde's derive — the one enumeration the compiler maintains — and
requires each name to be constructed in non-test source, or named in a short
NOT_COMPOSED list with its verdict, checked in both directions.

It found two more orphans nobody had connected to the vocabulary, both
already filed as resolver gaps rather than inventions: SetYanked
(improvise-lut7) and SetSearchMode (improvise-fo0w item 1).

Proved discriminating twice: removing SetTileCatIdx's three production
composers failed the first test naming it, and listing the composed
SetBuffer in NOT_COMPOSED failed the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 19:59:41 -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 b4bd6bcdc4 test(command): the global guard names the command, not merely one
Self-review under the code-review skill, two findings against this branch.

**doctrine(binding_dump_diff) had not been run**, and it is the doctrine
aimed at exactly this change: a green suite is not evidence for a keymap
refactor. Diffed: 84 added lookups, 0 removed, 0 CHANGED -- no binding
anywhere answers differently. 40 of the 84 are not a binding change at all
but the widened probe space hitting ImportWizard's catch-all on F(3)..=F(12).
`:` produced zero rows, because every mode that now inherits it already bound
it locally: the duplication was real and consistent, which is also why nothing
in the suite could have caught the two spines drifting apart.

**The fallback arm was vacuous.** `shadowing_answer` returned `None` for the
six non-shadowing modes, so the test asserted only that a global key resolved
to SOME plain command -- "is this key bound", where it means "does this key
reach the global". It now answers with the global's own command name, read off
the global rather than restated, leaving Help's `?` as the only hand-written
expectation because it is the only decision.

Proved by injecting the rival fix rather than only the bug: giving TileSelect
`Arc::new(global_keymap())` -- its own equal-but-separate copy -- leaves this
test GREEN, since every key resolves to exactly the right command. Only
`every_modes_keymap_chain_ends_at_the_same_global` fails, which is the
clearest statement of why that one compares by pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:25:48 -07:00
Edward Langley 544cbd79f3 test(command): the registration guard reaches prefixes and F-keys (improvise-piyp)
`every_bound_command_name_is_registered` had two blind spots, both found by
injection rather than by reading, and both inside the region improvise-636d
works in.

Its walk matched `Some(Binding::Prefix(_)) | None => {}`, so it never
descended into a prefix sub-keymap: everything under `g`, `y` and `Z` was
unchecked in every mode, and twice over for RecordsNormal, which reaches
Normal's three through `Keymaps::prefix_keymap`. And its key space was a
second copy of `tests_support::probe_keys_pub`, identical but for the two
F-keys the public one carries, so a name bound on any function key was
invisible here while `dump_every_binding` probed it.

Two unregistered names -- one under `g`, one on `F(5)` -- left the workspace
byte-identical at 1595/0/7. The positive control separating "untested path"
from "broken test" is the same bogus name on `Char('~')`, which fails.

The walk is now `reachable_names`, recursing into `Binding::Prefix` and
terminating because `prefix_keymaps_are_flat` says the nesting is one deep.
`probe_keys` DELEGATES to `probe_keys_pub` rather than restating it, which is
the part that matters: two lists of "the keys a test probes" is the same
disease as two keymap spines, and the F-key gap was the drift between them.
`probe_keys_pub` now covers F(1)..=F(12) rather than the two that happen to be
bound, since the point is to see a binding nobody expected.

Acceptance is the two injections failing SEPARATELY -- a fix for one need not
close the other -- and both do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:26 -07:00
Edward Langley 11320f771a feat(protocol): one global keymap every mode inherits from (improvise-636d)
`?` resolved in 2 of 17 modes: bound once on Normal and reached by
RecordsNormal through the single inheritance edge. It was missing from all
three panels, TileSelect, SearchMode, ImportWizard and Help itself.
Meanwhile `bind_panel_toggles` had two call sites and `:` was written into
two spines while `?` was in one — nothing kept the spines in agreement.

`global_keymap()` binds `?`, `:` and the three panel toggles, and all
seventeen modes are built `Keymap::with_parent(global)`. The answer to "which
modes must not inherit" is **none**: `lookup_local` is exhausted before the
parent, so the eight text-entry modes and SearchMode shadow every global key
with their own `AnyChar`, and ImportWizard with `Any`. Only Help writes an
override, and it is a decision — `?` closes the overlay.

The global binds Char keys only, because `AnyChar` is what does the
shadowing; F1 therefore stays on Normal although it runs the same command.
`TextEntry::with_parent` is folded into `new`, which now takes the parent
every text-entry mode has.

Four guards: the chain ends at one global (Arc::ptr_eq, not by value), the
global is Char-only and prefix-free, no mode binds a toggle of its own, and
every global key resolves in every mode and types where it must. Each was
proved by injecting its fault; evidence in context/test-evidence/protocol.md
records the two cases where a neighbouring test stayed green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 17:48:36 -07:00
Edward Langley 40b0574b4c test(protocol,core): assert gd83's property where the type lives, and make the collapsed fixture prune
Integration review of #106. Three findings; two applied, one does not reproduce.

1. AxisFrame::group_of had NO coverage of its GroupEnd arm. Replacing it with
   `FrameEntry::GroupEnd => None` left all 133 improvise-protocol lib tests
   passing — only tests/grid_frame_agreement.rs in the root crate caught it,
   and that file does no pruning. So improvise-gd83's property reached the
   protocol by construction through project.rs and never by assertion: a
   derivation living in two crates with one of them unchecked, which is what
   gotcha 44 and design-principles §5 exist to prevent.

   Two tests in render.rs over the exact frames prune_axis_items now produces
   — the re-opened run closed by a GroupEnd, and the collapsed header with no
   item under it. The second also asserts collapsed: true travels, because a
   header arriving without the flag is there and inert, which is lhi4 again
   one layer out. The injection now fails both where it failed nothing.

   This is the landed half of u6gc's AC3. The other half — that
   prune_axis_items -> axis_frame -> group_of really composes to these frames
   — still needs project.rs and stays filed rather than landed.

2. a_collapsed_groups_header_survives_pruning passed with prune_empty disabled
   entirely: its fixture funded every item, so nothing was ever pruned and the
   name claimed more than the test could see. It now carries an unfunded Mar,
   so turning pruning off leaves DataItem(["Mar"]) in the asserted sequence and
   the test fails. Same vacuity class as the any() -> whole-sequence fix in
   4075d66.

3. NOT APPLIED — "no fault separates the collapsed test from the GroupEnd
   test" does not reproduce. Re-measured all three faults over all four tests:
   inverting close_pending_run's condition to `had_item` fails the collapsed
   test while the GroupEnd test PASSES, because a re-opened run is closed by
   the GroupEnd arm whatever the header condition says. The evidence now
   carries the full 3x4 matrix rather than prose, so the claim is checkable
   instead of assertable, and every column is distinct.

Also moves wide_cell_clamps_without_overflow's evidence from core.md to
protocol.md. improvise-yae4 touches viewport.rs and render.rs and does not
touch improvise-core at all, so core.md was the wrong home; it was put there
by hand and the checker cannot see a misfiled entry — it asks whether a
heading names a real test, not whether it sits in the right file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 15:45:12 -07:00
Edward Langley d41c8b8e6f Merge main (1a9f45a) into wave/protocol-render-gaps
#91 and #93 landed while this wave was in flight and appended to the same four
context/ files. That conflict is why CI never ran: a pull_request workflow builds
refs/pull/N/merge, and a CONFLICTING PR has no merge ref, so no run is ever
created. Neither the close/reopen nor the empty commit could have helped.

Resolution: keep both sides. Only context/test-evidence/protocol.md conflicted
textually; the other three auto-merged. Verified with --no-ext-diff that nothing
of this branch was dropped -- the only removals are main's own rewrites, of the
frame-basis paragraph in context/code/command.md and of the minibuffer-exit entry
(renamed _to_plain_normal -> _to_records_normal) in test-evidence/command.md.

NOTE the first three "nothing was dropped" checks were WORTHLESS and read as
clean: an external diff tool is configured, so `git diff | grep '^-'` was grepping
a side-by-side format for unified-diff markers and matched nothing whatever the
content. --no-ext-diff is what actually answers the question. A check that returns
empty for the wrong reason is indistinguishable from one that passes.

Fixed while re-reading, and pre-dating the merge: this branch had TWO entries
headed a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths -- the
original, describing a fixture that no longer exists and repeating the claim the
PR #92 review measured false, and the superseding one. Merged into a single entry
whose subject is what each of the three fixtures was blind to. The evidence
checker cannot see this: it verifies that a heading names a real test, and a
duplicate heading names one just as well.

Merging gh/main (1a9f45a) rather than local main (1ec43a7): local main carries an
unpushed orchestrator kb.pl commit, and the PR's merge ref is computed against
what is on GitHub.

Gates on the merged tree: 1486 passing, 0 failing; clippy -D warnings, fmt,
wasm32, both comment-check spellings and kb check all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 05:33:07 -07:00
Edward Langley 902a77c174 review: two corrections from the #92 adversarial review, and a P2 they found
1. THE JMJZ WIDTH ASSERTION COULD NOT SEE A WINDOWED WIDTH, and the evidence
said it could. Injecting `if drawn { widen(..) }` -- widths from the window only
-- left 1476 passing, 0 failing. Two reasons, the second structural: the measure
was named `Revenue` (7 chars) against a value of `300021` (6), so the assertion
passed on the header alone; and comparing grid_body's widths against
compute_col_widths is INCAPABLE of testing windowing, because compute_col_widths
IS grid_body with an empty window. Windowing degrades both sides identically.
The same function twice is not a check, and it reads exactly like one.

Fixture is now the reviewer's shape -- a total that cancels and a one-character
header -- reordered once more so BOTH assertions discriminate: with the large
values last, the windowed row and the cancelled total were both 1.0, so the
totals assertion went blind to the row-window fault while the widths assertion
happened to catch it. Large values first:

  for ri in rows.clone()        -> totals [888888888.0] against [1.0]
  if drawn { widen(..) }        -> widths [10] against [11]

10 rather than 5 is a smaller gap and a better witness: the missing character is
the minus sign of a row outside the window.

2. THE T6DB none_cats PARAGRAPH REPORTED A NEGATIVE RESULT FROM A SETUP THAT
LACKED THE CONDITION -- and correcting it found a P2. The fixture was said to put
Product on Axis::None in one view and on Column in another. It did not:
add_category defaults to Column and nothing moved it, so all three views had
none_cats {_Index, _Dim} and the stated input to the hazard was absent.

Setting it explicitly failed the property on the first run, and not on a cell
value: Second loses BOTH Total columns when the host stands on Default.
rebuild_layout fills Model::formula_cache for the HOST's view, and grid_in on the
elsewhere path builds another view's layout against that stale cache -- the Total
cells are keyed without a Product coordinate, the other view asks for one, finds
nothing, and prune_empty drops the columns. Filed improvise-ac2z (P2). Not local:
grid_in takes &App and recompute_formulas needs &mut Model.

Guarded by a_hosts_none_cats_leak_into_another_views_formula_columns, a
characterization test whose message says that failing means the bug is fixed. The
property skips pairs whose none_cats differ and asserts compared > 0, so a future
fixture cannot skip everything and pass.

3. Smaller, all taken. "Nothing else fails" on the t6db injection was wrong: it
fails three tests, including the pre-existing prune-invalidation one -- the #78
fix had more coverage than it was credited with. And the 5ubs should_panic named
one thing and exercised another: pointed at a struct it reached only the
split_once branch, and all three panics shared one literal so `expected` could not
discriminate. The parse is now a `parse_expected` seam fed hand-written mutated
messages, each refusal with its own wording, plus a happy path.

1470 -> 1475 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:36:42 -07:00
Edward Langley a10e95eb5f fix(mode): settle Normal/RecordsNormal once, after the rebuild (review of #91)
Two blockers from the adversarial review.

BLOCKER 2, a new wrong state this PR introduced: resolving `AppMode::Normal`
per-effect reads the batch's OPENING layout (gotcha 23), so a batch that CROSSES
— `:toggle-records-mode . enter-mode normal` — settled against the grid it
started on and left `RecordsNormal` over a pivot grid. Base was correct there.

The fix moves the resolution to one place, `App::settle_normal_mode` called
after `rebuild_layout`, where there is no crossing case to get wrong. That is
strictly simpler than what it replaces: `ChangeMode` is an ordinary
`simple_effect!` again, `ReturnToNormalUnlessMoved` and `CloseWizard` write
plain `Normal`, and the per-edit coverage question the review filed as finding 3
is gone because there is one edit. Removing its call site fails exactly three
tests, workspace-wide.

And the prose defended the staleness with an example that does not bear the
weight: `:toggle-records-mode` works, but because `ToggleRecordsMode` and
`ViewBack` overwrite the answer in both directions, so it exercises nothing.
Replaced by a test that drives the crossing both ways.

BLOCKER 1, a false claim in `context/`: "keys.rs emits no ChangeMode" is true
and irrelevant — keys.rs holds only `BrowserKey`. The live browser key resolver
is `improvise_protocol::resolve`, which emits `Command::ChangeMode(Normal)` in
five places, and `reduce.rs` folds it with no resolution. The browser front end
still has the sihr bug; it is LIVE, not latent, and it compounds because the
client's only records-ness fact is the mode it just lost. Named honestly, with
improvise-e5pf cited. Still not closeable here — a client holds a `GridCache`,
not a `GridLayout`.

Also the two corrections: the thpe test's doc comment no longer claims a
discrimination it does not have, and the 2ud deferral notes that `registry.rs`'s
`visible_cols: 8` is production, not a test helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:18:36 -07:00
Edward Langley d8df9196f5 decide(protocol): keep ColWidths diffed against the mirror, for a different reason (improvise-44ad)
The move -- diff col_widths against the label-folded cache, as hkt6 already
obliged for totals -- is DECLINED, and the two things learned are worth more
than the change would have been.

THE TICKET'S STATED COST DOES NOT EXIST. It says the move breaks
a_client_on_a_socket_is_seeded_and_gets_its_edit_back, and that that assertion
is gotcha 33(c)'s standing evidence, so rewriting it inside a change 33(c)
guards is the improvise-q0vb / PR #12 shape. Measured by deleting the re-fit
from fit_to_labels: three existing unit tests fail and the socket test is not
one of them. What it actually asserts is that a reframe CARRIED widths -- a
statement about the producer's diff, not about the consumer's re-fit. 33(c) is
triply guarded elsewhere.

THE REAL REASON IS COUPLING. The widths a label-fold invents are each column's
own label width: an approximation the consumer applies until the authority
catches up. Diffing the producer's output against it makes what the server sends
depend on a fallback rule defined in the client -- invisible today, silent the
day a second client implements the fold. The redundant ColWidths per reframe
buys out of that, and over-sending is the direction Dirty::of is deliberately
sloppy in.

Landed anyway: the half the ticket calls "the better test either way".
a_client_never_holds_widths_of_a_different_length_from_its_labels asserts the
invariant 33(c) is really about, at EVERY step of a fold that widens before its
widths arrive and narrows before they shrink -- not merely at the end, which is
where a message-count assertion looks. Watched fail at step 0 with the re-fit
deleted.

The totals/col_widths asymmetry is now stated rather than discovered: totals is
fold-relative because hkt6 found a real hole; col_widths has none, the re-fit
being a safe fallback rather than a stale value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:59:03 -07:00
Edward Langley 877d3e193b refactor(help): a chord is recognised by its modifiers, not its spelling (improvise-thpe)
`compact` tested whether a label it had just formatted began with "Ctrl-" or
"Alt-". No false positive today — the reviewer enumerated every
`format_key_label` output — so this is the shape, not the behaviour.

`Hint::keys` is now `(KeyCode, KeyModifiers)` and `compact` takes the pairs, so
the string is not in scope to parse; `Hint::labels` and the help pages format
last through the one spelling in `keymap::key_label`. The regression guard is
the signature, as it is for `Checked`.

`Keymap::labels_for` — the "throw the modifiers away" step the ticket names —
has no caller left and is deleted: a consolidation that keeps its old path keeps
the defect available.

The rule also got wider and total over the bitset: a chord is any modifier
beyond shift. Injected a temporary `KeyModifiers::SUPER` spelled `Super-x` and
ran it — the old filter keeps it, `compact` drops it. Binding dump byte-identical
across the change (4406 lines).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:57:32 -07:00
Edward Langley c4bf74608f fix(protocol): a group run is opened by a header, not by a name (improvise-h0iv)
group_runs coalesced adjacent positions by group NAME, so two runs under two
different headers sharing a name read as one and both front ends drew a single
span across the boundary.

THE TICKET'S PROPOSED FIX DOES NOT WORK, and that is worth more than the fix.
h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat for
exactly that reason. But its own reachability case is ONE category -- Month
grouped into Q1 nested under Region, giving North/Q1 and South/Q1 adjacent --
so both headers are (Month, Q1), equal as values, and the pair separates
nothing. A test pins that so the next reader does not re-propose it. What does
separate them is that they are two distinct FrameEntry::Group entries, which
groups() already borrows from self.entries: std::ptr::eq on the header is the
literal statement of "the same header opened both".

THE PRODUCT QUESTION, decided here and reversible in one comparison. Whether
two adjacent same-named runs are one span a user wants spanned is a matter of
taste, which is why #77 filed rather than fixed. What settled it is not the
span but the indicator: the existing wasm-client test merged Month/Q1
(expanded) with Fiscal/Q1 (COLLAPSED) and its own message said the merged span
is drawn with the first one's indicator -- a collapsed group rendered as
expanded, with no affordance saying otherwise. A span boundary is taste; a
wrong collapse glyph is a wrong fact.

That test's justification was also spent: it said the rule must be name-only
"because that is what src/ui/grid.rs uses", and since improvise-8qlr grid.rs
calls group_run_heads. There is no second copy left to agree with, which is the
general point -- a consolidation's frozen behaviour reads as decided afterwards,
and nothing says it was frozen only for the duration of the move.

The agreement fixture gains the case it could not see: two_level_rows minus the
ungrouped Sep, so the two Q1 runs abut, end to end through a real GridLayout and
both renderers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:57:07 -07:00
Edward Langley 9b81324d6a fix(protocol): a frame that lost its origin must not claim to lead the axis (improvise-1wl0)
AxisFrame::first carried #[serde(default)], so a frame whose first was dropped
in transit deserialized as 0 -- not an absence but the claim "this frame leads
the axis", which is precisely what body_from acts on. A scrolled browser would
draw the carried group header above its first row, silently.

Dropping the attribute rather than reaching for Option<usize> or a two-variant
enum, and the reason is about the wire rather than the type: there is exactly
one production producer, reading window.start, so a frame is never partially
constructed and "unknown" is not a state an honest peer can be in. Making it
representable would only oblige body_from to answer the same question one level
down. A missing field is now a loud parse failure at the transport.

Same argument that gave Basis no Default (gotcha 43). The Default derive still
yields first: 0 and that is not the same hole -- a default AxisFrame has no
entries, so the claim is vacuous either way.

Test written first and watched fail, printing the forged first: 0. It asserts
the key was present before removal, that the mutated value is still a
well-formed AxisFrame minus one field, and that the error kind is "missing
field" -- the three ways this check could otherwise pass for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:53:15 -07:00
Edward Langley 4e5f9f3ab8 test(protocol): make the cyclic all() prove it reaches every variant (improvise-5ubs)
AppMode::all() and Rejection::all() walk a total cyclic after(), and both doc
comments claimed a new variant could not be omitted. Half true, wrong half:
answering after() FOR a new variant is compiler-forced, pointing an existing
arm AT it is not, so a variant nothing names is never walked -- measured by
the PR #78 reviewer as all().len() == 17 against 18 variants, suite green.

Closing it needs an enumeration independent of after(). Stable Rust has none
for an enum with data -- variant_count is nightly, a derive macro is a new
proc-macro crate, and strum's EnumIter cannot construct AppMode::ItemAdd or
Editing at all. serde can: the Deserialize derive already builds the complete
variant-name list from the enum definition and publishes it in its
"unknown variant ... expected one of ..." message. variants.rs reads that back
and diffs it against the names the cycle reached, each taken off the variant's
own serialization rather than off Debug.

The helper is defensive because an unparsed message would return an empty list,
and an empty list passes every cycle; a should_panic test points it at a struct
and asserts the message, not merely that it panics.

Both overclaiming doc comments replaced. The fourth instance of this shape --
assert_eq!(cases.len(), 13) in ui/effect.rs -- is over a hand-built list of
effects rather than enum variants, so serde has nothing to say about it; left
filed rather than bodged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 03:52:04 -07:00
Edward Langley 850ddcf0eb Merge gh/main (#48 active_view, #50 shared front end) into wave/b-projection-gaps
Eight conflicts, all resolved keeping both sides. The three in context/ were
append-vs-append. The five in code were the restructure #50 declared in advance:

- render.rs: render_model became a free fn over &ClientState and the mode label
  moved to improvise_protocol::mode_label. Took that shape and re-added the
  three doc_* fields.
- state.rs -> improvise-protocol/src/client.rs: only the import path conflicted.
  The file now lives IN improvise-protocol, so `crate::` is right, plus
  DocumentCache.
- keys.rs went 1387 -> 187 lines because resolve/step moved to
  improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every
  addition into resolve.rs: the Derived and Create arms of edit_or_drill, the
  or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its
  Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list.
- project.rs: kept the value-based numeric/error from improvise-tom1 with
  gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and
  view_in; kept our title-bar projection inside their per-view projection set.
- lockstep.rs: both sides appended tests and the hunks interleaved mid-function,
  so took gh/main's file and re-appended our page-turn block whole.

THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check
--workspace --all-targets` passed with three of our tests SILENTLY GONE — they
lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_
the_formula, the_page_keys_turn_the_odometer_as_the_command_does and
a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with
client_on now returning a ClientState rather than a Client.

One assertion changed meaning under us and is corrected: lockstep's
page_selections read workbook.active_view(), which since improvise-jytm is the
document's DEFAULT. SetPageSelection writes App::view(), the client's. They
coincide in a one-view fixture, so it was passing by fixture luck; it now reads
App::view() and still fails under the injection.

All four injections re-run on the merged code and all four still discriminate:
reversed odometer, dropped records formula_for lookup, title bar gated on Dirty,
and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt
clean, all eight wasm crates clean, both comment-run checks clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:15:14 -07:00
Edward Langley 414d02c4a6 feat(protocol): one home for UI strings (improvise-x7sw, ktv1, k099)
Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:

**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.

- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
  captions together. The client already holds the keymaps, which is the answer
  the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
  key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
  lines of content; `web/app.js` paints rows it does not author, and
  `harness/tests/shell.spec.js` asserts that `?` really draws them.

**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.

Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.

**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.

Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.

Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:18:17 -07:00
Edward Langley 57a67e54cf refactor(protocol): delete GridUpdate::Invalidated; say why Items and Axes stay
improvise-n89z. Three Projection mutations had no producer. They get two
different answers, and the distinction is the finding.

GridUpdate::Invalidated is DELETED. It was not merely unused, it was
REDUNDANT: `grid_updates` diffs, so it always knows which cells moved and sends
Cells, and `GridCache::seed` compacts — there is no state in which a server
knows the contents are stale and not which ones, and if there were, the answer
is a fresh Snapshot through `rebase`. Meanwhile its stated meaning had migrated
into running code, because PR #23 gave `invalidate_relabelled` the job of
blanking exactly what a reframing invalidates, totals included. Three documents
still described it as load-bearing, which is worse than silence: a reader
reasoning about the fold's guarantees assumed something produced it.

CategoryTreeUpdate::Items and ::Axes STAY, with the argument written down.
They are derived rather than invented — the effects that move a category tree
come in three shapes, by the same recipe that built ModelCommand — and the
producer takes the coarse arm on purpose, because Dirty::of cannot say WHICH
category moved (an EffectKind never can), so emitting them needs the tree
observed and diffed for a widget that costs a few hundred bytes.

The two tests that reached the blanking through the retired arm now reach it
through a reframing, which is what really produces it; the row one is strictly
stronger, since blanking everything could not tell "blanks what it reframes"
from "blanks the lot". `the_retired_invalidated_spelling_does_not_parse` keeps
it from returning as a serde alias, with the injected-fault discipline
`the_retired_spellings_do_not_parse` earned: well-formed input first, error
KIND asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:16:29 -07:00
Edward Langley 555d9f1ca7 feat(protocol): fifteen more bindings resolve on the client (improvise-e24v)
`cycle-panel-focus`; the category panel's `cycle-axis-at-cursor`,
`open-item-add-at-cursor`, `filter-to-item`, `toggle-cat-expand` and
`delete-category-at-cursor`; the view panel's `switch-view-at-cursor`,
`create-and-switch-view` and `delete-view-at-cursor`; tile select's
`set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's
`help-page-next`/`help-page-prev`; `commit-buffer` in all three kinds; and the
two cell writes ADR 002 lets a thin client name, `clear-cell` and `paste`.

Each mirrors its `Cmd` line for line, status sentence included — a client that
emitted the mutation without the sentence beside it would diverge on the status
line and nowhere else. Two orderings are copied rather than rationalised:
`CommitBuffer` writes its optimistic status first (improvise-6t3) and
`TileAxisOp` writes its axis first.

**A divergence found and fixed on the way.** `effect::HelpPageNext` clamps at
the last page and `HelpPagePrev` saturates at zero, while `reduce_view`'s
`Command::HelpPage` arm *wrapped*. Two derivations of one fact, disagreeing,
invisible because `h`/`l` in the help overlay had always been upstream keys and
nothing had ever sent the command. `walking_off_the_end_of_the_help_pages_
agrees_with_the_host` was written first and failed — client 0, host 4 — and the
wire was then changed to match the effect, since the effect is what ships in
the TUI. Whether both should wrap instead is improvise-trd3.

**Three names the ticket listed do need enrichment**, filed as improvise-4bwa:
`toggle-prune-empty` words its status off `View::prune_empty`, which is in no
cache; `delete-formula-at-cursor` needs `(target, target_category)` and
`FormulaCache` carries only the raw text; and `view-back` emits
`ApplyAndClearDrill`, which is deliberately off the wire (gotcha 38) and is
blocked on ADR 001 layer 2 rather than on a projection.

The lockstep harness is new and is the evidence: it applies what the client
composed to a **second authoritative host** and compares the documents as
`.improv` text (`Debug` prints the symbol table's `HashMap`s, so it differs per
process — gotcha 18). Four injected faults — a wrong axis, a wrong status
sentence, a constant view index, and `AddCategory` for `AddItem` — each fail a
named test; the second one did *not* until the fixture was made to sit on a
category header as well as an item row.

`the_command_names_that_still_need_the_host_are_these` shrinks by exactly what
closed. 1318 tests pass, up from 1311.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:05:17 -07:00
Edward Langley b394c9377f docs(protocol): land the crate's reasoning as context/ prose
The arguments in improvise-protocol's comments, relocated before the
comments are capped: the fold rules, the basis tiers, the one-envelope-pair
derivation, the TextEntry spine, and the four accessors that must not enter
a draw loop. Test narratives go to context/test-evidence/protocol.md,
keyed by test name per design-principles §7.

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