Commit Graph

978 Commits

Author SHA1 Message Date
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:33:49 -07:00
Ed Langley 1af51c99c6 Merge pull request #57 from fiddlerwoaroof/fix/web-review-findings
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fix(web): land the review findings main is missing
2026-09-20 13:20:23 -07:00
Edward Langley 3f9a9d78b2 Merge remote-tracking branch 'gh/main' into fix/web-review-findings 2026-09-20 13:15:13 -07:00
Edward Langley f7d2214c7a test(harness): drill is refused in the browser, and one refusal is silent
The fourth widget reported, and the one with a different answer: there is no
wire vocabulary for drill. reduce.rs contains no Drill variant at all, neither
Command nor ModelCommand. That is improvise-gd28, and it is a decision rather
than an oversight — drilling writes drill_state, which gotchas 30 and 1 rule
session-local, and a Command::Model is routed with no SessionId. Adding
ModelCommand::Drill would reintroduce exactly what improvise-3x3u removed.

Everything below the command works, verified in the browser: CellId::Aggregate
is emitted and received, editable answers false for it, and edit_or_drill
reaches the aggregate arm. The TUI is the control and passes 12 drill tests, so
this is isolated to the client/wire.

The finding gd28 did not record is that the two entry points fail DIFFERENTLY.
`i`/`a` declines out loud with a status. `>` — the key the hint line actually
advertises as ">:drill" — is a name resolve::step does not handle, so it goes
KeyOutcome::Upstream -> ClientFrame::Key -> Host::handle's empty vec: a black
hole, not a slow path (gotcha 47). Silent. That asymmetry is why this reads as
broken rather than refused.

These tests document what is true rather than leaving folklore. They MAKE an
aggregate through the tile bar (web/demo.improv has none by construction —
gotcha 46) and assert the precondition, so they cannot pass against a grid of
Records. Both proved by injection: silencing the aggregate arm fails both, the
second on its check_the_checker control; making `>` resolve fails only the
second.

Evidence added to improvise-gd28 rather than filing a duplicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:14:53 -07:00
Edward Langley adfc76572d chore: do not auto git add issues.jsonl 2026-09-20 13:13:35 -07:00
Edward Langley a74006ec2b Merge remote-tracking branch 'gh/main' into fix/web-review-findings 2026-09-20 13:03:29 -07:00
Edward Langley 955c30a864 fix(web): land the review findings main is missing
Repair-forward for the accidental push at 959f249, which put the PRE-review
version of the browser-widget work onto main. The three defects review found
are all in the version main currently carries; this is the delta that
supersedes it.

1. `categoryList` did not call `indicator()`. It inlined U+25B8/U+25BE, the
   SMALL triangles, where `indicator()` — the page's declared ONE spelling —
   the page's own grid group headers, and src/ui/category_panel.rs all draw
   U+25B6/U+25BC. The browser's category panel disagreed with the TUI's and
   with its own grid, inside the file whose comment says "only this spelling
   is the page's".

2. Two specs pinned the divergent pair, so the obvious cure would have failed
   tests that shipped with the feature. widgets.spec.js AND pointer.spec.js —
   the latter written on a different branch, which had grown the same wrong
   assertions independently. Three files converging on a spelling nothing
   compares is design-principles §5's predicted failure, observed. Proved
   discriminating by running the corrected specs against the pre-fix bundle,
   where they fail with `Received string: "▸ Product (4) R"`.

   The pointer.spec.js line is in this PR and not a later one because main
   already carries that file: without it this change leaves CI red. It is one
   assertion corrected by the same rule as its sibling, not feature work.

3. `el.tiles.hidden` was a no-op, removed with its false comment. `#tiles`
   declares `display: flex`, an author-origin rule that beats the UA sheet's
   `[hidden] { display: none }`, so the guard never hid anything. Worth
   knowing before restoring it: `#tiles` being ALWAYS laid out is why its
   height never changes and it stays out of the ResizeObserver feedback path.

Also `playwright.config.js` takes IMPROVISE_HARNESS_PORT (default 8099, so CI
is unchanged). The hard-coded port plus `reuseExistingServer` let Playwright
silently ADOPT a server left by another worktree and test THAT tree. It fired
in both directions in one session: a false negative (a fix reported absent)
and — the dangerous half — a false POSITIVE, 5 passed against a tree verified
to contain no #tiles at all. A failure sends you looking; a false pass prompts
nobody.

The evidence prose records all of it, plus the ResizeObserver acyclicity proof
that retires "argued, not tested" there: frameCost.cols is row header LEVELS
(independent of projected rows) and frameCost.rows derives from the column
window, so cols -> rows and nothing depends on rows. No cycle to oscillate.

Gates: 1359 Rust tests, rustfmt, comment-run and evidence linters, 21/21
Chromium harness (on a private port, so not another checkout's tree).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:03:14 -07:00
Ed Langley 8f2847703d Merge pull request #54 from fiddlerwoaroof/wave/f-route-then-aggregate
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
fit: route per record and aggregate, never route on the aggregate (improvise-4zl7)
2026-09-20 13:02:15 -07:00
Edward Langley 959f249c4b feat(web): a click is resolved in Rust, and the sidebar obeys its flag
The user's rulings on the two beads PR #55 filed.

improvise-7ybz: the browser was keyboard-only by omission. Nothing outside an
editable grid cell had a click listener, so clicking the collapse triangle on a
category did nothing — which is what "I can't expand categories" meant, since
the keyboard path worked all along.

`improvise_protocol::resolve::click` now answers all three widgets under one
rule: **go there, then do what Enter does there.** A category row focuses the
panel, moves its cursor and activates (expand on a header, filter on an item);
a tile selects and cycles its axis; a cell moves the selection, with the page's
inline editor as the pointer's `i`. One rule rather than three inventions, and
it is the keyboard's own definition rather than a second one — `cat_tree_entry`,
`toggle_cat_expand`, `filter_to_item` and `tile_axis` each gained an `_at` twin
and the cursor-reading form is now a one-line call of it.

It lives in the protocol crate, not the wasm client, for the `flatten_cat_tree`
reason: a third front end must call this, not derive it. `Client::click_category`
/ `click_tile` / `click_cell` are thin wasm-bindgen wrappers that fold and
serialize exactly as `key` does, so `app.js` still composes no `Command`.

The subtlety worth knowing: a click's three intents are resolved in one pure
call, so the activation must be asked about the row *clicked*, never about
`cat_panel_cursor` — the `SetPanelCursor` ahead of it has not been folded yet.
Injecting that exact fault turns `ToggleCatExpand("Product")` into
`ToggleCatExpand("Region")`, which is the silent half and is now pinned.

improvise-wsaw: `RenderModel::category_panel_open` existed from the start and
`app.js` never read it, so the sidebar was drawn unconditionally and `C` moved
only the mode label. It now hides on load and toggles with `C`, matching
`src/draw.rs:322` and the two panels PR #55 added. A first-time visitor sees
one fewer thing; the user chose front-end consistency, and the grid is still
populated.

Tests: harness/tests/pointer.spec.js, six assertions, **all six proved by
running them against the bundle built immediately before this commit** — where
the sidebar is wrongly visible and every click lands on an element with no
listener. Plus four Rust tests on the resolver, one of them proved by the
injection above.

Note this branch is based on gh/main but MERGES fix/web-ui-correctness (PR #55):
click_tile needs a tile bar to click, and that is #55's. Rebase or re-merge once
#55 lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:35:47 -07:00
Edward Langley ff39121853 Merge branch 'fix/web-ui-correctness' into feat/web-pointer-affordance 2026-09-20 12:31:08 -07:00
Edward Langley b06d0d054e Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate
# Conflicts:
#	context/code/core.md
2026-09-20 12:31:06 -07:00
Edward Langley ee8d4b3709 docs(core): the shape-dependence is what pins the cause, not the number
The review re-ran fold_bench across two shapes at the SAME 18,050 cells: the
old code cost 51 ms at 25x361 and 244 ms at 95x95, and after the fix the two
are indistinguishable. A 4.8x spread from group count alone is what a
per-member scan predicts and a constant factor does not, so the spread
vanishing is better evidence for the diagnosis than the absolute number.

Also records the depth asymmetry between the two Agg arms, which bounds a
chain of folded formulas and must not be tidied into one rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:30:25 -07:00
Ed Langley cd6c8db976 Merge pull request #56 from fiddlerwoaroof/docs/record-cell-key-reason
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
docs(core): correct the recorded reason for leaving record_cell_key's Category arm untested
2026-09-20 12:29:52 -07:00
Ed Langley 3fe4da52b0 Merge pull request #49 from fiddlerwoaroof/wave/n-cleanups
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
wave 2 / WU-N: cursor invalidation, the reduce_full deletion, and two declines with evidence
2026-09-20 12:23:56 -07:00
Edward Langley 96bb2e6f0c Merge remote-tracking branch 'gh/main' into fix/web-ui-correctness
# Conflicts:
#	context/test-evidence/clients.md
2026-09-20 12:23:14 -07:00
Edward Langley 22ea527837 Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate
# Conflicts:
#	context/code/core.md
2026-09-20 12:22:57 -07:00
Edward Langley ef1713e6f6 docs(core): the recorded reason for leaving record_cell_key's Category arm untested was wrong
Third-round review finding, and I verified it against the code before accepting
it. Commit 7d25762's message declined that test on the grounds that a formula
targeting a regular category should project Derived anyway, so the injected
behaviour "is not clearly a fault". That is a different key entirely.

RecordCol::Category(name) carries a CATEGORY name, and RecordRow::cell_key(arg)
puts its argument under _Measure (layout.rs:68-70). So answering Some there
yields `coords + _Measure=<category name>`, and formula_for matches it whenever
a _Measure item happens to share a name with a regular category — projecting
Derived for a COORDINATE column, which is never a formula target and which the
pivot path does not have at all. The two front ends would disagree, which is the
§5 defect this ticket exists to close. RecordCol's own doc names the collision:
the enum resolves a column from its index "so a category and a measure sharing a
name stay distinguishable".

The decision not to write the test is unchanged, and the reviewer's
recommendation too. What changes is the argument: the REACHABLE version of that
fault is a new RecordCol variant answered carelessly, and the exhaustive match
makes that unrepresentable rather than unlikely. A test would pin what the type
system already pins.

Recorded in context/code/core.md because a wrong argument in context/ outlives
the code it describes, and that one would have told a future reader a coordinate
column projecting Derived is defensible. The commit message cannot be corrected
without rewriting history while a review is in flight, so the durable record
says so explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:22:28 -07:00
Edward Langley e0c68c2958 style: rustfmt the new render-model tests
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:21:25 -07:00
Ed Langley b383761e4e Merge pull request #51 from fiddlerwoaroof/wave/b-projection-gaps
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
wave 2 unit B: the remaining projection gaps
2026-09-20 12:21:05 -07:00
Edward Langley b03d924be0 Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate 2026-09-20 12:21:00 -07:00
Edward Langley 5fbcb1718a fit: one evaluator for the fold — which was also the stall
Review of #54 found the fold reintroducing exactly the divergence the ruling
exists to remove, and the same line turned out to be the 244 ms stall. One
defect, two symptoms.

`eval_expr_cached`'s `Expr::Ref` looks the value up in `formula_cache` at
`context.with(cat, name)`. Inside the fold `context` is a MEMBER-RECORD key
carrying the none_cat coordinates, while `formula_cache` is keyed by stems with
none_cats STRIPPED — so the lookup could never hit, fell through to
`aggregate_raw`, found no raw cell behind a formula target, and answered
Err("ref:…"). A routing formula over a derived measure gave Number(11.0)
uncached and Error("ref:Margin") cached, and the cached path is the one a pivot
grid runs whenever any category sits on Axis::None.

`eval_expr`/`eval_bool` are hoisted to module level and BOTH evaluators now hand
the fold the same record-level `eval_expr`. The two agree by construction rather
than by a test. Note the repair not taken: teaching the fold to hit the cache by
stripping none_cats off the member key returns the GROUP's value to every
member — aggregate-then-route through a cache lookup, and silent.

That same per-member `aggregate_raw` was one poorly-narrowed scan per member, an
O(n^2) shape. Measured with the new `examples/fold_bench.rs`, five runs a point,
at 18,050 cells: 51.1-53.3 ms before, 18.1-19.2 ms after, against 7.1-9.0 ms for
a linear formula. Scaling went from superlinear (45x cells, 85x time) to linear
(45x cells, 45x time). Debug 2.75 s -> 132.9-135.1 ms. The fixed point runs
exactly 2 passes, measured by counting, so hoisting the member set has a 2x
ceiling and is declined on improvise-9iyv — a memo keyed by less than the whole
`partial` is a second answer to what a cell depends on.

`Expr::Is` has an arm in each evaluator and the cached one was untested: the
earlier fault was injected into the shared `eval_is`, which both arms call.
Replacing the cached arm alone left the suite green. The first replacement test
ALSO stayed green, which is the interesting part — once the fold routes through
`eval_expr`, an `Is` inside a SUM never reaches the cached arm, so that arm is
reachable only by a bare `Is` outside an aggregate. That is what the new test
uses, and injecting into that arm alone now fails it.

`IS` gained a word boundary: `Region ISLAND` parsed as Is(Region, "LAND"),
silently wrong where it was a parse error. `is_expr` is compound-atomic with
`sp+` written out, because a negative lookahead in an ordinary rule tests the
character AFTER pest's auto-inserted whitespace and fails backwards. The
lookahead was then redundant and removed — deleting it left the suite green,
while relaxing `sp+` to `sp*` fails the test, so `sp+` is the mechanism. `WHERE`
has the same hole, is far less exposed, and is filed as improvise-r08u.

Also: `ambiguous.sort_unstable()` before `dedup`, which only removes consecutive
duplicates, so the refusal could name a column twice.

Every fault re-injected after the refactor: no-fold 10 failures, shared eval_is
5, record-level Is arm 4, cached Is arm 1, feature_roles order 2, context-vs-
partial 1, the new cached-fold divergence 1, `sp+` relaxed 1. Baseline 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:20:53 -07:00
Edward Langley d65fcc5969 test(harness): the tile bar must move the grid, not only its own label
The first version of the tile test asserted that the tile read 'Product Col'
after 'c' — the bar reporting itself, which would pass against a bar that
displayed an axis the document had not taken. It now also asserts Espresso
moves from a tbody row header to a thead column header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:19:39 -07:00
Edward Langley 6f9cd6cad5 fix(web): the tile bar, the view panel and the formula panel are drawn
A user reported "I can't expand categories, and the Tile + View UIs don't
work". Driving the assembled site under Chromium separates three different
answers, and the useful finding is that two of the three were missing only
their last layer.

Category expand already worked, by keyboard: Shift-C, j, Enter folds
Command::ToggleCatExpand client-side and the items appear. What does not work
is the mouse, because categoryList builds plain <li>s with no listener — the
whole page's pointer model, filed as improvise-7ybz rather than guessed at.

The tile bar and the view panel had the cache, the keymap, the resolver and
the host round trip, and no DOM. Before this commit the browser held five
TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect,
ArrowRight moved tile_cat_idx, and r/c/p really did move a category between
axes — with nothing on screen to aim them at. RenderModel had no field for
either and index.html had no element. The formula panel was the third of
Panel::ALL in the same state, and is fixed by the same mechanism rather than
left arbitrarily behind.

So RenderModel grows `tiles`, `views` and `formulas`. The two panels follow
the help overlay's rule — an empty list is what says a widget is closed, so
the page asks no question about the mode — and the tile bar is drawn always
with only its *selection* conditional, exactly as src/ui/tile_bar.rs makes it
conditional on AppMode::TileSelect.

The view panel's axis line would have been a second derivation, so
ViewContent::axis_summary moves to improvise_protocol::viewmodel as
view_axis_summary and the TUI's own panel now calls it: one home, both ends
(design-principles §5).

Tests: harness/tests/widgets.spec.js, proved by running it against the
unfixed bundle, where the four new assertions fail on zero elements and the
category one passes — the control that says the keyboard really is being
driven. Two draft assertions were wrong and both corrections are recorded:
Axis::short_label is three columns ("Quarter Pag"), and switching views
leaves the panel open as the TUI does.

Not fixed, filed: improvise-7ybz (no pointer affordance anywhere on the page),
improvise-wsaw (app.js has never read category_panel_open, so #categories is
drawn unconditionally and is now inconsistent with the two panels added here).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:18:01 -07:00
Edward Langley e176a29e75 fix: the no-op guards must ask what the grid draws, not what the model stores
Second review round. Six accepted-but-no-op commands still reset the cursor and
bumped the epoch; five are fixed here and the sixth is filed.

The one to remember is AddItem { _Measure, <a formula target> }. The guard read
category.items, but _Measure's effective items are explicit UNION formula targets
(gotcha 7), so it called "new" a column the grid was already drawing. The three
item effects now compare effective_item_names ACROSS the mutation rather than
predicting from one side of it, which is correct in both directions at once and
needs no separate rule for dedupe, for formula targets, or for an unknown
category. Prediction is what was wrong; observation is the fix.

Four smaller ones: SetAxis on a category the view does not register (set_axis
no-ops, but try_axis_of returns None, which compares unequal to Some(axis));
HideItem/ShowItem naming something the grid never drew (hide_item records any
string); and ToggleGroup on a group that does not exist, which never got a guard
at all because it merged CLEANLY through the #48 merge -- the clean-merge hazard
arriving in the one place nobody re-read.

TogglePruneEmpty on a grid with nothing empty is NOT fixed. Guarding it means
computing the pruned layout, which is building the frame improvise-ni6k proposes
fingerprinting. It is one keystroke in ordinary use, so it is filed loudly rather
than patched quietly.

The fixture could not reach any of these by construction -- no formula, no
category on Axis::None, no unknown-name case: the one-category-fixture shape the
doctrine names. It now carries a Profit formula, a Channel category on Axis::None
and a Ghost entry hidden under a name the model never had.

Two of the five injections initially failed to reproduce, and BOTH TIMES THE
INJECTION WAS WRONG RATHER THAN THE GUARD: ShowItem needed a hidden ghost name,
since is_hidden is false for a name nobody hid; and AddItem had to compute its
predicate BEFORE the add, because afterwards the item really is in category.items.
An injection that does not reproduce the original ordering tests nothing.
ToggleGroup's first guard was also wrong -- add_item_in_group sets item.group
without pushing to Category::groups -- and the existing reshape test caught it.

All nine earlier injections re-run and still caught.

Filed: improvise-nynk (adopt_cursor_epoch resets clients on views nothing
reshaped -- bisected to main/#48, but this branch takes it from 2 effects to 12),
and TogglePruneEmpty plus the Axis::None class onto improvise-ni6k.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:17:20 -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 a6258f7c50 Merge gh/main (PRs #48, #50) into wave/n-cleanups
Two conflicts, both in ui/effect.rs and ui/reduce.rs, both resolved by keeping
BOTH sides rather than either.

#48 introduced App::view_mut() as shorthand for
model_state.workbook.active_view_mut(). SetAxis, HideItem and ShowItem conflicted
because this branch had just given each a no-op guard (improvise-9mo9 review, F1).
Resolution keeps the guards, expressed through the new accessor. ToggleGroup
merged cleanly and kept its reset, which is the case worth noting: git had no
reason to flag it.

#50 moved records_named into improvise-protocol and re-exports it from
ui/reduce.rs, and re-added reduce_view to the import list. reduce_view's last use
in this file was deleted by improvise-s17o on this branch -- apply_command's
fallback arm no longer folds -- so the resolution keeps the re-export and drops
the now-dead import.

A CLEAN MERGE IS THE DANGEROUS ONE, so all nine fault injections were re-run
against the merged tree rather than trusting a green suite: SetAxis's moved
check, AddItem's fresh check, AddCategory's Err arm, SetAxis's reset removed,
SetNumberFormat gaining a reset (the negative arm), the ShowItem non-vacuity
guard, reset_cursor clearing the whole cursor map, the CommitAndAdvance advance
ordering, and apply_command folding again. All nine still caught, and the tree
restores byte-identical. That matters most for the cursor ones, since #48 changed
which view a session is projected against.

1333 passing (1328 from main plus this branch's 5), clippy and fmt clean, both
comment-run checker modes clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:07:09 -07:00
Ed Langley a3bef6ed7e Merge pull request #50 from fiddlerwoaroof/wave/h-shared-frontend
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
WU-H: the shared front-end layer (improvise-0yq8, e24v, x7sw, ktv1, k099)
2026-09-20 12:03:16 -07:00
Edward Langley 7d25762725 test: the worker's dirty-marker test could not see the widget vanishing
Re-review finding, low severity and correct. The test asserted only
`!document.dirty`, and `DocumentCache::default()` is already clean — so it
could not tell "told it cleared" from "never told anything", and passed under
an injection that made `document_update` always return `None`. It is the test
that names the browser deployment, which is the one place nothing ever sends
ClientFrame::Save, so it is the only guard on that path.

It now records every marker the client is told and asserts `[true, false]` for
the edit. The bootstrap seeds its own clean marker first, so the sequence is
cleared after the handshake and the assertion is about the edit rather than
about the connect. Verified failing under both injections: dropping the
worker's re-projection gives `[true]`, and stopping the widget being projected
at all gives `[]`.

Declined, with the reason: a test for `GridLayout::record_cell_key` returning a
key for a `RecordCol::Category` column. The injection the reviewer describes
would make a coordinate column project `Derived` where a formula owns that
coordinate — and the reviewer's own harder case established that a formula
targeting a regular category SHOULD project `Derived` there, in both modes. So
the injected behaviour is not clearly a fault, the exhaustive match already
forces a decision on any new `RecordCol` variant, and a test would be pinning
an answer nobody has argued for. Recorded as a known gap rather than guarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:59:55 -07:00
Edward Langley ed412a0256 fix: review round — the Ok-but-no-op resets, the measure regression, and a
loop-of-one test

F1 (HIGH). Gating the reset on Ok is not the same as gating it on "something
changed". Six accepted commands changed nothing and reset every cursor anyway:
SetAxis to the axis already held, HideItem on an already-hidden item, ShowItem on
a never-hidden one, AddItem of an existing item (Category::add_item dedupes and
returns Ok), and RemoveItem/RemoveCategory of something absent. Each now asks the
state before mutating. The case that made it urgent needs no second client:
TileSelect binds `r` to set-axis-for-tile row, which emits SetAxis with no
comparison against the current axis, so `r` on a Row category sent the user's own
cursor to the origin.

F2 (REGRESSION this PR introduced). Naming a measure in the trailing `+` column
at the bottom-right of a records grid sent the cursor home: AddItem now resets,
inserts_a_column matches only NewCategory, and EnterAdvance at the bottom-right
returns AbortChain with no SetSelected. The restore moves to BEFORE the advance,
where an aborting advance cannot skip it -- gotcha 21's rule applied to the
cursor rather than the mode -- which also subsumes the +cat arm's copy.

F3. a_refused_model_command_invalidates_no_cursor named a class and tested one
member; injecting reset_cursor into AddCategory's Err arm left it green. Now a
table over all three refusable effects, and that injection fails it by name.

F5. The benchmark could not reproduce its own figures: it took the mean over a
few iterations, and review measured the same share at 8% and at 131% -- a part
slower than the whole containing it, printed flat. It also passed (false, 2)
where grid_of passes the view's (true, 0), so it timed a call that never happens.
Now min-of-N with the view's own format, and a share above 100% prints UNSTABLE.
Figures are stable across runs at ratio 1.2-1.5x, col_widths 45-49%.

F6. The drg counts omitted the 8 simple_effect! invocations and misclassified
AddItem at base through a regex that cannot see `app` newline `.model_state` --
doctrine(derive_then_verify) catching the person citing it. Correct: 57 effects,
narrow set 32 -> 25, not 25 -> 17 of 49. The direction holds and is still the
argument; the headline was overstated by about half again. Corrected in
context/code/command.md and on the bead, not quietly restated.

F9. a_model_command_that_leaves_the_active_view_alone_broadcasts_nothing asserts
a ResetCursor broadcast, so the name read as a lie: ..._broadcasts_no_switch.

F4 filed as improvise-pdvm: AddItem/RemoveItem declare CellData so layout_version
does not bump while cursor_epoch does, and this PR's own reshape assertion is the
first evidence for it. Related to improvise-75ld, same question other direction.

Faults injected for every new guard: SetAxis's moved check, AddItem's fresh
check, AddCategory's Err arm, and the pre-fix advance ordering ((0,0) vs (0,3)).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:58:03 -07:00
Edward Langley b6beac48b7 Merge remote-tracking branch 'gh/main' into wave/f-route-then-aggregate 2026-09-20 11:54:41 -07:00
Edward Langley 0edd26b1b5 fit: fold the member record onto partial, not onto context
Self-review of the commit before it. The record key the fold evaluates at was
built by folding the record's coordinates onto `context`, which still carries
the formula's own target coordinate. Harmless for a `_Measure` target, because
`Expr::Ref` overwrites `_Measure` on the way in — and not harmless for a formula
targeting any other category: the record key keeps `Levy=Tax`, `evaluate_depth`
matches the same formula again at that key, and the cell converges to
`Error("circular")`. A formula silently self-referencing through its own
aggregate.

`partial` is `context.without(target_category)` plus any inline WHERE, so
folding onto it drops the target coordinate by construction. Every member
satisfies `partial` by definition — that is what selected them — so nothing
constraining is lost.

`a_formula_targeting_another_category_still_folds_over_its_records` is the
guard; restoring the `context` fold fails it with `Error("circular")` against
`11.0` and leaves the other eight passing. Evidence in
`context/test-evidence/core.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:54:29 -07:00
Edward Langley fc4035dbca Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend
# Conflicts:
#	crates/improvise-command/src/ui/reduce.rs
2026-09-20 11:54:09 -07:00
Edward Langley 24d9f61e35 fix: improvise-ir7l was not closed in records mode, and a save told no client
Two findings from the adversarial review of #51, both demonstrated before being
fixed and both re-proved by injection afterwards.

FINDING 1 — a formula-owned cell was still a writable Record in records mode,
and my own guard test's written premise was false. "A formula target lives only
in measure_item_names" does not hold: an item can be BOTH an explicit _Measure
item and a formula target, and improvise-poia in this same PR made the `+`
column that builds one a single keystroke away. Type `Profit` into `+` on a
document that already has `Profit = Revenue - Cost`, and the column projects
CellId::Record; `999` commits, is stored, and is then shadowed forever by
formula_cache — the exact defect ir7l exists to close, in both front ends.

The question is now asked of the record's REAL coordinate.
GridLayout::record_cell_key is the one mapping from a records position to a
model cell; cell_id and the new CmdContext::owning_formula both go through it.
The synthetic (_Index, _Dim) key names no measure, which is why EditOrDrill's
guard could not fire there.

typing_into_a_records_formula_column_is_refused_on_the_key_path drives the
reviewer's repro through App::handle_key with no test API. It exists because the
projection half and the EditOrDrill half are separately breakable — injecting
each alone fails a different assertion.

FINDING 2 — DocumentCache::dirty never cleared on a client. Both save paths
cleared model_state.dirty OUTSIDE a projection: Host::save returned only a
possible Rejected, and Worker::on_frame built its frames and persisted after. So
after the user's last keystroke a browser showed [+] on a saved document,
indefinitely — which is the failure mode protocol.md's own z754 section names as
the thing the design avoids.

The z754 test that should have caught it called sessions.project_all by hand,
which is exactly the call both production paths omit. Not a test comparing a
thing with itself; a test exercising a call the deployment never makes. The new
ones go through Host::handle and through the worker's real message loop.

Also filed improvise-8ec3: the reviewer measured improvise-io's test binary at
3.7-5.2s where this machine reads 1.4-1.7s, so the 2s per-binary rule is
breached on some machines — on main too — and the figure design-principles §7
pins is not reproducible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:53:29 -07:00
Edward Langley 022e12f9b4 fit: route per record and aggregate, never route on the aggregate
improvise-4zl7. A tree is fitted on individual records; an improvise formula is
evaluated at a CellKey, and an aggregating cell used to aggregate each Expr::Ref
first and evaluate the IF once on the totals. That is the same answer for a
linear formula and a different one for anything that routes, so every fitted
tree meant one thing on a record row and something else one row up. The user
ruled: aggregate the rule over the members.

The rule needed no new aggregate vocabulary. SUM(e) already means "fold e over
the members" and simply did not: eval_agg specialised Expr::Ref and, for every
other inner expression, summed the raw matching cells and ignored the expression
entirely. It now folds — one entry per member record, the expression evaluated
at that record's own coordinates. eval_agg is shared by eval_expr and
eval_expr_cached, so the fold has exactly one home; each evaluator passes its
own recursion in.

Expr::Is(Filter) is the new arm in both evaluators: `Cat IS Item` is 1.0 when
the cell's own Cat coordinate is Item, keeping the evaluator f64-only and
reusing Formula::filter's own context.get/unwrap_or(false). A grammar production
in primary, before ref_expr, with both operands through
improvise_formula::quote's one home; IS joins the keyword guard.

The emitter reads feature index -> role off preprocess's plan rather than
reconstructing it: gp_tree::data::feature_roles is the one statement of the
expansion order and encode_row now drives off it too. improvise_gp::Features
carries one Spelling per feature — Measure, Coordinate or Artefact — decided by
role and column, never by the name's shape. A one-hot over a real category is
emitted as `Region IS North` and accepted; col.missing and the date parts stay
Artefact and stay refused (improvise-wd6n).

fit-measure on a document a category explains now lands instead of refusing, and
the refusal no longer depends on which features the search happened to pick.

Evidence: eight hand-computed tests in improvise-core over a four-record fixture
whose Cost straddles the threshold inside each region, so route-then-aggregate
and aggregate-then-route differ at every level. Two faults injected and watched
to fail — reverting the fold fails 7 of 8, and a constant-false eval_is fails
exactly the 3 coordinate tests. Reordering feature_roles fails both it and
encode_row_replays_the_training_expansion.

The fold is O(members) per evaluation per fixed-point pass and unbenchmarked:
improvise-9iyv.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:50:09 -07:00
Ed Langley c59df9f406 Merge pull request #48 from fiddlerwoaroof/wave/e-active-view
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Split active_view: the document's default vs each client's (jytm, 6c6q, k69y)
2026-09-20 11:49:41 -07:00
Ed Langley 2c92e1ce6f Merge pull request #44 from fiddlerwoaroof/ci/gate-the-comment-cap
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
ci: gate the comment cap, which has been advisory since it was written
2026-09-20 11:48:39 -07:00
Edward Langley 82ef93d4cb Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend 2026-09-20 11:48:28 -07:00
Edward Langley f66c4aff04 fix(protocol): records_named has one home, and the lockstep sees every cursor
Findings 1 and 2 of the adversarial review of PR #50, both demonstrated by
injection before being fixed.

**F1.** `resolve.rs`'s `structural`/`record_write` were a second derivation of
`reduce.rs::records_named`, and nothing tested them: `lockstep.rs`'s
`apply_on` restates the basis against its twin with `caught_up`, which
re-derives it with the SERVER's answer and throws the client's claim away
before anything is compared. Emptying `record_write`'s claim left the
workspace green.

Fixed by collapsing rather than testing, which is what this unit argues for
everywhere else. `records_named` is pure and `RecordHash` is already
re-exported by the protocol, so it moved DOWN into `improvise-protocol`
beside the enum it reads; `improvise-command` re-exports it; `resolve.rs`
has one `model()` that claims `records_named(&cmd)`. The claim is now right
by construction and the guard is the exhaustive match that was always there.
Verified by emptying the shared function's `SetCell`/`ClearCell` arm: five
server-side tests fail, where the client's copy had none.

What the old shape cost, which was not a compile error: `structural` accepted
any variant and claimed `[]`, so a new record-writing command compiled clean
on the client with the wrong answer and the symptom was a dead browser key,
refused as `RecordUnstated`, with nothing failing.

**F2.** Three resolver divergences the suite could not see, all now failing:
`delete_view_at_cursor`'s cursor step-back (its only decision — the test
pressed `n` and `Enter` while its doc comment said "create, switch and
delete"), and both Formula arms of `CommitKind`, Formula being the only kind
whose `on_commit()` is `Some`. `both_ends` now compares every cursor, not
only the document, mode and status. A fourth injection — `cycle_panel_focus`'s
`start` index — still survived, because `PanelKeymap` binds Tab to
`enter-mode normal` inside a panel so no key reaches those arms; they are now
driven directly by `panel_focus_cycles_from_the_focused_panel_not_the_first`.

Widening the commit test found a real divergence, pinned rather than fixed as
improvise-cl7y: the client folds `CommitBuffer`'s optimistic "Formula added"
and cannot parse, so a browser reports a formula it did not add.
`a_formula_the_client_cannot_parse_diverges_on_the_status_line` asserts both
sides, so a fix has to come there and change it.

F3 filed as improvise-thpe and deliberately not fixed here.
`apply_on`'s doc comment pointed at an evidence entry that did not exist and
now names `context/code/clients.md`, which has the argument.

1324 tests pass, up from 1322.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:48:26 -07:00
Edward Langley 7368467ce9 test: prove the drill's cursor ordering instead of arguing it
improvise-9mo9 made SetAxis reset the cursor, and DrillIntoCell issues eight or
so SetAxis effects. The PR claimed that was safe and did not test it: the claim
was that SwitchView precedes the batch, so reset_cursor writes the ACTIVE view's
cursor -- by then the freshly created _Drill one -- while the view drilled from
keeps its entry in ViewState::cursors. That is an ordering argument about effects
inside a batch, which is the class of claim gotcha 23 exists because people get
wrong, and I had flagged it to the reviewer as unverified.

It holds. Two faults injected: removing SwitchView from the batch fails the
non-vacuity guard ("the drill did not switch views, so this proves nothing"), and
making reset_cursor clear the whole cursors map rather than the active entry
fails on the origin view's cursor (left: (0,0), right: (1,1)).

Both sides are asserted on purpose. That _Drill opens at its origin is half the
property; that the other view keeps (1,1) and row_offset 1 is the half that would
have broken, and a test asserting only the first passes against an emptied map.

The cursor_epoch half stays argued and now says so in context/code/command.md: a
drill bumps it once per SetAxis, adopt_cursor_epoch compares before and after per
command so it relays once rather than eight times, and DrillIntoCell is key-path
only today, so no client sees it. That changes when drill becomes addressable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:48:09 -07:00
Edward Langley 7b2e9d8606 docs(clients): the editable-is-the-id list still named two kinds
A fact stated twice is changed by a grep, and this copy was missed. The
"four decisions that are silent when wrong" list in context/code/clients.md
enumerated CellId::Record and CellId::Aggregate and then said None is "not
aggregate" — written when those were the only two kinds. There are now four,
and a reader of that list would have concluded a Derived or a Create cell must
be a None.

Found by grepping for the claim after PR #47 put web/demo.improv on the site's
front door, which is also how the remaining stale copies were located: two in
prolog-sessions/kb.pl and two in context/repo-map.md, both orchestrator-only
files, reported as text in the hand-back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:42:00 -07:00
Edward Langley 1747d39384 Merge gh/main (PR #47) into wave/n-cleanups
No overlap: #47 is README, context/plan.md, docs/index.html, web/assemble.sh
and harness/, and touches no Rust and none of this branch's context/ files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:40:53 -07:00
Edward Langley a31c3d2dce Merge remote-tracking branch 'gh/main' into wave/b-projection-gaps 2026-09-20 11:40:47 -07:00
Edward Langley f096659b62 Merge remote-tracking branch 'gh/main' into wave/e-active-view 2026-09-20 11:40:44 -07:00
Ed Langley d6b0d4cbd9 Merge pull request #47 from fiddlerwoaroof/wave/p-milestones
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Wave 2 WU-P: the landing page, and the milestone epics verified criterion by criterion
2026-09-20 11:40:14 -07:00
Edward Langley 942e690601 test(active-view): close the three guards review found missing
All three were demonstrated by injection against a green suite — this repo's
characteristic defect, and the PR's own evidence table asserted coverage it did
not have.

- `ClientSession::view_in` had no guard: pointing it at the host's view left
  1313 tests green. Only `grid_in` was checked; `category_tree_in` and
  `tile_bar_in` were not, and the tile bar is the control a user reassigns axes
  with. `a_session_on_another_view_is_projected_that_views_axes` closes it.
- `adopt_deleted_views` and `apply_switch` had vacuous audience assertions:
  both bystanders sat on the document default, where a wrongly-addressed relay
  folds to a no-op. Both now sit on a view they can be moved off, and both read
  the audience off the `Broadcast` rather than inferring it from folded state.
- `ViewBack`/`ViewForward`'s `EffectKind` had no guard — reclassifying either
  was silent. `walking_the_view_history_moves_no_document_state` asserts the
  consequences (`dirty`, `layout_version`) rather than the declaration.

Four follow-ups filed: improvise-w3bf, improvise-pubt, improvise-h074, and the
reachability trigger appended to improvise-lltt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:35:26 -07:00
Edward Langley 29012d88e3 fix(protocol): a chord-only hint rendered as a bare colon
Found in a self-review pass while the PR was opening, and latent rather than
live: `compact` drops chorded keys, so a command advertised in a mode where
every binding for it is a chord compacts to the empty string and `hint_line`
rendered ":caption" — a colon advertising a key the reader cannot find.

No shipped mode reaches it (`enter-tile-select` is the closest and keeps `T`),
which is exactly why it wanted a test rather than an argument. `hint_line` now
drops such a hint outright, and the_compact_rendering_drops_chords_and_says_
nothing_when_all_are pins all three shapes. Proved discriminating by making
`compact` keep chords: the test fails.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:23:57 -07:00
Edward Langley 0917d04bfd test: two guards the self-review found missing, and ADR 002's enum has grown
Three findings from running /code-review over this branch.

a_filter_axis_is_projected_but_not_cycled builds its own PageAxis by hand, so
it says nothing about what grid_of actually emits — a test that fabricates the
very data the code under test produces, which is this repo's characteristic
defect. a_page_axis_carries_its_items_and_a_filter_axis_does_not is the
producer half; it fails under `let navigable = true`, which is filtering_cats()
leaking into the protocol, the exact trap gotcha 27 names.

improvise-ir7l holds in RECORDS mode only by accident: cell_id's records branch
never asks Model::formula_for, and it is safe purely because records_columns
builds from ordered_item_names (explicit _Measure items) while a formula target
lives only in measure_item_names. a_records_grid_has_no_column_for_a_formula_
target aims a guard one level below the fix, and fails under the plausible
tidy-up that swaps the two — a Profit column appears, and it would project as a
writable Record.

ADR 002 specified a two-variant CellId. It now has four, and a fact stated in
two places is changed by a grep. The ADR gets an Extension section rather than a
rewrite: the decision is unchanged, and both new variants were arrived at by
applying its clause 1 rather than departing from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:21:50 -07:00
Edward Langley 604c5c69c4 chore(bd): auto-export
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:21:14 -07:00