diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0c30e6d4..05d87c9d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,7 +22,7 @@ {"_type":"issue","id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","acceptance_criteria":"A syntactically invalid .improv file produces an error containing the source line number (and file path when loading from disk); pest spans already carry this. Test asserts the error message contains the expected line:col.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-09-20T02:10:39Z","closed_at":"2026-09-20T02:10:39Z","close_reason":"Done. The server as a wasm blob in a Web Worker: same App, same Sessions, same Host, behind self.onmessage/self.postMessage with OPFS underneath. Closed with --force because the graph still lists improvise-xf1 (browser verification of OpfsFs), which is DEFERRED by user decision -- that block is waived, not satisfied.\n\nAC 1 met (wasm32 clean, builds through wasm-pack, added to the CI wasm job). AC 3 met with room: 1,334,892 bytes raw / 496,965 GZIPPED plus 6,700 of JS glue -- about 492 KB against a 3 MB budget; with the client's unchanged 122,599 the pair is ~600 KB. AC 4 met: tests/post_message.rs drives a real Worker with nothing but the JSON a MessagePort carries.\n\nAC 2 IS MET HOST-SIDE, NOT BROWSER-SIDE, and the line is exact. Tested: worker.rs (16t), vfs.rs, post_message.rs (6t) -- handshake, Addressed filter, rejection path, persist trigger, seeding and list are all on this side. Untested: bindings.rs (cfg wasm32) and web/worker.js -- compile-checked, node --check-ed, all three calls confirmed in the generated .d.ts with matching arity, nothing more. createSyncAccessHandle exists in no harness, so OPFS ITSELF REMAINS NEVER RUN: if persistence misbehaves in a browser it is the first place to look.\n\nHOST WAS MOVED, NOT MIRRORED -- the one non-additive call, sanity-checked at merge. Host is App + Sessions + handle, contains no socket, and lived in a tokio crate the worker cannot depend on. It now lives in improvise_command::ui::host beside Sessions; ws-server re-exports so every path resolves; registry stayed behind because a worker hosts ONE document. Re-implementing the loop would have been the mirror improvise-xks6 had just deleted for the frames, and a drifting Applied/Broadcast match is invisible until a client sees it. Verified at merge: host.rs imports only crate::ui::{app,project} and improvise_protocol, and improvise-command's dependency list is unchanged.\n\nTHE DOCUMENT IS NAMED OUT OF BAND, in the worker's URL -- the analogue of the socket naming it in the ws:// path. NO FOURTH ClientFrame. Per the user's ruling mid-flight, loading is THE VFS, not a bespoke entry point: a seed is a Storage::write read back like any other document, so a fetched demo, an upload and a saved file share one parse path and a seed never clobbers an edit.\n\nClientFrame::Key IS LEFT ANSWERING NOTHING, and the brief's premise that a worker could resolve it was WRONG for a reason worth keeping. A worker CAN call reduce_key; the blocker is downstream and identical on both transports -- NOTHING IN THE PROJECTION STREAM CARRIES THE HOST'S OWN ViewState. A client's slice moves only by folding Commands, and project emits document projections, a grid diff and a basis, none of which is a mode or cursor change. So resolving a key host-side does not under-deliver, it DIVERGES: the host believes it is in Editing while the client draws Normal, and viewport() reads the mirror's cursor so the grid would not even scroll. That is improvise-bpq2, now a DEMONSTRATED property rather than prose -- a_key_resolved_on_the_host_moves_state_no_client_can_hear fires reduce_key against a real App and asserts the mirror did not follow.\n\nFOUR REAL OBSTACLES, AND NONE WAS OPFS: Host in the wrong crate; Save unreachable from the wire; dirty never set by a wire edit; the async seam unreachable through a Box. THREE OF THE FOUR ARE GAPS IN THE SHARED LAYER THAT THE SOCKET SERVER HAS TOO -- it never noticed, because its documents are opened from a CLI and saved by a human pressing a key in a TUI that is not there. Filed separately. Being the second real consumer of an abstraction is what audits it.\n\n1155 passing (+22), clippy/fmt clean, wasm32 clean on all SEVEN bundle crates, actionlint clean. Delivery stayed cheap: two lines in build.sh, one cp -rf plus one cp in the assemble step.","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-xf1","type":"blocks","created_at":"2026-08-31T23:11:01Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-20T18:19:45Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.\nURL CORRECTION to the verification annotation above.\n\nThat annotation cites `https://fiddlerwoaroof.github.io/improvise/` as what was\nmeasured for criteria (2) page-load-to-interactive, (3) bundle size and (5) the\nREADME link. Those measurements were taken against the browser APP, and were\ncorrect when taken — but this same PR then moved the app, on a user ruling\n(improvise-kh8): the site root is now the LANDING PAGE and the app is at\n`https://fiddlerwoaroof.github.io/improvise/app/`.\n\nThe measurements stand — 1172/278/408 ms to an editable grid, 699 KB gzipped for\nthe two wasm binaries — and every URL that produced them now names the wrong\npage. Read every bare `…github.io/improvise/` in that annotation as\n`…github.io/improvise/app/`. Anyone re-running them against the root will be\ntiming four asciinema players instead, and would get a number that looks\nplausible and means nothing.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-20T18:22:57Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"PROGRESS 2026-09-20 -- the DELIVERY half is done and landed; the ticket stays OPEN because the half it is named for (a static shell that works) genuinely does not, and bd is right to refuse the close while improvise-djm blocks it.\n\nDONE: pages-build/pages-deploy appended to the EXISTING ci.yml, so the deploy is a needs: edge on [test, wasm, pages-build] rather than a workflow_run dance. web/build.sh runs UNCHANGED, so there stays one answer to how the bundle is built and a local build is evidence about the CI build. wasm-pack pinned to v0.15.0 and added to the devShell -- note the wasm CI job only ever ran cargo check, so NOTHING had previously required the one tool that turns a passing check into a loadable page.\n\nMEASURED: build.sh succeeds end to end -- 346 KB wasm, 120 KB gzipped, 388 KB assembled site, every reference resolving inside it, all asset paths relative so the /improvise/ sub-path works. actionlint clean. THE WORKFLOW HAS NEVER RUN: unproven until the first main push.\n\nNOT PATH-FILTERED, deliberately. The bundle is improvise-wasm-client + protocol + core + formula, so a filter meaning 'skip when the bundle cannot have changed' would name most of the workspace, and getting it wrong leaves a STALE SITE WITH NOTHING SAYING IT IS STALE. The over-build is the cheaper error.\n\nWHAT REMAINS HERE, and one part was not predicted by this ticket. The deployed page does not work -- app.js holds no model, so it needs a server -- and the ?ws= escape hatch ALSO fails, because Pages is https and a ws:// address from an https origin is blocked as MIXED CONTENT. There is currently NO configuration in which the published URL does something useful. That is stated in STATIC HTML (#backend-notice) rather than by app.js, because a page that cannot reach a server cannot be trusted to explain why. Close this when improvise-djm makes the page self-hosting.\n\nAC 4 (bundled demo model) is MOVED TO improvise-bck: nothing in the browser reads a .improv until improvise-9x6 gives the worker an init-from-bytes entry, so the file would have no consumer.\n\nHUMAN ACTION: Pages is not enabled (the API 404s). configure-pages@v5 enablement:true should create it on the first main push; if the token may not, Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS (not the branch/folder source improvise-3gy currently names).","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-09-20T04:20:53Z","closed_at":"2026-09-20T04:20:53Z","close_reason":"Done -- the page now works from its own URL, which is what this ticket was waiting on.\n\nThe DELIVERY half landed earlier (pages-build/pages-deploy appended to the existing ci.yml, so the deploy is a needs: edge on test+wasm rather than a workflow_run dance; build.sh runs unchanged). What was missing was that the deployed page could not DO anything: app.js held no model and the ?ws= escape hatch was blocked as mixed content. improvise-djm and improvise-9x6 closed that, and the static backend notice is gone.\n\nVERIFIED BY THE USER, 2026-09-20, Safari 27: https://fiddlerwoaroof.github.io/improvise/ renders the Corner Coffee demo from the in-page worker -- Product rows, Revenue/Cost/Profit columns, Quarter on page, and the formula column evaluated.\n\nONE DEFECT FOUND AND FIXED HERE, worth remembering because it cost two round trips: Pages serves everything max-age=600 and the wasm-pack filenames carry NO CONTENT HASH, so for ten minutes after a deploy a returning visitor runs the PREVIOUS build. That made a shipped fix look like it had not shipped, twice -- the deployed artifact was verified to be the right commit while the browser was still running the old one. The assemble step now stamps ?v=\u003csha\u003e onto BOTH the glue and the .wasm, because a fresh glue does not pull a fresh binary: the glue locates it with new URL('..._bg.wasm', import.meta.url) and a URL built against a base does not inherit the base's query.","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-09-20T02:29:49Z","closed_at":"2026-09-20T02:29:49Z","close_reason":"Done. web/app.js spawns the worker: new Worker('./worker.js?doc=\u0026seed=', { type: 'module' }).\n\nTWO CORRECTIONS TO THE TICKET'S OWN DESCRIPTION, both dates rather than decisions:\n * NO MessageChannel is needed or wanted. worker.js uses self.onmessage/self.postMessage -- a dedicated worker's OWN implicit port -- so worker.postMessage / worker.onmessage is the pair.\n * type: 'module' is MANDATORY, since worker.js imports the bundle. A classic worker fails to parse it, and that failure does reach the error listener.\n\nTHE WEBSOCKET PATH IS KEPT, worker is the default: ?ws= selects the socket, its absence spawns the worker, and THE PRESENCE OF THE PARAMETER IS THE WHOLE SELECTION MECHANISM -- a second knob (?transport=) would name the same fact twice. Kept because improvise-ws-server is the only way a TUI-hosted document is shared, and deleting the page's path to it would leave a working server nothing could reach. The cost is not 'two things to keep working': the transports differ only in WHERE STRINGS COME FROM. Handshake, on_frame, repaint, typed_cell and keydown are all above the seam, so the difference is confined to two ~15-line factories returning { send, ready }. That is exactly what improvise-xks6 bought -- one envelope pair, two deployments.\n\n#status now composes TWO facts, the transport's and the document's, because a single source let a repaint silently erase a connection error.\n\nMEASURED: client 345 KB / 123 KB gzipped, worker 1.33 MB / 498 KB gzipped, total site 1.8 MB / ~620 KB gzipped -- comfortably inside the 3 MB budget. build.sh end to end under nix develop; the CI assemble step replayed locally with every relative reference checked to resolve. Every JS call confirmed against the generated .d.ts with the right arity.\n\nNOT VERIFIED, and this is the whole remaining risk: NOBODY HAS OPENED THE PAGE. No browser, no headless DOM. Every line of app.js and worker.js is read, not run -- the module-worker spawn, OPFS, ResizeObserver arithmetic, click-to-edit, keydown, the CSS. The Pages workflow has also never run.\n\nAC 4 is partly blocked: cell edit, formula entry and reload work through Commands; keyboard-driven interaction needs improvise-bpq2, and today NO KEY PRODUCES A VISIBLE CHANGE -- Host::handle answers ClientFrame::Key with an empty vec, and keys.rs resolves locally only in minibuffer modes you can only enter via an upstream key. The listener is wired and honest about doing nothing.","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} @@ -37,8 +37,17 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lyyc","title":"check-evidence is one-directional: a test citing the evidence file can have no entry","description":"`scripts/check-comment-runs.py --check-evidence` validates one direction only: every `### name` heading in `context/test-evidence/` must name a test that exists. It never asks the reverse — that a test whose doc comment *points at* the evidence file has an entry there.\n\nThat blind spot has now been walked into for the first time. PR #59 added `the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons`, whose doc comment directs the reader to `context/test-evidence/clients.md`, and that file had no entry for it. The checker was green throughout. Worse, the same file still described the behaviour #59 *ships* as one of the two injected faults that had proved the old test — so a reader following the pointer arrived at prose that contradicted the code. Both were found by human review, which is the thing the checker exists to avoid needing.\n\nThe asymmetry in the original design is deliberate and correct as far as it goes: most tests have no evidence and should not be forced to. What is missing is the narrower obligation — **a test that names the evidence file must appear in it**. That is checkable: grep test doc comments for `test-evidence`, take the enclosing `fn` name, require a matching heading.\n\nNote the second half of the incident is not covered by that check either, and probably cannot be: an entry can exist and still be *stale*. The drill entry existed and described its own subject backwards. The only defence there is the rule that a fact stated twice is changed by a grep rather than an edit (gotcha 48), which is discipline rather than a gate. Worth saying so in the ticket so nobody implements the cheap half and believes the class is closed.\n\nAcceptance: `--check-evidence` gains the second direction; it fails on a test that cites the evidence file and has no heading; proved by deleting one heading and watching it fail.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:40:48Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:40:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-djoo","title":"Publish the context/ prose as a documentation site folded into GitHub Pages","description":"Generate a static documentation site from context/ (design-principles, repo-map, code/*, adr/*, test-evidence/*) plus docs/design-notes.md, assembled by web/assemble.sh under /docs/ alongside the landing page at the root and the app at /app/. No framework, no build step beyond one zero-dependency node script. Ticket ids resolve to a generated index built from .beads/issues.jsonl; source paths link to GitHub at the assembled ref. The generator link-checks its own output and exits non-zero on a break, so a dead cross-reference fails the assembly. context/plan.md and context/cloud-agents.md are deliberately not published.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:34:53Z","created_by":"wave3-docsite","updated_at":"2026-09-20T20:34:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rmvx","title":"Six advertised keys do nothing in the browser, and the guard is scoped to one mode","description":"Found by generalising improvise-gd28's browser finding. `\u003e` was not an instance;\nit is a class. Gotcha 47 says every KeyOutcome::Upstream is a black hole —\nHost::handle answers ClientFrame::Key with an explicit empty vec — so any key\nthe app ADVERTISES and then resolves upstream does nothing at all in a browser\nand reads as broken rather than as unavailable.\n\nTHE LIST, advertised in a hint line AND unhandled by resolve::step:\n\n \u003e drill-into-cell Normal (fixed in PR #59)\n P toggle-prune-empty Normal\n R toggle-records-mode Normal\n o add-record-row RecordsNormal\n d/Del delete-formula-at-cursor FormulaPanel\n : enter-mode command Normal, RecordsNormal, FormulaPanel,\n CategoryPanel, ViewPanel, TileSelect\n\nThe `:` row is a DIFFERENT case and must not be lumped in. Its name IS handled;\nit is refused on purpose by strands_the_user, because CommandMode is a prompt\nthis client can enter but cannot commit — a dead room is worse than a locked\ndoor. So for `:` the refusal is RIGHT and only the silence is wrong. Fixing it\nmeans saying so, not implementing it.\n\nHOW THE CROSS-REFERENCE WAS MADE, because the method is reusable and the naive\nversion is wrong. help::advertised() is a table of (command, args, caption) —\nCOMMAND NAMES, not text — so the hint line can be crossed against what\nresolve::step handles exactly, with no tokenisation of the rendered string and\nno guessing which character in \"hjkl arrows :nav\" is a key. Anything that\ntokenises the hint TEXT is solving a harder problem than exists.\n\nMY FIRST PASS OVER-REPORTED AND I ALMOST BELIEVED IT. Resolving every bound key\nagainst an EMPTY ClientState flags edit-or-drill, commit-cell-edit,\ncreate-and-switch-view, page-prev/page-next and delete-formula-at-cursor as\nupstream — but most of those are handled names refused by STATE (no cell under\nthe cursor, no published basis), which is correct behaviour and not this bug.\n\"Upstream in an empty fixture\" and \"the name is unhandled\" are different\nquestions, and the first is not evidence for the second. The list above is the\nsecond, separated by checking each name against step's arms directly.\n\nAND THE NAMED LIST IS HONEST FOR ONE MODE ONLY — this is the point with reach.\nresolve.rs's `the_command_names_that_still_need_the_host_are_these` is the\nguard gotcha 47 says must be kept accurate, and it IS accurate — but it is\nbuilt with in_mode(AppMode::Normal), so it only ever asks about Normal-mode\nbindings. add-record-row (RecordsNormal) and delete-formula-at-cursor\n(FormulaPanel) are outside its reach entirely: they could never have failed it.\nA named list that silently scopes itself to one mode is a guard that LOOKS\nbroader than it is, and that is the same shape as every other defect this repo\nkeeps finding — a check passing while the thing it names is unexamined.\n\nTHE DECISION THIS TICKET WANTS, which is why nothing beyond `\u003e` was touched:\n\n (a) Do the same interim for the remaining five: each says what is true, in\n the client's existing status mechanism. Cheap, honest, reversible, and\n each needs its own TRUE wording — the `\u003e` fix was nearly routed through a\n message about aggregates, which would have been true-sounding about the\n wrong thing and worse than silence.\n (b) Implement some of them client-side instead. toggle-prune-empty and\n toggle-records-mode are view-shape changes and may be composable from\n existing ModelCommands; add-record-row and delete-formula-at-cursor\n touch the document and need checking against gotcha 38 first.\n (c) Make the class unrepresentable: a test asserting that every command named\n by help::advertised() either resolves locally or appears on a per-mode\n exemption list. That is the \"make the wrong thing impossible\" move and it\n would have caught all six at once. It is also the only option that stops\n the SEVENTH appearing.\n\nI did not pick. (c) is the one I would argue for, and it is independent of\nwhether (a) or (b) happens to the five.\n\nNot chased: whether any key is advertised in a hint for a mode whose keymap\ndoes not bind it at all. every_hint_key_resolves_in_its_mode covers that from\nthe keymap side, so it should be empty — but I did not verify it, and it is the\nmirror-image question.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:23:03Z","created_by":"wave3-webui","updated_at":"2026-09-20T20:23:03Z","comments":[{"id":"e5a7ccf4-843d-504a-818b-283c47115c96","issue_id":"improvise-rmvx","author":"wave3-webui","text":"Precedent note (orchestrator's instruction; do NOT build this yet).\n\nThis ticket asks for a check that every key a hint line advertises actually resolves to something in a browser, so that \"advertised but Upstream\" — a black hole (gotcha 47) — fails the build instead of being discovered by a user.\n\nThat is **the same shape as two checks this repo already has**, which makes it precedent rather than novelty, and the two are worth reading before designing it:\n\n- `every_bound_command_name_is_registered` (improvise-command/src/command/keymap.rs) walks the keymaps and requires each bound name to resolve in the registry — *and* that its args parse, because a typo'd mode name otherwise compiles into a dead key.\n- `every_hint_key_resolves_in_its_mode` (same file) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map. Its tokenizer knows the hint dialect explicitly and **panics on a token it cannot classify** rather than skipping it — which is the detail to copy, since a checker that silently skips what it does not understand reports success for the cases it never looked at.\n\nSo the design question is not \"how do we build such a check\" but \"what is the browser's analogue of *resolves*\". For the host, resolving means the registry answers. For a browser, it means `resolve::step` answers with `Some` rather than falling through to `KeyOutcome::Upstream`. The list of keys that legitimately still need the host already exists as `the_normal_mode_keys_that_still_need_the_host_are_these`, so the check is plausibly: every key named in a hint either resolves locally or is on that named list — which also forces closing one to come and say so.\n\nOne live instance, for scale: `\u003e` was advertised and silent, and that was one of six. The one-line interim (a `SetStatus` from `step`) closed `\u003e` only; the other five were deliberately left alone pending this ticket.\n","created_at":"2026-09-20T20:40:58Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-iioi","title":"The view and formula panels have no pointer affordance while the category panel does","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js's viewRow (~:404) and formulaRow (~:422) build their \u003cli\u003e with NO click listener, where categoryList (~:453) now has one. Click (resolve.rs:71-79) has exactly three variants and no ViewRow/FormulaRow.\n\nDemonstrated: Shift+V, then click the last row of #views -\u003e NOTHING changes. Mode stays VIEWS, the row list is byte-identical before and after, the active marker does not move, the panel cursor does not move.\n\nWHY THIS IS THE ONE A USER HITS FIRST: a user who has just learned that category rows are clickable will click a view row and read the silence as broken — which is LITERALLY the bug report that produced improvise-7ybz ('I can't expand categories' meant 'clicking does nothing'). Shipping the cure for one panel and not its two siblings reproduces the original report.\n\nIt is also the CHEAP one: the resolver already has everything. view_at_cursor, switch-view-at-cursor and delete_view_at_cursor exist, and the formula panel already has a formula_cursor in reduce_view's SetPanelCursor arm. Click::ViewRow(usize) under the commit's own stated rule is a near-copy of click_category_row.\n\nADJACENT, same class, lower confidence and not chased by the reviewer: grid GROUP HEADERS (th.group) are collapse-toggles in the TUI and are not clickable here either; row/column header \u003cth\u003es likewise.\n\nNOT covered by the drill work: improvise-gd28 is refused-by-design because no wire vocabulary exists. The view and formula panels have full wire vocabulary today, so this is a different gap.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, press Shift+V.\n2. Click a row in #views.\n3. Nothing changes — no switch, no cursor move, no status.","acceptance_criteria":"Clicking a view row switches to that view; clicking a formula row moves the formula cursor — each under the same rule as click_category_row (go there, then do what Enter does there). Covered by harness/tests/pointer.spec.js assertions proved against the pre-fix bundle.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:20:22Z","started_at":"2026-09-20T20:20:22Z","comments":[{"id":"c306069a-89f1-55d3-ba64-0c81b8025a7a","issue_id":"improvise-iioi","author":"wave3-webui","text":"A RULING WANTED WHILE THIS TICKET IS OPEN, raised by the reviewer and sharpened\nrather than answered, because it follows from my rule and that is exactly why my\nrule should not settle it.\n\nTHE CLICK RULE I WROTE is \"go there, then do what Enter does there\". Applied to\nthe tile bar it gives: select the tile AND cycle its axis, because Enter in\nTileSelect is cycle-axis-for-tile. So there is NO click that merely selects a\ntile. A user cannot point at a tile to inspect it, or to see which one they are\nabout to act on. It is the one widget where a single click MUTATES THE DOCUMENT\n— every other click in the page moves a cursor, opens a panel, or expands a row.\n\nThat is consistent with the rule and it may still be wrong. A pointer carries an\nexpectation a keyboard does not: `T` then arrow then Enter is three deliberate\nacts, and collapsing them into one click removes the step where the user sees\nwhat they have selected before committing. The keyboard equivalent of \"click\"\nis arguably `T`+arrow (select), not `T`+arrow+Enter (select and act).\n\nTHE OPTIONS, none of which I have taken:\n (a) Keep it. One rule, no exceptions, and cycling is reversible in four\n clicks since Axis::next() is a rotation.\n (b) A tile click SELECTS only; cycling stays on Enter/Space and r/c/p. Costs\n the single-gesture affordance and makes the tile bar the one widget where\n a click does not activate.\n (c) Single click selects, double click cycles. Introduces a second pointer\n vocabulary the page does not currently have anywhere.\n\nI lean (a) on consistency grounds and distrust that lean, because it is the\nanswer my own rule produces and I am the one who wrote the rule.\n\nALSO, A CORRECTION TO MY OWN FRAMING, same source: the \"one rule\" is honestly\nTWO. click_cell does NOT do what Enter does in the grid — Enter is\nenter-advance, and a cell click moves the selection and delegates activation to\nthe page's inline editor, which is the pointer's `i` rather than its Enter. The\ncode has always done that; the commit message overstated by one widget. The\nhonest statement is: a click navigates to what was clicked, and activates it\nwhere activation is something the client can express.\n","created_at":"2026-09-20T20:23:17Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-hkr8","title":"A click that cannot compose its activation keeps the prefix and drops the payload","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated with a probe.\n\ncrates/improvise-protocol/src/resolve.rs:98 and :121 both do:\n\n commands.extend(filter_to_item_at(state, index).unwrap_or_default());\n commands.extend(tile_axis_at(state, index, None).unwrap_or_default());\n\nBoth callees return Option\u003cVec\u003cCommand\u003e\u003e and both None arms come from model(state, cmd)? -\u003e BasisCache::claim -\u003e self.current.as_ref()?, i.e. THE SERVER HAS NOT PUBLISHED A BASIS YET (gotcha 43: claim returns None until then; a record missing from the basis is filter_map'd away rather than failing, so this is the only None trigger).\n\nThe KEYBOARD handles the same None correctly: resolve() maps step(..) == None to KeyOutcome::Upstream — 'I cannot answer, ask the host'. The CLICK instead keeps the prefix and drops the payload.\n\nProbe on ClientState::default() (no basis), clicking an item row:\n\n [ SetPanelOpen { panel: Category, open: true },\n ChangeMode(CategoryPanel),\n SetPanelCursor { panel: Category, cursor: 1 } ]\n\nThe panel opens, focuses and the cursor moves — and the FILTER, which is the entire point of the click, is gone, with the frame reported as applied. Same for click_tile: you land in TILES mode and the axis never cycles.\n\nThis is gotcha 47's clause-4 failure (partial AND silent) reproduced one layer up, and it is worse than the keyboard's answer for the same input.\n\nREACHABILITY IS LOW and that is deliberate context, not a dismissal: the basis arrives in the bootstrap Snapshot and app.js folds a whole frame before repainting, so there is normally nothing painted to click before a basis exists. The reviewer could not construct a reachable browser path. AGENTS.md's rule applies — 'reachability is a reason not to rush a fix and not a reason not to notice one' — and the lost update CheckedBatch fixed had the same reachability argument and none of the documentation.\n\n## Steps to Reproduce\n\n1. Build a ClientState with no published basis (ClientState::default()).\n2. Call click_category on an item row index.\n3. Observe the navigation commands present and the filter absent.","acceptance_criteria":"A click is all of its intents or none: when the activation half cannot be composed (no published basis), click_category_row and click_tile emit nothing rather than the navigation prefix alone. Demonstrated by probing ClientState::default() and seeing an empty command list instead of SetPanelOpen/ChangeMode/SetPanelCursor without the filter.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:17:59Z","created_by":"orchestrator","updated_at":"2026-09-20T20:17:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7ybz","title":"The browser category panel has no pointer affordance at all","description":"The user reported 'I can't expand categories'. Expanding works fully by KEYBOARD (Shift-C, j/k, Enter -\u003e Command::ToggleCatExpand, resolved client-side in improvise-protocol/src/resolve.rs:598). What does not work is the MOUSE: web/app.js::categoryList builds plain \u003cli\u003e elements with no click listener, so clicking the collapsed triangle does nothing. Verified in Chromium: clicking the 'Product' row leaves the panel byte-identical.\n\nThis is the whole page's pointer story, not one widget: #grid cells get a click handler ONLY when cell.editable, so there is no click-to-select either, and the tile bar and view rows added by fix/web-ui-correctness are likewise keyboard-only.\n\nThe DECISION needed before coding: does the browser get a pointer interaction model at all, and if so what does a click mean? Clicking a category row plausibly means (a) move cat_panel_cursor there, (b) also focus the panel (enter CategoryPanel mode) if it is not focused, and (c) toggle expand if the row is a header. That is three commands from one click, and app.js must stay protocol-free - so the shape would be a wasm entry point (Client::click_category(index) -\u003e Option\u003cString\u003e) mirroring typed_cell, not JS composing Commands. Same question for a grid cell click and a tile click.\n\nNot filed as part of the fix because it is a product call, not a defect in an implemented feature.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:10Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:56Z","started_at":"2026-09-20T19:30:56Z","comments":[{"id":"89e3844a-2923-598b-87dc-5cddf6486dec","issue_id":"improvise-7ybz","author":"wave3-webui","text":"USER RULING on the tile click, recorded here because the reasoning is worth more than the behaviour: **a click SELECTS, a second click CYCLES.**\n\nThe first click on a tile moves the tile cursor and enters `TileSelect` without touching the document; cycling the axis needs a second click on the *already-selected* tile. `Enter`/`Space` keep cycling directly from the keyboard.\n\n**What it buys.** Every click in the UI is now non-mutating by default. That is a property of the whole pointer story, not a tile-specific concession, and the strongest version of the argument is a consequence the ruling was made without: a tile cycle is an `EffectKind::Model(LayoutGeometry)` change, so it bumps `App::cursor_epoch`, which `Sessions::adopt_cursor_epoch` broadcasts as `Command::ResetCursor` **to every connected client** (gotcha 24). A single stray click therefore used to cost every other viewer their cursor position. The pointer is the easiest input to trigger by accident, and it was wired to the one class of change that reaches other people.\n\n**What it cost, accepted knowingly.** Two clicks for the common action, and a **deliberate divergence from the keyboard**, where `Enter` cycles in one keystroke. Record it as a divergence chosen on purpose, not an inconsistency to tidy later — otherwise someone reads the keymap and \"fixes\" the pointer to match, which silently reinstates the broadcast-on-accident.\n\n**The implementation detail that is easy to get wrong.** \"Already selected\" must be `mode == TileSelect` **and** `tile_cat_idx == index`. The index alone is not enough: `tile_cat_idx` is 0 before anything has selected anything, so an index-only test reads tile 0 as already selected and cycles it on its very first click — the guarantee then holds for every tile but one. And \"second click\" must mean *on the already-selected tile*, not merely the second click of a session, or clicking A then B cycles B and the guarantee holds only for a session's first click. Both are asserted (`the_first_click_on_tile_zero_selects_it_like_any_other`, `clicking_a_different_tile_selects_it_rather_than_cycling_it`).\n\nEvidence discipline: the non-mutation is asserted **positively as well as negatively** — a test saying \"the axis did not change\" passes against a page where nothing happened at all. The fixture publishes a basis deliberately, so a missing `Command::Model` is a decision rather than a client that could not compose one.\n","created_at":"2026-09-20T20:41:14Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-nynk","title":"adopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped","description":"Found by the adversarial review of PR #49, demonstrated AND bisected. This is\nimprovise-jytm's regression, not PR #49's -- but #49 widens it.\n\nimprovise-jytm (#48) made \"which view a client is on\" per-client: apply_switch\nmoves only the asker's mirror. `Sessions::adopt_cursor_epoch`\n(ui/project.rs:904-912) was not updated and still resets EVERY mirror and returns\n`Broadcast::ToAll(Command::ResetCursor)`. So a reshape of view A throws away the\ncursor of a client sitting on view B, which nothing reshaped.\n\nTHE BISECT IS THE LOAD-BEARING PART:\n - at a3bef6e (main, post-#48) with HideItem -\u003e PASSES\n - at a3bef6e (main, post-#48) with TransposeAxes -\u003e FAILS\nSo the hole is already in main. What PR #49 (improvise-9mo9) changes is the\nNUMBER OF EFFECTS THAT REACH IT: from 2 (TransposeAxes, CycleAxis) to 12.\n\nTHE FIX IS SMALL AND THE TYPE ALREADY EXISTS. `Broadcast::To(SessionId, Command)`\nis there for exactly this shape (improvise-6c6q, improvise-jytm). adopt_cursor_epoch\nshould reset only the mirrors whose `active_view` is the view that reshaped, and\nreturn one `To(id, ResetCursor)` per affected client instead of one ToAll. Note\nthe return type becomes Vec\u003cBroadcast\u003e rather than Option\u003cBroadcast\u003e.\n\nWHY IT WAS NOT FIXED IN PR #49: changing ToAll -\u003e To for cursor invalidation is a\ndecision about broadcast semantics in a design that landed hours earlier, and\ngotcha 36 records a deliberate argument for ToAll here (\"a consequence the asker\nis owed like everybody else\"). That argument is still right for a client on the\nRESHAPED view and wrong for everyone else, which is a distinction #48 created and\nnobody has yet written down. It wants jytm's owner, not a review fix-up.\n\nimprovise-ni6k answers it for free: a per-view grid-frame fingerprint is\nper-view by construction.\n\n## Steps to Reproduce\n\n1. Two sessions on one document.\n2. Bystander sends Command::SwitchView(\"Second\"), then SetRowOffset(2).\n3. Asker sends ModelCommand::HideItem (or TransposeAxes, on main) against the\n host's \"Default\" view.\n4. The bystander's row_offset goes to 0 and the broadcast is\n [ToAll(Command::ResetCursor)] -- for a view the bystander is not on and that\n nothing reshaped.","acceptance_criteria":"adopt_cursor_epoch resets only the mirrors whose active_view is the view that reshaped, and addresses the relay per client with Broadcast::To rather than ToAll. The regression guard is a two-session test where the bystander is on a DIFFERENT view from the asker and keeps its offset -- and it must also keep the existing positive arm, where a bystander on the SAME view is still reset and still told, or 'never invalidate anyone' passes it. Note the bisect: this reproduces at a3bef6e with TransposeAxes, so it is not PR #49's to fix, but #49 takes it from 2 effects to 12.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:05Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T19:17:05Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pdvm","title":"AddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree","description":"Found in the adversarial review of PR #49 (improvise-9mo9). PRE-EXISTING, but\nthat PR is the first thing in the repo to produce evidence for it.\n\n`AddItem`, `AddItemInGroup` and `RemoveItem` declare\n`EffectKind::Model(ModelChange::CellData)`. `App::apply_effects` bumps\n`layout_version` only for `ModelChange::LayoutGeometry`, so these three do not\nmove it. Since improvise-9mo9 they DO call `app.reset_cursor()`, which bumps\n`cursor_epoch`.\n\nSo one event moves one counter and not the other, and the two counters are\nanswering the same question: \"has the grid's shape changed under you\". A client\nis told its cursor is invalid (`Command::ResetCursor`) while its `layout_version`\nbasis tier still reads current, so a batch composed against the old layout is\naccepted.\n\nTHE EVIDENCE IS THIS PR'S OWN TEST. `every_reshaping_model_command_invalidates_\nevery_cursor` now asserts, with a `grid_shape()` guard over `row_items`/\n`col_items`, that `AddItem`/`AddItemInGroup`/`RemoveItem` REALLY DO reshape the\ngrid. That is the assertion `ModelChange::CellData` contradicts.\n\nWHY IT IS NOT OBVIOUSLY \"RECLASSIFY THEM\". `EffectKind` is read by three\nconsumers off one classification (gotcha 40): `Dirty::of`, `layout_version`, and\n`model_state.dirty`. Moving these three to `LayoutGeometry` changes what every\nclient is told is dirty and widens the basis rejection surface. It also interacts\nwith gotcha 3's traps -- `AddCategory`/`RemoveCategory` are already the\n\"looks like data, is geometry\" case -- and with the `ModelCommand` derivation.\n\nRELATED, NOT DUPLICATE: improvise-75ld is the same family in the other direction\n(a refused or no-op AddCategory bumps layout_version and raises dirty when\nnothing happened). Decide them together; a single question -- \"what does an\nEffectKind entitle a counter to assume\" -- is under both.\n\n## Steps to Reproduce\n\n1. On a view with a category on Row, apply `ModelCommand::AddItem` for a new item\n in that category.\n2. `grid_shape()` (or `layout.row_count()`) changes: a row was inserted.\n3. `app.cursor_epoch()` has moved and clients receive `Command::ResetCursor`.\n4. `app.layout_version()` has NOT moved, because the effect declares CellData.\n5. A client may now compose a `Command::Model` whose `Basis.layout_version` is\n the pre-change value and have it accepted.","acceptance_criteria":"One event moves both counters or neither. Either AddItem/AddItemInGroup/RemoveItem are reclassified as LayoutGeometry -- in which case the blast radius through Dirty::of, the basis rejection surface and gotcha 3's traps is argued, not assumed -- or layout_version stops being read off EffectKind and is observed the way cursor_epoch and record_versions already are. Decide alongside improvise-75ld, which is the same question in the other direction, and alongside improvise-ni6k, whose grid-frame fingerprint would answer all three at once. The regression guard must assert the two counters agree over the same table every_reshaping_model_command_invalidates_every_cursor already drives, so it cannot pass by naming nothing.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:57:17Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:57:17Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:32Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it","description":"Found by improvise-i5tk's benchmark (`cargo bench --bench grid_projection`), which\nwas run to decide a *different* question and answered this one instead.\n\nMEASURED, on this machine, one `project::grid_of` call = one TUI frame's grid cache:\n\n typical 100 x 4 = 400 cells whole-grid 1.902ms col_widths 45%\n large 1200 x 8 = 9600 cells whole-grid 185.644ms col_widths 64%\n 18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms col_widths 42%\n pathological 9000 x 10 = 90000 cells whole-grid 5.235s col_widths 44%\n\nA 60fps frame is 16.7ms. At 18,000 cells -- the size gotcha 41 already treats as\nrealistic -- a TUI redraw takes about four tenths of a second, and the TUI redraws\non every keypress.\n\nWHY: the whole grid is evaluated roughly THREE TIMES per frame.\n\n1. `ui/viewport.rs::compute_col_widths` walks every (row, col) calling\n `layout.display_text`, to size the columns.\n2. The SAME function then walks every (row, col) AGAIN summing column totals, to\n size the total row.\n3. `project::grid_of` then evaluates the visible cells via `cell_display`, and\n `column_totals` sums every row again for each visible column.\n\nEvery one of those goes through `Model::evaluate_aggregated`, and every result is\nthrown away at the end of the frame.\n\nWHAT THIS IS NOT: it is not the viewport question. improvise-i5tk asked whether\nnarrowing the TUI's `GridCache` to its viewport would help, and the benchmark says\nit buys only 1.2-1.5x, because passes 1 and 2 are viewport-INDEPENDENT by\nconstruction -- `compute_col_widths` takes no viewport and a column total must sum\nevery row including scrolled-past ones (gotcha 44). So narrowing removes part of\none pass out of three. The fix here is caching or a single fused pass, not windowing.\n\n## Steps to Reproduce\n\n1. `cargo bench --bench grid_projection` on this branch.\n2. Read the `whole-grid` column: 402ms for 18,000 cells, against a 16.7ms frame.\n3. Read the `ratio` column: windowing the cache buys 1.5x, so the cost is not in\n the part a viewport would narrow.\n4. Read the `col_widths` column: one function, taking two whole-grid passes of its\n own, is 42-64% of the frame.","acceptance_criteria":"A frame's grid cache evaluates each cell at most once. The benchmark in benches/grid_projection.rs is the evidence both ways: it must show the whole-grid figure dropping by roughly the number of redundant passes removed, and it must still be run AFTER the change rather than the change argued from the structure. Note what the measurement already rules out: windowing the cache to a viewport is not the fix (1.2-1.5x measured, improvise-i5tk), because compute_col_widths takes no viewport and a column total must sum every row (gotcha 44). Do not lose the totals' whole-grid correctness while removing the whole-grid cost.","notes":"SECOND RUN 2026-09-20, same machine. The col_widths share is stable at 45-46%, not 42-64%: the single 64% in the first run's 'large' row was noise. whole-grid 1.9ms / 190ms / 327ms / 5.0s; ratio 1.2-1.5x throughout. The two figures the decision rests on -- ratio ~1.4x and col_widths ~half the frame -- reproduce; the per-row percentages do not to better than a few points. Quote ranges, not single runs.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:57Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T18:18:24Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:05:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lltt","title":"[bug] A view-scoped ModelCommand names no view, so it lands on the host's","description":"Opened by improvise-jytm, which made `ViewState::active_view` authoritative per\nclient. The view-configuration half of `ModelCommand` — SetAxis, CycleAxis,\nTransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty,\nSetNumberFormat — acts on \"the active view\", which is now an ambiguous phrase.\n\n`Sessions::apply_model` routes with no SessionId (gotcha 38, correctly: the change\nis addressed to no one session, because a `View` is document state shared by every\nclient sitting on it). But it must still name WHICH view, and today it names it\nimplicitly: `apply_command` runs the effect against the `App`, and the effect reads\n`App::view()`, which is the HOST's slice. So a connected client on another view\nmoves the host's view rather than its own.\n\nBefore jytm every client was on the same view, so the implicit answer was right.\nIt is now wrong exactly when two clients are on different views, which is the\ncapability jytm exists to add.\n\nThe honest fix is for those variants to name their view — a protocol change — so\nthe command carries the operand rather than borrowing an ambient one. Rejected\nduring jytm: retargeting `app.view_state.active_view` around `apply_command` at the\ndoor. It makes the host's own UI slice a parameter channel, and `reset_cursor`/\n`cursor_epoch` would then write the asker's key into the host's cursor map.\n\nNote the `_Drill`/`_Records` virtual views make this sharper than it looks: a host\nin records mode is on a view no client can name.\n\n## Steps to Reproduce\n\n1. Two clients, A and B, on one document with views \"Default\" and \"Second\".\n2. B sends `Command::SwitchView(\"Second\")`. Its mirror moves; the host's App stays\n on \"Default\" (correct, per improvise-jytm).\n3. B sends `Command::Model { cmd: ModelCommand::SetAxis { category: \"Region\",\n axis: Row } }`.\n4. The axis moves on \"Default\" — the view B is not on — and B's next projection\n shows nothing changed while A's shows a change A did not ask for.\n\nNot reachable from the TUI, which is the only client that drives the App's own\nslice, and not reachable from `web/app.js` today because it sends no SwitchView.\n","acceptance_criteria":"The view-configuration ModelCommand variants name the view they act on, so a connected client's SetAxis moves the view that client is on and not the host's. A test drives two sessions onto different views and asserts an axis change by one is invisible to the other. Deciding NOT to fix is also an acceptable close, but then gotcha 38 must say so.","notes":"THE TRIGGER, added after the adversarial review of PR #48 (2026-09-20). This is deferrable, but it is deferred on a CONDITION rather than on a judgement, and the condition is one line of somebody else's future work away. It is unreachable today ONLY because no shipped client emits Command::SwitchView. That is the whole of the argument. The browser already emits view-configuration model commands -- keys.rs resolves a keypress to ModelCommand::TransposeAxes locally and sends it -- so the machinery on the other side of the ambiguity is live. THE DAY ANY WAVE WIRES A VIEW SWITCH INTO THE BROWSER, THIS GOES LIVE ON THE NEXT TRANSPOSE KEYSTROKE, AND SILENTLY: a client on Second presses transpose, the axes of Default move instead, that client's grid does not change and every other client's does. Two consequences. (1) Do not close this by arguing it is unreachable -- reachability is the thing that is about to change, and gotcha 47 already records that 'unreachable today' was the argument under which the lost update CheckedBatch later fixed had shipped. (2) There is a cheap interim guard worth landing before the real fix: assert that no shipped browser client emits Command::SwitchView, a test over improvise-wasm-client's key resolver. It turns the silent failure into a loud one at exactly the moment the premise stops holding, which is the only moment anybody would otherwise not be looking.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:49Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:34:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"CORRECTION, same session: this bead as first written read as though nobody had\ndecided the Chromium-only question. Somebody had.\n`invariant(opfs_is_run_but_bindings_is_not)` in prolog-sessions/kb.pl records\nit, and gives TWO reasons, not one:\n\n \"harness/tests/shell.spec.js runs web/app.js under CHROMIUM ONLY --\n engine-independent logic, and routeWebSocket is slower across three.\"\n\nThe description above only answers the second. The first — the logic is\nengine-independent — is a general claim covering all six specs, and against\nthe specs as a test of web/app.js it is correct: app.js is plain DOM and\nplain event wiring, and running the same assertions on a second engine buys\nvery little.\n\nWHAT IT DOES NOT COVER is the claim improvise-bck criterion (1) actually\nmakes. That criterion is not \"app.js's logic works in Firefox\", it is \"the\nstandalone deployment works in Firefox\" — and the standalone deployment is\nmodule workers, `wasm-pack` output, `createSyncAccessHandle` through\nbindings.rs and the seed fetch, none of which is app.js's logic and all of\nwhich is engine-specific. opfs.spec.js covers Firefox's OPFS through\n`OpfsFs` directly and, per the same invariant, NEVER INSTANTIATES\nbindings.rs. So the two standalone specs on Firefox would be the first\nexecution of that shim on a second engine, and near enough the first on any.\n\nSo the disagreement with the invariant is narrow and should be argued on\nthose terms: the engine-independence reason is a statement about app.js, and\nthe two standalone specs are not only a test of app.js. If that argument is\nrejected, the right outcome is to REWRITE improvise-bck criterion (1) to say\nChromium, not to leave a criterion standing that names an engine nothing\nruns on — a criterion nobody checks is the failure mode this whole pass\nexists to catch.\n\nEither way the invariant's wording is owed an update, since it currently\nreads as a blanket justification for a split that has two different reasons\nbehind its two halves.\nCORRECTION — MY OWN PREMISE WAS WRONG, found in adversarial review of PR #47.\n\nThe note above says the two standalone specs on Firefox \"would be the first\nexecution of that shim on a second engine, and near enough the first on any\".\nThe second half is false. `bindings.rs` HAS been executed, on Chromium, since\nimprovise-vvl3 landed the harness. The chain is unbroken and each link is\ncheckable:\n\n /app/index.html with no ?ws= -\u003e app.js `new Worker(\"./worker.js\")`\n worker.js `import { start } from \"./worker-pkg/improvise_worker_server.js\"`\n bindings.rs `pub async fn start()` -\u003e `boot()` -\u003e `OpfsFs::open().await`\n\nand `harness/tests/shell.spec.js`'s own comment on the standalone test has said\nso all along: \"If this draws a grid, worker.js, bindings.rs, OpfsFs and the seed\nfetch all ran.\"\n\nI RAN THAT SPEC GREEN AT 2ccd21f while verifying the /app/ move, and watched the\nbrowser fetch /app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm\nand draw the grid with #status reading \"in-page worker\". The evidence was in my\nhand and I still repeated the claim, because I took it from kb.pl and the repo\nmap instead of from the run.\n\nWHAT SURVIVES: the argument for running the two standalone specs on Firefox is\nUNAFFECTED and is still the point of this ticket. Firefox would be a genuine\nsecond engine for the shim, and improvise-bck's criterion (1) still names an\nengine nothing runs. Only the \"first on any engine\" rhetoric goes.\n\nFIVE PLACES STATE THE FALSE VERSION and want one grep, not five edits:\nkb.pl:11, kb.pl:105 (invariant opfs_is_run_but_bindings_is_not), repo-map.md:241,\nrepo-map.md:467, and improvise-bck's own NOT-DEMONSTRATED list. Those three files\nare orchestrator-owned; the bck half is corrected in bck's notes.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:19:44Z","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"Three PRs merged within 20 seconds on 2026-09-20 (#37, #41, #42). Each CI run built the site and uploaded a github-pages artifact, and each queued a deploy behind 'concurrency: group: pages, cancel-in-progress: false'.\n\nOnly the newest run kept its artifact:\n\n run 35526758561 (04e4e3d, #37) conclusion=failure artifacts=0\n run 35526767814 (5f871f7, #41) conclusion=success artifacts=1\n run 35526774007 (52507a6, #42) conclusion=failure artifacts=0\n\nThe two superseded deploys failed with:\n\n Fetching artifact metadata for \"github-pages\" in this workflow run\n Found 0 artifact(s)\n Error: No artifacts named \"github-pages\" were found for this workflow run.\n\nThe upload step was GREEN in all three runs, so this is not a build failure —\nthe artifact is gone by the time the losing deploy runs. The successful deploy\n(17:47:28Z) preceded the failing one (17:47:44Z) by 16 seconds.\n\nDeploying a superseded commit is pointless anyway: the tip of main is what\nshould be published. Cancelling is deliberately NOT the answer — the existing\ncomment on the concurrency block records that a deploy-pages cancelled midway\ncan leave the Pages deployment half-applied.\n\n## Steps to Reproduce\n\n1. Merge three PRs to main within about a minute.\n2. Watch the CI runs: the newest deploy succeeds, the older ones fail on an\n empty artifact fetch and mark otherwise-green runs as failed.\n\nReported by the user from the Actions log.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-20T18:03:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:55Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} @@ -56,12 +65,12 @@ {"_type":"issue","id":"improvise-o8fa","title":"improvise-ws-server speaks ws:// only, so the deployed page cannot reach it","description":"Found by opening the deployed page (2026-09-20). The guidance the page and the README carried was WRONG in a way that sends someone down a dead end.\n\nFROM AN https ORIGIN A ws:// ADDRESS IS BLOCKED AS MIXED CONTENT, so the deployed GitHub Pages site needs wss://. improvise-ws-server has NO TLS AT ALL -- no rustls, no native-tls, nothing in the crate or its dependencies -- so wss:// is not obtainable from it. The text said \"note that from an https origin a ws:// address is blocked\", which correctly named the symptom and then offered an address that can never work.\n\nTEXT CORRECTED ALREADY: both web/index.html and README.md now say the address must be wss:// from the deployed page, that the server speaks plain ws:// only, and that the ws:// form works when the page is served over http://localhost. This ticket is the missing CAPABILITY.\n\nTHREE WAYS TO PROVIDE IT, and they are not equivalent:\n * TERMINATE TLS IN FRONT (nginx, caddy, cloudflared, ngrok). Zero code, works today, and is what the corrected text points at. But it means the documented path to sharing a TUI-hosted document involves a second piece of software, which is a poor answer for a tool whose selling point is that it is one binary.\n * NATIVE wss:// IN improvise-ws-server -- rustls plus a cert. Real work, and it raises the certificate question: a self-signed cert makes the browser refuse the socket with no usable error, so this is only pleasant with a real cert or an explicitly trusted local CA (mkcert).\n * DECIDE THE DEPLOYED PAGE DOES NOT PAIR WITH A LOCAL SERVER at all, and say so. Defensible: the standalone worker deployment is the point of the Pages site, and ws:// pairing is a LOCAL development convenience that works fine from the local page. Cheapest, and it makes the docs shorter rather than longer.\n\nThe third is probably right for now and the first is the escape hatch; the second only earns its place if sharing a TUI-hosted document to a remote browser becomes a real use case. Do not implement the second by default.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:53:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:22:10Z","started_at":"2026-09-20T05:32:35Z","closed_at":"2026-09-20T06:22:10Z","close_reason":"Closed by PR #21 (WU-I). DOCUMENTATION ONLY, per the user's ruling; native wss:// DECLINED.\n\nTHE DECLINE'S SUBSTANCE IS THE CERTIFICATE, NOT rustls. A self-signed cert makes the browser refuse the socket with no usable error, because a WebSocket handshake has no interstitial -- there is no place to click through. A real cert or mkcert is a second piece of software too, so the choice is two-either-way, with one option additionally shipping a TLS stack and a cert-loading surface in a pivot-table binary.\n\nWHAT LANDED: a worked Caddy example, the nginx difference that bites (proxy_http_version 1.1 plus the Upgrade/Connection headers), the honest cost (a second binary, a poor answer for a one-binary tool), and the framing that the standalone worker deployment is the point of the Pages site while ws:// pairing works fine from a local http://localhost page.\n\nTHIS BEAD COST SEVEN COPILOT ROUNDS AND NINE FINDINGS, EVERY ONE IN PROSE. The sequence is the value and is recorded in kb.pl:\n * the first recipe exposed an unauthenticated world-writable server -- documenting a proxy documents an exposure, because TLS termination REMOVES the loopback binding that was the only control;\n * the \"fix\" put Caddy basic_auth in front of a socket the page opens as new WebSocket(url), an API that can set no headers over a handshake that shows no prompt -- BROKEN reads as protection, which is worse than absent;\n * an ngrok --cidr-allow recipe was written without being run (deprecated-but-accepted, so the reviewer's own \"fails at startup\" was also wrong);\n * \"the browser cannot send a credential on a WebSocket handshake\" over-generalised the governing fact and contradicted the Cloudflare Access bullet three paragraphs up, which works BECAUSE the handshake carries a cookie;\n * an IP allow-list cannot see a cross-site socket at all, which is improvise-kc7n;\n * an absolute loopback claim denied a browser mitigation that exists but is mid-rollout;\n * and \"the auth above\" called an allow-list authentication in a section whose opening sentence is that the server has none.\n\nThe governing fact was ALREADY IN THE SAME FILE at every step, as the basis of this very decline. The lesson the repo did not have: a reviewed paragraph is not evidence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-955n","title":"[bug] Host::commands applies a batch partially before rejecting","description":"Found by Copilot on PR #13 (the retro review of the unreviewed batch), DECLINED there with reasons on the thread, and filed rather than patched.\n\nADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a TYPE (Checked) to make the unchecked path unconstructible. But Checked is PER-COMMAND: Checked::against answers for one Command against the App AS IT STANDS. A ClientFrame::Commands is a SEQUENCE of them, and Host::commands applies in order and abandons at the first rejection. So a frame carrying SwitchView followed by a stale Command::Model has already applied the SwitchView.\n\nSO ADR 001 GUARANTEE IS CURRENTLY TRUE OF A SINGLE COMMAND AND FALSE OF A BATCH.\n\nA SECOND-ORDER EFFECT NOBODY HAD WRITTEN DOWN, found while declining it: Worker::on_frame skips persist() when ANY frame in the answer is Rejected. So a batch that DID apply a SwitchView before being refused leaves Workbook::active_view moved in memory and NOT WRITTEN BACK -- the document on disk and the document in the worker disagree, silently.\n\nWHY IT WAS DECLINED RATHER THAN PATCHED: closing it means the basis check moving from the COMMAND to the BATCH -- every command answered before any is applied. That is an ADR 001 design change with a real question inside it: WHAT DOES A BATCH BASIS MEAN WHEN ITS LATER COMMANDS WERE COMPOSED AGAINST ITS EARLIER ONES EFFECTS? A client resolving one keypress produces SetSelected plus offsets computed from the selection it just set. Answer that before writing code.\n\nREACHABILITY IS BOUNDED TODAY: no client in this workspace composes a batch containing more than one basis-carrying Command::Model -- the batches improvise-bpq2 produces are view commands, which carry no basis. Do this before the second tranche of client-side resolution creates a reason to batch two model commands.\n\nSupersedes the earlier ticket filed from bpq2 if there is one -- dedupe.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T03:35:27Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:48Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:48Z","close_reason":"Closed by PR #26 (WU-C). improvise-08wb is closed as a duplicate of this one.\n\nADR 001 said a stale batch is never applied, NOT EVEN PARTIALLY, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible -- but Checked is PER-COMMAND and a ClientFrame::Commands is a sequence of them, so Host::commands applied in order and abandoned at the first rejection. The guarantee was true of a single command and false of a batch.\n\nCheckedBatch::against now answers for EVERY command before any is applied.\n\nTHE DESIGN QUESTION THE TICKET DEMANDED WAS ANSWERED BY THE USER, not by the code, and it is larger than the ticket imagined. The ticket asked \"what does a batch's basis mean when its later commands were composed against its earlier ones' effects\". The user's ruling: \"a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs\", as \"a single transaction from a given base\". Four clauses -- one basis per frame; checked once before anything; applied in order with data dependence EXPECTED; all or nothing. Clauses 1-3 landed here; clause 4 (rollback) is improvise-yqpd.\n\nAN EARLIER \"at most one document mutation per frame\" RULE WAS SHIPPED AND THEN REJECTED BY THE USER as forbidding the capability. It was wrong twice over: it forbids data-dependent chains, and arity is not a property of a variant anyway -- a future Command::RunScript is one variant performing unboundedly many mutations.\n\nTHE FINDING THAT MATTERED MOST came from the independent adversarial review that substituted for Copilot (quota exhausted), and it was a REGRESSION THIS WORK INTRODUCED: hoisting the basis check to the frame hoisted ALL THREE TIERS. The document and layout tiers are legitimately frame-level facts; THE RECORD TIER IS NOT AND CANNOT BE, because which records are at stake is a property of the command. So every mutation after the first had its records checked against nothing -- a silent lost update, confirmed by executing a real Host: client A's stale [SetCell(North), SetCell(South)] clobbered client B's write to South with no rejection, where the base commit refused it. Fixed: every mutation answers for its own records against the FRAME'S basis, with an exemption stated over a property -- a record that neither resolves nor is claimed is one the frame is creating. The reviewer attacked that exemption at all four corners and could not break it: the predicate is a conjunction and the client controls only one conjunct.\n\nTHE SECOND-ORDER WORKER EFFECT the ticket named is closed twice over; its test fails only when BOTH old behaviours are restored.\n\nA CAPABILITY NOBODY HAD NAMED, now guarded: a frame may write THE SAME RECORD TWICE -- two measures on one record IS one record -- and that works only because every check happens before any apply. Moving the checks into the loop breaks it, which is what stops someone closing clause 4 by taking that trade unnoticed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-08wb","title":"[bug] A refused ClientFrame::Commands keeps the commands before the refusal","description":"Found by improvise-bpq2 (2026-09-20), stated on the variant, and currently UNREACHABLE from any client in this workspace -- filed so it is a decision rather than a discovery.\n\nClientFrame::Commands(Vec\u003cCommand\u003e) batches, because one cursor key resolved client-side is SetSelected plus the offsets viewport_effects returns, and a host that folds the selection but not the offset projects the wrong window. Host::commands applies IN ORDER and ABANDONS AT THE FIRST REJECTION.\n\nTHAT LEAVES A HOLE ADR 001 DOES NOT HAVE. ADR 001 is emphatic that a stale batch is never applied, NOT EVEN PARTIALLY -- and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per-COMMAND, and a ClientFrame::Commands is a sequence of them, so a frame whose third command is refused has already applied the first two.\n\nWHY IT IS NOT LIVE: no client in this workspace composes a Commands frame containing more than one basis-carrying Command::Model -- the batches bpq2 produces are view commands (SetSelected + offsets), which carry no basis.\n\nTHE FIX IS TO MOVE THE BASIS CHECK TO THE BATCH: check every Command::Model in the frame before applying any of them. That is the shape ADR 001 already describes for an effect batch, one level up, and it would make Host::commands match the guarantee the ADR states.\n\nDo this before a client has reason to batch two model commands -- which the second tranche of client-side resolution (the ~15 bindings bpq2 left) may well create.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:36:42Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:29:28Z","started_at":"2026-09-20T05:32:29Z","closed_at":"2026-09-20T07:29:28Z","close_reason":"Closed as a DUPLICATE of improvise-955n, which PR #26 fixed. Both beads describe the same defect -- Host::commands applied a ClientFrame::Commands in order and abandoned at the first rejection, so a frame whose later command was refused had already applied the earlier ones, leaving ADR 001's 'never applied, not even partially' true of a single command and false of a batch. 08wb was filed from improvise-bpq2's walk and 955n from Copilot's retro review of PR #13; 955n carries the fuller reasoning and the second-order worker effect, so it is the survivor.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:53:19Z","started_at":"2026-09-20T17:53:19Z","lease_expires_at":"2026-09-20T17:58:19Z","heartbeat_at":"2026-09-20T17:53:19Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ir7l","title":"[bug] A formula cell is offered as editable and silently reverts","description":"Pre-existing and consistent with the TUI, but improvise-bck just put it on the DEMO FRONT PAGE, which changes who meets it: the first thing a stranger sees is Profit beside Revenue and Cost.\n\nA formula target cell projects CellId::Record, so render.rs::editable says true, so the browser offers an editor. Type into Profit and the write lands -- then recompute_formulas overwrites it on the next pass and the value REVERTS with no explanation.\n\nWHY IT IS NOT SIMPLY A BUG IN editable: a formula target IS a stored coordinate; the cell is a record. What makes it unwritable is not its identity but that a FORMULA OWNS IT. So the fix is a fact the projection does not currently carry -- something like \"this cell is derived\" -- and it belongs beside numeric/error on CellDisplay, which already exists precisely because the SERVER is the side that knows.\n\nCHECK THE TUI BEFORE CHANGING ANYTHING: it has the same behaviour, so whatever is decided should land in both, or the divergence becomes the new bug (gotcha 44 is what that looks like). And note ADR 002 deliberately made settability a fact about the CellId VARIANT -- adding a second, independent \"but not really\" flag is exactly the two-answers-to-one-question shape this codebase keeps curing. Consider instead whether a formula-owned cell should project a different CellId at all.\n\n## Steps to Reproduce\n\n1. Open `web/demo.improv` in the browser — it ships `Profit = Revenue - Cost` beside Revenue and Cost on the front page.\n2. Click the Profit cell. It is offered as editable, because a formula target projects `CellId::Record` and `render.rs::editable` reads the variant.\n3. Type a number and commit. The write lands.\n4. `recompute_formulas` overwrites it on the next pass and the value reverts, with no explanation.\n\nThe TUI behaves the same way, so whatever is decided must land in both or the divergence becomes the new bug.\n","acceptance_criteria":"A formula-owned cell projects a non-Record CellId, so the write is unconstructible rather than flagged — the discipline ADR 002 used for aggregates and ADR 003 reaches for again for read-only sources. It lands in the TUI in the same change, or the two front ends diverge (gotcha 44 is what that looks like). Do NOT add a second 'but not really' flag beside CellId: two answers to 'can I write here' is the disease. Demonstrated on web/demo.improv, where Profit sits beside Revenue and Cost on the front page.","notes":"VERIFIED CONCRETELY during the PR #13 review (2026-09-20), and the diagnosis is sharper than when this was filed.\n\nTHE ROOT CAUSE: project::cell_id decides Record vs Aggregate purely on whether a regular category sits on Axis::None. IT NEVER ASKS WHETHER THE _Measure COORDINATE NAMES A FORMULA TARGET. So a formula cell is a Record, render.rs::editable says true, and the browser offers an editor.\n\nMEASURED AGAINST THE SHIPPED DEMO:\n col 2 label=\"Profit\" is_record=true text=\"12,200\"\n after applying SetCell(key, Number(999.0)), Profit reads \"12,200\"\n\nThe write is ACCEPTED AND STORED. Model::evaluate_aggregated checks formula_cache BEFORE raw data (model/types.rs:409), so the typed value is shadowed forever with nothing telling the user. Not a revert -- a permanent silent shadow.\n\nAND THE TEST THAT COVERS THIS AREA IS LYING BY ITS NAME. every_visible_cell_in_the_web_demo_is_settable PASSES while its name is FALSE for the Profit column -- a third of the landing-page grid. It was proved discriminating against the one thing it CAN see (the aggregate at (0,0) in examples/demo.improv) and is structurally blind to this. That is exactly the shape AGENTS.md calls this repo's characteristic find, arriving in the test written to prevent it.\n\nTHE DECISION IS STILL THE DECISION, and ADR 002 is where it goes:\n * TEACH cell_id ABOUT FORMULA TARGETS -- changes what EVERY client calls a record, and record identity is also ADR 001's unit of atomicity, so this reaches the basis check too.\n * OR GIVE editable A SECOND INPUT -- but that is the conflation gotcha 42 exists to forbid, and ADR 002 deliberately made settability a fact about the CellId VARIANT precisely so there could not be a second answer.\n\nThe first is probably right and is not small. Until it lands, the demo's front page teaches a visitor that typing into Profit does nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-20T02:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:48Z","started_at":"2026-09-20T17:53:19Z","closed_at":"2026-09-20T18:22:48Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vhcz","title":"[bug] OpfsFs::list disagrees with the other backends on path shape","description":"Found by improvise-9x6 (2026-09-20) and currently PAPERED OVER by always passing the flat root.\n\nOpfsFs::list returns BARE NAMES; MemoryFs and PhysicalFs return dir-JOINED ones. So Path::new(\"\") is the ONE dir at which all three agree, and the only one at which storage_contract actually describes OpfsFs.\n\nTHE TRAP: a worker that organised documents under docs/ would get an EMPTY PICKER in a browser and a FULL ONE in every test. The harness cannot see the disagreement because it never runs OpfsFs -- improvise-xf1 is deferred -- so this is precisely the class of defect the missing browser harness exists to catch, discovered by reading instead.\n\nTWO FIXES:\n * Make OpfsFs::list join dir, matching the other two. Straightforward, and makes storage_contract describe all three at every dir.\n * Or document the flat root as PART OF THE CONTRACT and assert it in storage_contract, so the constraint is stated rather than discovered.\n\nThe first is better unless there is a reason OPFS cannot -- note gotcha 28 says backends that are not trees treat dir as a NAME PREFIX, which is already the trait doc contemplating this, so check whether the doc or the impl is the thing that is wrong.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:14Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:42Z","started_at":"2026-09-20T05:32:36Z","closed_at":"2026-09-20T06:44:42Z","close_reason":"Closed by PR #24 (WU-J). THE TICKET ASKED WHICH OF THE DOC OR THE IMPL WAS WRONG: IT WAS THE IMPL.\n\nThe Storage trait doc already said that backends which are not trees treat `dir` as a NAME PREFIX. OpfsFs::list ignored `dir` outbound as well as inbound, returning bare names where MemoryFs and PhysicalFs return dir-joined ones -- so Path::new(\"\") was the one dir at which all three agreed, and the only one at which storage_contract actually described OpfsFs.\n\nNow it joins. The three backends agree at EVERY dir, and storage_contract runs against OpfsFs at both \"\" and \"docs\" in a real browser. `dir` stays DECORATION rather than a filter, because entry_name reduces every path to its file name and an OPFS entry name may not contain a separator.\n\nFAULT INJECTED: reverting to bare names fails storage_contract_at_\"docs\" on `lists the one document`.\n\nTHE TRAP THE TICKET NAMED IS CLOSED: a worker that organised documents under docs/ would have got an empty picker in a browser and a full one in every test. That divergence was invisible to the harness because nothing ran OpfsFs -- which is exactly why improvise-vvl3 had to land alongside this, and why this bead could only ever have been found by reading until it did.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmws","title":"Storage cannot expose an async seam through Box\u003cdyn Storage\u003e","description":"Found by improvise-9x6 (2026-09-20). Every worker host will hit this, so it is worth solving once rather than in each.\n\nOpfsFs::open / attach / refresh / commit_deletes are on the STRUCT, not on the Storage trait -- deliberately, per gotcha 28: Storage stays synchronous because createSyncAccessHandle yields a blocking handle, and only four operations are genuinely async. But App::storage is a Box\u003cdyn Storage\u003e, so HANDING THE BACKEND TO THE App PUTS ALL FOUR PERMANENTLY OUT OF REACH. The App owns the only copy and the trait object cannot name them.\n\nAND THE OBVIOUS FIX IS REFUSED BY THE COMPILER: impl Storage for Rc\u003cOpfsFs\u003e is two foreign types, so the orphan rule forbids it. improvise-9x6 worked around it with a local newtype (bindings::Shared) holding an Rc\u003cOpfsFs\u003e and forwarding the four sync methods. That works but every worker must write it again.\n\nTWO CANDIDATE FIXES:\n * Ship the shared wrapper FROM improvise-io, so the newtype exists once where OpfsFs does and the orphan rule is satisfied.\n * Or have OpfsFs hand out an attachable handle -- separate the thing the App holds from the thing the message loop awaits, which is arguably the more honest modelling since they have different lifetimes and different call sites.\n\nPrefer whichever keeps Storage synchronous; that part is not negotiable (gotcha 28 is the argument).","status":"closed","priority":2,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T02:10:13Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:44:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T06:44:43Z","close_reason":"Closed by PR #24 (WU-J). NEITHER OF THE TICKET'S TWO OPTIONS WAS NEEDED, and the reason is worth more than the fix.\n\nThe ticket said `impl Storage for Rc\u003cOpfsFs\u003e` is refused by the orphan rule. That is true IN improvise-worker-server, where both the trait and Rc are foreign -- which is where improvise-9x6 hit it and wrote bindings::Shared. It is NOT true in improvise-io, where the trait is LOCAL and the impl is simply allowed.\n\nSo: improvise-io ships a blanket `impl\u003cT: Storage + ?Sized\u003e Storage for Rc\u003cT\u003e`. bindings::Shared and its _shim_is_a_storage canary are DELETED, not moved, and no future worker writes one. Storage stays synchronous, which gotcha 28 says is not negotiable.\n\nTHE GUARD IS THE SIGNATURE: a_shared_backend_is_a_backend_and_a_clone_is_the_same_one does not compile without the impl. The body asserts what a hand-written newtype could still get wrong -- that a clone addresses the same backend -- and a fault injected into the forwarded `write` (made a no-op) fails it.\n\nTHE GENERALISABLE PART: an orphan-rule refusal is a fact about WHERE YOU ARE STANDING, not about the pair of types. Before working around one, check whether the impl belongs in the crate that owns the trait.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9mo9","title":"[bug] Geometry effects that renumber the grid but never reset the cursor","description":"Found by the code review of improvise-gvnb (2026-09-19). A PRE-EXISTING gap that gvnb inherited rather than introduced -- but gvnb is what makes it matter, because the epoch is now what tells every client their cursor is invalid.\n\nApp::reset_cursor bumps cursor_epoch; Sessions::adopt_cursor_epoch watches the COUNTER and broadcasts Command::ResetCursor to all. Nothing declares which effects renumber the grid -- deliberately, per gotcha 31 -- which means a geometry effect that forgets reset_cursor is INVISIBLE IN BOTH DIRECTIONS: the host own cursor stays stale AND no client is told.\n\nToday only TransposeAxes and CycleAxis call it. These also renumber rows or columns and do not:\n SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory\n (plus AddItem/RemoveItem, which are CellData rather than LayoutGeometry)\n\nSetAxis IS THE SHARPEST CASE: CycleAxis is literally SetAxis to Axis::next() and resets; SetAxis does not.\n\nWHY THE REVIEW DID NOT JUST PATCH IT: SetAxis is used by DrillIntoCell (cmd/grid.rs:365-471) to build the _Drill view, and resetting there could discard the user position in the view they drilled FROM, depending on effect order. That is a decision about effect ordering and intended UX, not a patch.\n\nKEEP THE NEGATIVE ARM. a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat -- a LayoutGeometry effect that must NOT reset. Without it, \"reset on every model command\" would pass the positive test. Whatever you do here, do not lose it.\n\n## Steps to Reproduce\n\n1. In a view with a cursor somewhere other than the origin, apply any of `SetAxis`, `HideItem`, `ShowItem`, `ToggleGroup`, `TogglePruneEmpty`, `AddCategory`, `RemoveCategory`, `AddItem` or `RemoveItem`.\n2. The grid renumbers and the cursor is not reset, so it addresses a position that has moved.\n3. Worse for other clients: `reset_cursor` is what bumps `cursor_epoch`, and `Sessions::adopt_cursor_epoch` watches that counter to broadcast `Command::ResetCursor`. No reset means **no client is told either**.\n\n`SetAxis` is the sharpest case: `CycleAxis` *is* `SetAxis` to `Axis::next()` and does reset.\n","acceptance_criteria":"Each of SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem and RemoveItem either calls reset_cursor or carries a one-line reason why it does not. SetAxis is the sharp case — CycleAxis IS SetAxis to Axis::next() and resets, while SetAxis does not — and DrillIntoCell uses SetAxis to build the _Drill view, where resetting could discard the position in the view drilled FROM, so that is a decision about effect ordering and intended UX, not a patch. KEEP THE NEGATIVE ARM: a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone uses SetNumberFormat, and without it 'reset on every model command' passes the positive test. Note improvise-2zq changed which axis a new category lands on, so re-read on_category_added before assuming AddCategory renumbers the way it used to.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-09-20T01:05:15Z","created_by":"Edward Langley","updated_at":"2026-09-20T17:57:44Z","started_at":"2026-09-20T17:53:45Z","closed_at":"2026-09-20T17:57:44Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s1xw","title":"Multi-level grid labels on the wire -- the browser shows Food/Alice where the TUI shows two columns","description":"THE LAST THING KEEPING GridLayout INSIDE GridWidget, and a LIVE DIVERGENCE today, not a hypothetical. Found closing improvise-n10.\n\nGridCache::row_labels / col_labels are Vec\u003cString\u003e produced by GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH A SLASH. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear (\"slash-joined labels should be gone\"). So the protocol carries a form of the labels THE TUI ITSELF CONSIDERS WRONG, and crates/improvise-wasm-client/src/render.rs renders precisely that form.\n\nConsequence: the browser grid has no multi-level headers, no repeat suppression, no groups and no collapse indicators, and shows Food/Alice where the TUI shows two header columns.\n\nSIX FACTS THE CACHE DOES NOT CARRY (exhaustive as of improvise-n10, by grep over the non-test body of src/ui/grid.rs):\n 1. number of header levels -- layout.row_cats / col_cats\n 2. multi-level label TUPLES -- layout.row_items (AxisEntry::DataItem(Vec\u003cString\u003e))\n 3. inline GROUP HEADER rows -- layout.row_items (AxisEntry::GroupHeader)\n 4. column GROUP SPANS -- layout.col_group_for(ci)\n 5. collapse indicator (down/right triangle) -- view.is_group_collapsed(cat, group)\n 6. records-mode column identity -- layout.record_col(ci). THIS ONE IS CORRECT TO KEEP OFF: it keys pending drill edits and is session-local by design.\n\nSo 1-5 are the gap.\n\nTWO CONSTRAINTS ON THE FIX:\n * IT IS A CO-ORDINATED CHANGE, NOT AN ADDITIVE ONE. row_labels/col_labels change TYPE, and improvise-wasm-client/src/render.rs consumes both fields, so the client updates in the same change.\n * DO NOT ADD row_levels BESIDE row_labels. Two answers to \"what are the labels\" is the disease this whole projection architecture exists to cure -- see improvise-edp, where the category panel was walked twice, two different ways.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:41:34Z","started_at":"2026-09-20T05:32:27Z","closed_at":"2026-09-20T07:41:34Z","close_reason":"Closed as ABSORBED BY improvise-anr9, verified explicitly rather than assumed. s1xw enumerated six facts the cache did not carry; PR #23 landed 1-5 (header levels, multi-level label tuples, inline group headers, column group spans, the collapse indicator) and 6 is record_col, which s1xw itself says is CORRECT to keep off -- session-local, keys pending drill edits. Both of its stated constraints were honoured: it was a co-ordinated type change (row_labels/col_labels changed TYPE and improvise-wasm-client/src/render.rs updated in the same change) and no row_levels was added beside row_labels. The live divergence it reported -- the browser drawing Food/Alice where the TUI draws two header columns -- is closed, and tests/grid_frame_agreement.rs now drives both front ends over one cache and compares what each draws.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"open","priority":2,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:26Z","started_at":"2026-09-20T05:32:41Z","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zl7","title":"A fitted tree must route per record and aggregate the results, not route on the aggregate","description":"Successor to improvise-8xhy, which shipped remedy 1 (refuse at emit) as an explicit interim. This is the remedy that produces a formula A HUMAN WOULD HAVE WRITTEN, and it is the right end state -- but it was costed during 8xhy and it is much larger than it looks.\n\nANSWER THIS FIRST, BEFORE TOUCHING THE GRAMMAR. A fitted measure cell is evaluated at a CellKey context. In any view that aggregates over the split category -- Region on Page or None -- context.get(\"Region\") is None, and today Formula::filter falls straight through to aggregate_raw. So a categorical fit would SILENTLY DEGRADE to something else in exactly the views a pivot-table user spends most of their time in. That is a product decision about what a fit MEANS for an aggregated cell, not an emitter detail.\n\nSCOPE, once that is answered:\n * Expr::Is(Filter) evaluating to 1.0/0.0. This keeps the f64-only evaluator and avoids introducing string comparison into eval_expr. The primitive already exists in the right shape -- types.rs reads context.get(\u0026filter.category), the current cell own coordinate.\n * A grammar production plus tokenizer handling in the 1109-line parser.rs, with quoting rules for the category and item inside it (gotcha 34 -- one home, improvise_formula::quote).\n * Arms in BOTH evaluators: eval_expr and eval_expr_cached.\n * Coverage in the grammar-walking generator.\n * Thread Dataset::columns (the ColumnSpec/ColumnKind plan) into the emitter, so feature index -\u003e role is read off preprocess own plan. Do NOT reconstruct it: Spec::names is \u0026[\u0026str], and reconstructing index-\u003erole is a second implementation of preprocess expansion order, exactly the drift risk gotcha 41 names in another key.\n * .improv is free -- the writer emits Formula::raw.\n\nIT CLOSES ONLY A THIRD OF 8xhy EITHER WAY. A WHERE predicate covers the one-hots. col.missing means \"this record has no value for measure X\", a different vocabulary. The date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused, or get their own tickets.\n\nPAIRS WITH improvise-vx3 (the fit-measure command), which is where a user would actually learn about the restriction.","acceptance_criteria":"A fitted measure evaluated at an aggregating cell routes PER RECORD and aggregates the results, rather than routing on the aggregate. fit-measure on web/demo.improv succeeds and the resulting formula means the same thing on a record row and one row up. The seed-dependence is gone: whether the command succeeds no longer depends on which features the search happened to pick. Note what this costs — evaluate_aggregated deliberately does not iterate the records behind a formula cell — and state how it interacts with gotcha 9's fixed point and Model::formula_cache. col.missing and the date sub-features remain out of scope.","notes":"USER RULING 2026-09-20: AGGREGATE THE RULE OVER THE MEMBERS. Route-then-aggregate, per record. The other three options -- Error(\"aggregated\"), refuse at fit time, and fall through to aggregate_raw -- are all rejected.\n\nTHE BEAD'S SCOPE HAS CHANGED AND THE TITLE NOW SAYS SO. It asked what a CATEGORICAL split means in a cell that aggregates over the split category. PR #25's review established the question has nothing to do with the split being categorical:\n\nA tree is fitted on individual RECORDS. A formula is evaluated at a CellKey, and in any aggregating cell evaluate_aggregated aggregates each Expr::Ref FIRST and then evaluates the IF. A linear formula survives that -- SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) -- and a tree does not, because AGGREGATE-THEN-ROUTE IS NOT ROUTE-THEN-AGGREGATE. So EVERY fitted tree, numeric splits included, means one thing on a record row and something else one row up.\n\nIT IS NOT THEORETICAL: fit-measure Revenue on the repo's own shipped web/demo.improv is refused, naming Product=Espresso/Product=Latte. And because which features a champion picks is a property of the SEARCH, on a document with several categories the same command refuses or succeeds DEPENDING ON THE SEED. That is not a contract.\n\nWHAT THE RULING COSTS, stated plainly because it is the expensive answer and was chosen anyway: evaluate_aggregated deliberately does NOT iterate the underlying records for a formula cell -- it aggregates the refs and evaluates once. Routing per record means a formula cell must be able to ask for the records behind it and fold over them. That touches the evaluator, and it interacts with gotcha 9 (fixed-point recomputation) and with Model::formula_cache, which is where derived values live precisely so raw data and derived data stay separate.\n\nNOTE WHAT IT BUYS BESIDES CORRECTNESS: it is what a user means. A fitted rule applied to a group should say what the rule says about the members, summed -- not what the rule says about a fictional average member. The second is the thing that looks right and is wrong, which is the failure mode vx3's own ticket warned about ('a rule the user will believe').\n\nALSO NOTE: this ruling supersedes improvise-8xhy's remedy 1 (refuse at emit) as the END STATE, but not as the interim -- the refusal stays until this lands, or fit-measure silently produces the wrong number in aggregating views.\n\nTHE TWO THIRDS THIS STILL DOES NOT COVER, unchanged from the original text: col.missing means 'this record has no value for measure X', a different vocabulary; and the date sub-features (when.year, when.month) need date arithmetic improvise does not have at all. Those stay refused or get their own beads.","status":"closed","priority":2,"issue_type":"feature","assignee":"wave3-4zl7","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T20:03:35Z","started_at":"2026-09-20T05:32:41Z","closed_at":"2026-09-20T20:03:35Z","close_reason":"Implemented and merged in PR #54 (b06d0d0 -\u003e main 8f28477). Route-then-aggregate per the user's ruling: SUM(e) folds e over the member records rather than aggregating the refs and routing once. It needed NO new vocabulary — SUM(e) already meant this and simply did not do it: eval_agg specialised Expr::Ref and, for every other inner expression, summed the raw matching cells and DISCARDED THE EXPRESSION, so SUM(Revenue - Cost) never computed a difference at all. The fold lives in eval_agg, which both evaluators share, so unlike gotcha 3 there is one copy. Expr::Is(Filter) is the scalar coordinate predicate the language lacked. Four review rounds; findings 1 and 3 turned out to be ONE defect — the fold routed through eval_expr_cached, whose Ref could never hit a cache keyed by stems with none_cats stripped, so it fell through to aggregate_raw once per member. Fixing the correctness bug WAS the optimisation: 51ms -\u003e 17.4ms at 18,050 cells, superlinear -\u003e linear, and the 4.8x shape-dependence between two fixtures at equal cell count disappeared, which is what confirms the diagnosis rather than the smaller number. Follow-ups: improvise-wd6n, improvise-9iyv, improvise-r08u.","labels":["area:gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hwpi","title":"The browser shell fails quietly, and never reads a key","description":"Found by the improvise-cr3 code review (2026-09-19) by READING web/app.js. There is no browser and no headless DOM in this environment, so none of this is observed -- it is what the code says will happen, and it is filed rather than fixed because each item is a judgement call about the shell rather than a defect in the Rust.\n\nFOUR THINGS, in descending order of how much they would cost a person at a demo:\n\n1. NO KEYBOARD AT ALL. #grid carries tabindex=\"0\" but the page registers no keydown handler, so Client::on_key_event -- and the whole keys.rs classifier and the lockstep machinery behind it -- is UNREACHED from the browser. Consistent with improvise-bpq2 (ClientMsg has no Key variant server-side) but larger than it: even a LOCAL key, one that needs no round trip, does nothing. improvise-1ey acceptance (3) is \"arrow keys move the cursor locally with no round-trip\", and nothing in the page can do it.\n\n2. A MALFORMED FRAME IS QUIET. client.on_frame(event.data) returns Result\u003c_, JsError\u003e, which wasm-bindgen turns into a THROWN EXCEPTION. It is thrown inside the message listener with no try, so it surfaces as an uncaught error in the devtools console and the following repaint() is skipped. Nothing in the UI says so: the grid simply stops updating. A try/catch writing to el.status would make it visible.\n\n3. NO error LISTENER ON THE SOCKET. A connection that never opens leaves #status blank -- only close writes \"disconnected\". So \"server not running\" and \"still connecting\" look the same.\n\n4. close() RUNS TWICE ON COMMIT, benignly. Enter -\u003e close(true) -\u003e td.replaceChildren(original) removes the focused \u003cinput\u003e -\u003e blur fires -\u003e close(false). editing is already null so it is harmless (one extra repaint), but there is no re-entry guard and only a real browser would show it.\n\nNOTE WHAT MAKES THIS TICKET DIFFERENT: items 2-4 are the kind of thing a test would catch instantly and we have no way to run one. Before fixing them, consider whether the right first move is a headless DOM in CI (jsdom or playwright) -- otherwise the fixes are as unverified as the bugs. improvise-xf1 (verify OpfsFs against a real browser) wants the same harness.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:40:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T02:29:50Z","closed_at":"2026-09-20T02:29:50Z","close_reason":"All four items done, plus a fifth the ticket could not have predicted.\n\n1. KEYDOWN WIRED -- a window listener feeding Client::on_key_event. preventDefault fires only when a frame was produced and no Ctrl/Alt is held, so browser shortcuts stay the browser's; metaKey is skipped entirely, matching BrowserKey::to_key_event dropping Meta so Cmd-R cannot arrive as a bare r. The inline editor stopPropagation keeps it out of the way, with an if (editing) return as belt and braces.\n2. MALFORMED FRAME -- try/catch around on_frame writing to #status, plus console.error with the offending frame. render_model() is guarded the same way; it also returns a Result.\n3. SOCKET error LISTENER -- 'cannot reach ... -- is improvise-ws-server running?', so that no longer reads like 'still connecting'.\n4. close() RE-ENTRY GUARD -- a closed flag in edit().\n\nTHE FIFTH, AND IT IS THE INTERESTING ONE: a worker whose start() REJECTS is caught INSIDE worker.js, so it fires NO error event on the main thread. The page would have sat on 'in-page worker - demo.improv' over an empty grid forever -- THIS TICKET'S EXACT DISEASE, REBUILT IN THE NEW DEPLOYMENT ON DAY ONE. The guard is a first-frame watchdog (8s, cleared by the first frame), and it deliberately covers BOTH transports rather than each peer's own failure modes, because 'the peer never answered the handshake' is the one condition that means the same thing over a socket and over a port -- a check aimed per-peer would have missed exactly this one.\n\nKEYS ARE INERT AND THAT IS STATED, NOT HIDDEN. Host::handle answers ClientFrame::Key with an empty vec; improvise-bpq2 is the other half.\n\nSTILL UNRUN -- and this ticket predicted that would be the problem: 'consider whether the right first move is a headless DOM, otherwise the fixes are as unverified as the bugs.' That sentence is now true OF THESE FIXES. Three of the four are one-assertion Playwright tests. See the harness ticket.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zv4m","title":"improvise-protocol should own the websocket framing, so both ends stop mirroring it","description":"Raised by improvise-cr3 (2026-09-19) as the thing to push back on, and filed rather than fixed there.\n\nTODAY: improvise-wasm-client/src/socket.rs declares ClientFrame/ServerFrame, which MIRROR improvise-ws-server/src/wire.rs ClientMsg/ServerMsg field for field. The client cannot depend on the server crate -- tokio, tungstenite, Box\u003cdyn Storage\u003e, all native -- so the duplication was the only way to speak the protocol from wasm.\n\nWHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of the client (the same argument the crate already makes for improvise-command), and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions; a_frame_named_the_other_envelopes_way_does_not_parse is its check-the-checker. So a drift is caught -- but only by a test whose job is to notice, rather than by a shared definition that cannot drift.\n\nTHE FIX: move the framing into improvise-protocol, which both ends already depend on and which is the crate whose charter is exactly this -- \"the wire vocabulary and the portable session slice\". Then the mirror, the dev-dependency and both compatibility tests all delete.\n\nNOTE THE THIRD ENVELOPE while you are in here: improvise-wasm-client/src/wire.rs has ClientMessage/ServerMessage framing the WORKER deployment (improvise-tm6). Three envelopes for two deployments is worth resolving in one pass -- decide whether the worker and the socket want the same vocabulary before moving either.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:37:50Z","closed_at":"2026-09-20T00:37:50Z","close_reason":"Duplicate of improvise-xks6 ('One home for the client/server frame envelopes'), filed minutes apart from the improvise-cr3 hand-back without spotting the existing ticket. Closing this one; xks6 is the survivor and its description should absorb two facts this one carried:\n\n * WHAT HOLDS THE LINE MEANWHILE: improvise-ws-server is a DEV-dependency of improvise-wasm-client, and the_socket_framing_matches_the_server round-trips every variant THROUGH THE SERVERS OWN TYPES in both directions, with a_frame_named_the_other_envelopes_way_does_not_parse as its check-the-checker. The drift is caught by a test whose job is to notice, rather than prevented by a shared definition.\n * THERE ARE THREE ENVELOPES, NOT TWO: improvise-wasm-client/src/wire.rs frames the WORKER deployment (improvise-tm6) separately again. Decide whether the worker and the socket want the same vocabulary before moving either, or the move produces two homes instead of one.\n\n(See also memory agent-issue-drift-pattern -- search before filing.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8xhy","title":"[bug] A fit over a categorical feature emits an unevaluable formula","description":"Found during improvise-7na (2026-09-19), verified by probe, and pinned as a characterization test rather than fixed.\n\ngp_tree::data::preprocess one-hots a categorical column into a feature named Region=North. That is an ENCODING ARTEFACT, not an item of the document, so the emitted formula Expr::Ref fails Model::find_item_category and EVERY CELL of the fitted measure evaluates to Error(\"ref:Region=North\"). col.missing and the col.year / col.month date sub-features are in exactly the same position.\n\nSo \"fit a tree and get a working formula\" holds ONLY for a fit over numeric measures.\n\nTHE TEST THAT SHOULD HAVE CAUGHT IT WAS LYING: improvise_evaluation_matches_gp_tree_eval cannot see this, because its improvise_eval helper FABRICATES a _Measure item for every feature name, including outlook=rainy. It asserts the emitter and gp_tree::eval agree, which they do -- against a model that could not exist. Worth remembering as a shape: a helper that builds the world the code wants is not a test of whether that world exists.\n\nPinned as a_split_on_a_one_hot_feature_does_not_evaluate -- characterization, flip it when fixed.\n\nTHREE REMEDIES, pick deliberately: restrict fits to numeric features (smallest, and honest about what works); materialise one-hot columns as real _Measure items (makes the formula evaluable but pollutes the document with encoding artefacts); or teach the emitter improvise WHERE vocabulary so a categorical split becomes a real predicate over the category (largest, and the only one that produces a formula a human would have written). The third is probably right and should be costed before the first is shipped as permanent.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T00:35:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:45:21Z","closed_at":"2026-09-20T00:45:21Z","close_reason":"Fixed as REMEDY 1, explicitly labelled interim. fitted_formula now takes the \u0026Model the formula is to be stored in and refuses a tree whose splits it cannot resolve, listing them. formula_text is untouched -- it is the tree's own text and there is nothing else it could write. Nothing outside the crate called fitted_formula.\n\nWHY THE EMITTER AND NOT THE DATASET LAYER: dropping categorical columns before the fit is smaller, but it throws away real signal (permutation importance, and any future remedy-3 emitter) and makes the crate's halves disagree -- one-hot encoding is gp-tree's whole job. The defect is that the EMITTER spells a formula the document cannot evaluate.\n\nWHY THE CHECK ASKS THE DOCUMENT, NOT THE NAME'S SHAPE: refusing names containing '=' or matching .missing would be a syntactic proxy and a wrong one, because a measure LITERALLY NAMED Region=North is a legal document and must be accepted. The two are indistinguishable as strings; only the document can tell them apart. So it calls Model::find_item_category -- the exact precondition Expr::Ref has -- which covers .missing, the date parts, and any future sub-feature naming scheme with no second copy of preprocess's expansion rules to drift from. Both directions pinned.\n\nTHE LYING TEST IS KEPT, NOT DELETED. Quoting and threshold routing are real things improvise_evaluation_matches_gp_tree_eval checks, and outlook=rainy earns its place there as an awkward NAME TO SPELL. What changed is that it and its helper now state their scope -- a test of arithmetic and quoting against a STIPULATED model, unable to see whether such a model could exist. The property it could not state is asserted separately against a real document: a_formula_the_emitter_accepted_evaluates_everywhere -- if fitted_formula says Ok, no cell of the target is a ref: error. That test and the refusal test are each other's check; without the accepting case the refusal could pass by refusing everything. The characterization is flipped to a_split_on_a_one_hot_feature_is_refused.\n\nREMEDY 3 IS STILL RIGHT AND IS NOT THIS TICKET -- successor filed. It is much larger than it looked: improvise has NO SCALAR COORDINATE PREDICATE (Filter gates a formula or an aggregate, it is not an Expr), it would still leave col.missing and the date parts uncovered, and it must first answer what such a predicate means where context.get(cat) is None.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -166,13 +175,24 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-r0qa","title":"The evidence checker validates only heading-to-test, never test-to-evidence","description":"Found by adversarial review of PR #59, 2026-09-20, as the first case to walk into the gap.\n\nscripts/check-comment-runs.py --check-evidence validates ONE direction: every '### name' heading in context/test-evidence/ must name a test that exists. design-principles §7 states that choice deliberately — 'a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation.'\n\nThe blind spot is the reverse. PR #59 added a Rust test whose doc comment says 'see context/test-evidence/clients.md', and that file has NO entry for the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons. The checker passed (503 headings, all resolving). So the new assertion's proof lived only in the PR body — which is not in the repo, and which design-principles §6 now says is exactly where an argument must not live, because nothing can audit it.\n\nWHY THE ORIGINAL ASYMMETRY IS STILL RIGHT, and why this is a narrower fix than 'check both ways': most tests have no evidence and should not. The check is not 'every test has evidence' — it is 'a test that CITES evidence cites evidence that exists'. That is a different predicate and it has no false positives: a doc comment naming context/test-evidence/\u003cfile\u003e.md (and optionally a heading) is a claim, and the claim is checkable.\n\nNote the failure mode it closes is the one this repo keeps finding: a pointer that reads as a promise the thing exists. Same argument as a dead ticket ID, in another key.\n\n## Steps to Reproduce\n\n1. Add a Rust test whose doc comment cites context/test-evidence/clients.md for a section that does not exist.\n2. python3 scripts/check-comment-runs.py --check-evidence\n3. It passes.","acceptance_criteria":"A test whose doc comment cites context/test-evidence/ for a section that does not exist fails the checker. Demonstrated by pointing a test's doc comment at an absent heading and watching --check-evidence fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:39:27Z","created_by":"orchestrator","updated_at":"2026-09-20T20:39:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-20T20:18:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-wsaw","title":"The browser ignores category_panel_open, so C toggles nothing visible","description":"RenderModel::category_panel_open has existed since the panel was added and web/app.js has never read it: repaint() calls categoryList(model) unconditionally, so #categories is permanently visible. Verified in Chromium: on a fresh load the client's own view slice says category_panel_open:false while the sidebar is drawn.\n\nConsequences. The TUI draws the category panel only when the flag is set (src/draw.rs:322), so the two front ends disagree about what the panel toggle does. In the browser, Shift-C and Ctrl-C change the mode label and nothing else - the user gets no feedback that the panel opened, which is part of why 'I can't expand categories' reads as broken.\n\nThe DECISION: honour the flag (matching the TUI, but the sidebar then disappears on load and the user must press C to see any category), or keep the always-visible sidebar and treat the flag as not applying to this front end (in which case app.js should stop carrying a field it ignores, and the browser's C should do something else visible - focus styling on the panel, say).\n\nNote fix/web-ui-correctness added #views and #formulas and DOES honour their flags, so the three panels are currently inconsistent with each other. That inconsistency is the concrete cost of leaving this open.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:16Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:30:59Z","started_at":"2026-09-20T19:30:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8ec3","title":"improvise-io's test binary breaches the 2s per-binary rule on some machines, and the pinned figure is not reproducible","description":"`doctrine(suite_speed)` and design-principles §7 both state the rule that bites\nas **no single test binary over 2 seconds**, and pin it at `22d43b2` with\n\"longest single binary 1.42s\".\n\nThat number is not reproducible across machines. Measured on PR #51's branch:\n\n- This agent's machine: `improvise_io` lib, 1.42-1.68s over four runs.\n- The reviewing agent's machine, same branch: **3.70-5.24s**, and **4.82s on\n `main`** — so the breach is pre-existing, not introduced by #51.\n\nTwo things follow, and the second is the one worth doing.\n\n**The stated number is a measurement of one machine presented as a property of\nthe suite.** `doctrine(suite_speed)` already says \"re-MEASURE rather than\nre-assert\", and this is what that costs when the measurement is 3x apart on two\nmachines: a PR body claiming \"longest single binary 1.47s\" is checkable and\nfalse for the reader who checks it, which is worse than no claim.\n\n**`improvise-io` is the binary at issue, and it is proptest-dominated.** Its\nslow half is the grammar-walking generator in `persistence/parse.rs` plus the\n`.improv` roundtrip proptests, at the default 256 cases.\n`doctrine(proptest_cases)` says constrain the strategy rather than lower the\ncount, so the work is to look at what the generator actually produces — a\ngrammar walk with no size bound generates deeply nested documents whose cost is\nnot uniform across cases.\n\n## Steps to Reproduce\n\n1. `cargo test -p improvise-io --lib` and read the `finished in` it prints.\n2. Compare against the \"1.42s\" pinned in `context/design-principles.md` §7 and\n in `doctrine(suite_speed)`.\n3. On a loaded or slower machine the figure exceeds the stated 2-second\n per-binary limit, on `main` as well as on any branch.\n","acceptance_criteria":"Either improvise-io's lib binary is brought under 2s on the slow measurement (by constraining the .improv grammar generator's strategy, NOT by lowering proptest cases below 256 — doctrine(proptest_cases)), or design-principles §7 and doctrine(suite_speed) stop pinning a single machine's number and say what the rule is measured against. A figure that is 3x apart on two machines is not checkable, and doctrine(suite_speed) already says re-measure rather than re-assert.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:53:08Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:53:08Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-wd6n","title":"A fit may still not split on col.missing or a date sub-feature","description":"improvise-4zl7 closed the categorical third of improvise-8xhy: a one-hot over a\nreal category is now emitted as `Cat IS Item`. Two artefact families remain\n`Spelling::Artefact` and are still refused by `fitted_formula`:\n\n- `col.missing`, the 0/1 indicator `preprocess` adds for a column with any\n missing cell. It means \"this record has no value for measure X\" — a question\n the formula language has no word for at all. `Expr::Ref` over an absent cell\n is an error, not a zero, so there is no existing spelling to reuse.\n- the date sub-features `when.epoch_day`, `when.year`, `when.month`,\n `when.day`, `when.dow`, and with a time component `when.hour`, `when.minute`.\n These need date arithmetic improvise does not have anywhere: a `.improv` cell\n holding a date is `CellValue::Text`, and nothing in `eval_expr` reads one.\n\nBoth are visible to a user only as a refusal naming the feature, which is the\nright interim. Closing either is a formula-language change, not an emitter\nchange, and they are independent of each other.\n\n## Steps to Reproduce\n\n1. Build a document whose only usable feature is a measure with some cells\n cleared, so `preprocess` emits `\u003cmeasure\u003e.missing`, and whose target is best\n explained by which records have that measure at all.\n2. `fit-measure \u003ctarget\u003e`.\n3. The champion splits on `\u003cmeasure\u003e.missing` and `fit_measure` refuses with\n \"the fitted tree splits on [\\\"\u003cmeasure\u003e.missing\\\"], which the formula\n language cannot spell at all\".\n\nThe date half reproduces the same way with a column of ISO dates: see\n`crates/gp-tree/src/data.rs::build_date` for the sub-features it emits, and\n`the_missing_flag_and_the_date_sub_features_are_refused_too` in\n`crates/improvise-gp/src/lib.rs` for the refusal asserted.\n","acceptance_criteria":"A fitted tree that splits on a col.missing flag or a date sub-feature is emitted as a formula that evaluates, or the family is declined in writing with the reason. Whichever way it goes, the two families are answered separately: they need different vocabulary. improvise-4zl7's route-then-aggregate rule (context/code/core.md 13) must keep holding for whatever is emitted.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T18:50:51Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T18:50:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cl7y","title":"A browser reports \"Formula added\" for a formula only the host can parse","description":"Found by the lockstep test improvise-e24v added for commit-buffer, once it\nwas widened to cover the Formula kind (the review's Finding 2).\n\nCommitBuffer emits its optimistic status FIRST and the add effect LAST, so on\nthe host a parse failure overwrites \"Formula added\" with \"Formula error: ...\"\n(improvise-6t3). The client mirrors the command line for line, which is\ncorrect -- but it cannot parse, so it folds \"Formula added\" and stops there.\nThe two ends then disagree on the status line, and the host's correction is a\nViewOnly effect that reaches nobody.\n\nThe user sees a browser confidently report a formula it did not add. This is\nthe same room improvise-fdt6 describes from the other side.\n\nNot a defect in the mirror: doctrine(mirror_the_cmd_line_for_line) says the\nclient reproduces its Cmd, and it does. The gap is that AddFormula's success\nis not observable to the composer. Pinned, not fixed, by\na_formula_the_client_cannot_parse_diverges_on_the_status_line, which asserts\nBOTH sides of the divergence so a fix has to come there and change it.\n\n## Steps to Reproduce\n1. Open the browser client and press F, then n, to open the formula prompt.\n2. Type an unparseable formula -- \"P=\" -- and press Enter.\n3. The status line reads \"Formula added\". No formula was added, and the host's\n \"Formula error\" never arrives.","acceptance_criteria":"A client that cannot parse does not claim success. Either the status\nfor a fallible add is withheld until the host answers (which needs the host to\naddress a status back to the composer, as ClientFrame::Save's rejection does),\nor the formula text is validated where both ends can do it. Whichever, the\ntest named above must change, because it currently asserts the divergence.","notes":"Checked during the adversarial review of PR #50, so nobody re-litigates it:\n\"the client cannot parse\" is true of the DEPENDENCY GRAPH, not merely of the\ncode as written. The repo map lists improvise-formula as a browser-bundle\ncrate, which reads as though parse_formula were a free call away -- it is not.\nimprovise-formula is a dependency of neither improvise-protocol nor\nimprovise-wasm-client; it reaches the bundle only through improvise-io and\nimprovise-command, neither of which a thin client has.\n\nSo closing this is a dependency decision (put improvise-formula on the thin\nclient's graph, and pay whatever that costs the bundle) rather than a call\nsomebody forgot to make. The other route -- have the host address a status\nback to the composer, the way ClientFrame::Save's rejection already does --\ncosts no dependency and is probably the cheaper answer.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:18Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pubt","title":"[bug] display_value_in reads the host's drill edits against a foreign layout","description":"Found by the adversarial review of PR #48 (improvise-jytm).\n\n`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`,\nand that function consults `App::drill_state` — the host's staged, uncommitted drill\nedits, which are session-local residue belonging to whoever opened the drill\n(gotcha 30, improvise-h0b).\n\nBefore improvise-jytm every client shared one layout, so a client seeing the host's\npending drill text was at least seeing it against the coordinates it was staged\nagainst: shared, arguably wrong, but coherent. That is improvise-tom1's territory.\n\nAfter the split a client can be on a DIFFERENT view, and `grid_in` derives a separate\n`GridLayout` for it. `display_value_in` is then handed that foreign layout while\n`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the HOST's\ndrill layout (gotcha 1). Row indices are positions, and positions in one layout mean\nnothing in another — so if that path is reachable, a client is shown another client's\nuncommitted text against coordinates it was never staged for.\n\nWhy it is probably not reachable today, and why \"probably\" is the reason to file it\nrather than to fix it blind: the `synthetic_record_info(key)` arm only fires for the\nsynthetic `(_Index, _Dim)` keys that records/drill mode produces, and a foreign\nlayout built by `GridLayout::new` carries no frozen records, so it should not produce\none. That argument is a read, not a test, and it is the kind of read this repo has\nbeen wrong about before.\n\nTwo things to do, in order: write the test that decides whether it is reachable, and\nif it is, stop `display_value_in` consulting `drill_state` for a layout that is not\nthe host's — the drill snapshot is the host's session-local residue and a foreign\nlayout is by definition not its addressee.\n\n## Steps to Reproduce\n\nNot confirmed reachable; establishing that is the first half of the work.\n\n1. Host opens a drill and types into a cell without committing, so\n `App::drill_state.pending_edits` is non-empty.\n2. A connected client switches to a different view (`Command::SwitchView`), so\n `grid_in` derives a layout that is not `App::layout`.\n3. Project that client's grid and read `CellDisplay::edit_text`.\n\nIf any cell's `edit_text` comes from `pending_edits`, that is the bug: the row index\nthose edits are keyed by addresses a row in the host's drill, not in this client's\nlayout.\n","acceptance_criteria":"A test establishes whether the drill arm of display_value_in is reachable with a layout that is not App::layout. If it is, drill_state is not consulted for a foreign layout. If it is not, the test stays as the guard that says so, because the argument for unreachability is currently a read rather than a check.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:33:24Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w3bf","title":"A session mirror's active_view is written in four places, none through reduce_view","description":"Found by the adversarial review of PR #48 (improvise-jytm). Not a defect today: the\nfour writers agree. It is the SHAPE the divergence bugs in this area have taken every\ntime, filed before it becomes one.\n\n`ClientSession::view.active_view` — the server's mirror of a client's view — is\nwritten in four places, and none of them goes through `reduce_view`:\n\n1. `Sessions::apply_switch` — sets it directly from the accepted name.\n2. `Sessions::adopt_deleted_views` — sets it directly to the document default.\n3. `ClientSession::reseed` — replaces the whole slice via `fresh_view`.\n4. `Sessions::connect` — same, through `reseed`.\n\nEverywhere else in this codebase, the mirror stays in step with the client by folding\nthe SAME command the client folds (`ClientSession::fold` → `reduce_view`, the\n\"same function runs on both ends\" rule of improvise-cqq/m91). These four bypass it.\nThey are correct today only because `reduce_view`'s `SwitchView` arm is one line\n(`view.active_view = name.clone()`) and each of these reproduces it.\n\nThe day that arm grows a second statement — clearing a per-view buffer, resetting a\ncursor, anything — the mirror and the client diverge and nothing fails. That is\nexactly gotcha 36's \"folding SwitchView into the mirror alone was the bug\", arriving\nfrom the other direction.\n\nTwo shapes worth weighing:\n\n- Route all four through `reduce_view(\u0026mut session.view, \u0026Command::SwitchView(name))`.\n Cheap, and makes the bypass unrepresentable for this command.\n- A test that the two agree: fold a `SwitchView` into a bare `ViewState` and into a\n `ClientSession` and compare. Weaker (it is a compatibility test, and\n `doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)` says\n those are a definition waiting to be made) but it costs nothing.\n\n## Steps to Reproduce\n\nNot reproducible today; the four copies agree. To make it reproduce: add any second\nstatement to `reduce_view`'s `Command::SwitchView` arm in\n`crates/improvise-protocol/src/reduce.rs` — for instance clearing `status_msg` — then\nrun the suite. It stays green, and a client's own slice now carries something its\nserver-side mirror does not.\n","acceptance_criteria":"Either the four writers route through reduce_view, or a test pins that a ClientSession's mirror and a bare ViewState fold a SwitchView identically. Closing it as 'the copies agree' is not acceptable — that is the state it is filed in.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:32:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:32:43Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-20T19:17:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:11:01Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rm4s","title":"The evidence check accepts headings naming tests that never run","description":"Found by adversarial review of PR #44 (finding F9), demonstrated with a built fixture that returned 'test-evidence: 3 entries, all name a test that exists', exit 0.\n\nscripts/check-comment-runs.py --check-evidence scrapes source text line by line for 'fn NAME' preceded by a test attribute. Three shapes defeat it:\n\n1. A test behind a cfg feature nobody enables — compiled out, never runs.\n2. A test attribute and fn inside a STRING LITERAL (an r-hash fixture). The scanner reads inside string literals, so this names a test that does not exist at all.\n3. A #[cfg(target_arch = wasm32)] test. This is the realistic one for THIS repo: it has real wasm32-only code (improvise-io/src/opfs.rs, improvise-worker-server) and a wasm CI job that only runs cargo check. Evidence keyed to such a test is accepted while cargo test --workspace never executes it.\n\nAll three are the same defect one layer up from the one the gate exists to prevent: evidence for a check that never runs.\n\nCheap fix the reviewer proposed and I verified is currently free: diff the heading set against the test names cargo actually reports (cargo test -- --list, or the run log) instead of scraping source. Cross-checking all 451 current headings against the executed test names from a real cargo test run came back EMPTY — every heading names a test that really ran — so adopting the stricter source costs nothing today.\n\n## Steps to Reproduce\n\n1. Create context/test-evidence/x.md with a heading naming a_test_that_is_only_text.\n2. Put a #[test] fn a_test_that_is_only_text() {} inside an r-hash string constant in any scanned .rs file.\n3. python3 scripts/check-comment-runs.py --check-evidence -\u003e passes, exit 0.","acceptance_criteria":"A heading naming a cfg-gated, wasm32-only, or string-literal 'test' is rejected; the check is diffed against the test names cargo actually reports rather than scraped from source text. Demonstrated by adding each of the three shapes and watching the check fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-20T18:06:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:06:00Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ahdf","title":"repo-map gotchas 36 and 43 still name Sessions::apply as the production door","description":"Found by adversarial review of PR #45 (finding F11).\n\nVerified: Sessions::apply has NO production caller. Host calls apply_checked (crates/improvise-command/src/ui/host.rs:144); apply (project.rs:774) delegates to apply_checked (:783) after Checked::against, and its only call sites are tests. context/code/command.md already records this.\n\nTwo repo-map gotchas still say otherwise:\n * gotcha 36: 'Sessions::apply(app, id, cmd) is the only way a connected client's Command reaches the server'\n * gotcha 43: 'Sessions::apply answers once, before the dispatch'\n\nBoth are PRE-EXISTING rather than introduced by #45 — but they are the siblings of the sentence #45 fixed in design-principles §5, and gotcha 48 is the rule they break: 'when a fact is stated in more than one place, changing it is a grep, not an edit.' #45 fixed §5 and the now-false note in command.md; these two were left because they sit inside long paragraphs whose surrounding argument needs reading, not a sed.\n\n## Steps to Reproduce\n\n1. grep -n 'Sessions::apply' context/repo-map.md\n2. Compare against crates/improvise-command/src/ui/host.rs:144 and the note in context/code/command.md.\n\nNote the claims are not merely imprecise: a future agent told that apply is 'the only way' may add a caller to it, bypassing the CheckedBatch frame-level answer that host.rs actually performs.","acceptance_criteria":"Gotchas 36 and 43 name apply_checked where they mean the door production uses, or say explicitly that apply is the single-command convenience its tests drive; a grep for 'Sessions::apply' across context/ finds no claim that contradicts context/code/command.md.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-20T18:05:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:14Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:01:04Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-20T17:57:25Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-7kvw","depends_on_id":"improvise-ni6k","type":"related","created_at":"2026-09-20T18:24:00Z","created_by":"orchestrator","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mbmf","title":"An assertion message in gp-tree states the opposite of what it checks","description":"crates/gp-tree/src/fit.rs, in the_ranking_is_ordered_shares_with_the_unused_features_dropped:\n\n assert_eq!(ranked.len(), 2, \"the zero-importance feature is listed\");\n\nimportance_ranking is given three features, one of zero importance, and the\nassertion requires TWO entries back — i.e. the zero one was dropped. The\nmessage says it is listed.\n\nThe message is only ever read on failure, which is exactly when someone is\ntrying to work out what the intended behaviour was; here it would tell them\nthe opposite. The test name and the doc comment both say 'dropped', so the\nmessage is the only wrong copy of three.\n\n## Steps to Reproduce\n\n1. Change importance_ranking to list zero-importance features at 0%.\n2. cargo test -p gp-tree the_ranking_is_ordered\n3. The failure reads 'the zero-importance feature is listed' — describing the\n new (wrong) behaviour as though it were the requirement.\n\nFound during the 2026-09-20 comment-cleanup wave review.","acceptance_criteria":"The message names what the assertion actually requires (the zero-importance feature is dropped); a deliberate injection of a 3-entry ranking makes the test fail with a message that reads correctly.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:49:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:12:59Z","closed_at":"2026-09-20T18:12:59Z","close_reason":"Fixed in PR #45 (commit 56ff51e): the assertion message in the_ranking_is_ordered_shares_with_the_unused_features_dropped now says 'the zero-importance feature is dropped', matching what the assertion checks and what the test name and doc comment already said. The Closes: trailer was written before the bead was actually closed — flagged by adversarial review as a dead promise of the same class as a dead ticket ID.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-23qd","title":"The Projection::Basis record set is derived twice, and the second copy is the tests' yardstick","description":"Found by the post-wave code consistency review.\n\nThe set of records a client's Projection::Basis should carry is derived TWICE:\n * crates/improvise-command/src/ui/project.rs:883-892 — ClientSession::basis_update, the producer\n * crates/improvise-command/src/ui/project.rs:1350-1364 — `server_truth`, a #[cfg(test)] helper\n\nand `server_truth` IS THE GROUND TRUTH the tests measure against (used at :1632, :1660, :2511). It re-implements \"which records does this client's grid name\" rather than asking the producer.\n\nSo a change to WHICH records get published would move both together and be invisible to every test measured against it. That is doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing) in its most dangerous form — not a helper that fabricates an input, but a helper that fabricates the EXPECTED OUTPUT.\n\nThe fix is small: have server_truth call the same filter, or extract it as a pub(crate) fn both use. What must not happen is the two staying separate with a comment saying they agree.\n\n## Steps to Reproduce\n\nNot a runtime failure — a test-integrity one, which is why it is filed rather than patched.\n\n1. Change which records `ClientSession::basis_update` publishes (`project.rs:883-892`).\n2. Make the matching change in `server_truth` (`project.rs:1350-1364`), the `#[cfg(test)]` helper the basis tests measure against at `:1632`, `:1660` and `:2511`.\n3. Every test passes, whatever the change was — including one that publishes the wrong set — because the yardstick moved with the thing it measures.\n","acceptance_criteria":"server_truth calls the same filter the producer uses, or the filter is extracted as a pub(crate) fn with both calling it. Changing which records are published then moves the producer and the yardstick together by construction, and a test proves it: alter the filter and watch the basis tests fail rather than silently track it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:57:52Z","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -202,7 +222,7 @@ {"_type":"issue","id":"improvise-x7sw","title":"Which-key hints have no protocol home (the open half of improvise-jb3)","description":"improvise-jb3 closed with its WIDGET half already done -- WhichKeyWidget::new(\u0026[(String, \u0026static str)]) was never \u0026App-coupled. This is the half that was never that ticket.\n\nThe derivation happens in draw.rs: App::armed_prefix_keymap() -\u003e Keymap::binding_hints(). ViewState::transient_prefix IS portable (gotcha 13 stores a prefix as an ADDRESS, not a keymap, precisely so ViewState stays free of improvise-command types) -- but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone.\n\nTWO ANSWERS, and the choice is the ticket:\n * THE RESOLVED HINT LIST CROSSES -- a Projection, or a ViewState field. Cheap, and it keeps the keymap a client-only concept, which is the stated direction (\"keys should be converted into commands in the browser: the keymap should not be known server side at all\"). But it puts a UI string list on the wire, which is what improvise-k099 is already unhappy about for AppMode::hint_text/label.\n * THE CLIENT GETS THE KEYMAPS. Consistent with keys being a client concept, and the wasm client already has keys.rs -- but the hint list is currently derived from the SERVER-side KeymapSet, so this means the client owns the keymaps outright and the server stops having them. Larger, and probably where this is heading anyway.\n\nNote the second reading is the one that fits improvise-bpq2. Decide them together.","acceptance_criteria":"A browser can draw which-key hints. Decide between the resolved hint list crossing as a Projection, and the client owning the keymaps outright — the second is where improvise-bpq2 already went (the table now lives in improvise-protocol), so prefer it unless there is a reason not to, and decide it TOGETHER with improvise-k099, which is the same question about UI strings on a wire enum. Whichever, ViewState::transient_prefix stays an ADDRESS and not a keymap (gotcha 13).","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:05Z","started_at":"2026-09-20T17:53:39Z","closed_at":"2026-09-20T18:18:05Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2yc2","title":"DataStore::live_records walks a HashMap -- decide its order before writing the caller","description":"The next instance of improvise-shyt, found while fixing it, and deliberately LEFT ALONE rather than guessed at.\n\nDataStore::live_records walks the records: HashMap, so it yields a different order in every process -- the same defect that made records-mode rows reorder between sessions, misdirected drill staged edits, and drifted SUM/AVG in the last f64 bits.\n\nIt differs from shyt in one way that matters: IT HAS NO CALLERS YET. So this is not a bug report, it is \"decide the order deliberately before something depends on it\". The ADR 002 surrogate path will be the caller -- the RecordHash -\u003e RecordEntry index is what live_records walks -- and whoever writes it should pick an order on purpose rather than inherit a hash walk and then discover it, which is how shyt happened.\n\nTHE ANSWER SHYT LANDED ON, for reference: the index narrows, the primary IndexMap orders (matching_positions resolves each candidate with get_index_of, sorts, reads back with get_index). Measured at 18,000 cells that cost at worst +20% on one path and was 7% FASTER on the other. Whether that is the right answer here depends on what the surrogate path wants; a hash-keyed index may legitimately want no order at all, in which case say so and return something whose type admits it.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T05:54:43Z","close_reason":"Closed by PR #20 (WU-K). DECIDED: live_records answers in the STORE'S OWN ORDER -- it walks the primary IndexMap's cells and dedupes by RecordHash, so a record sits where its FIRST cell sits. That is the order matching_positions already gives (gotcha 18), projected onto records, rather than a second answer to \"what order is this store in\".\n\nDerived on each call rather than memoised on RecordEntry, because a stored position is a copy and the tombstone a deleted record leaves would keep it while its cells moved to the end. That case is now tested (a_recreated_record_takes_its_new_place_not_its_old_one) -- and it was found because Copilot noticed the doc comment ASSERTED the derive-don't-memoise choice mattered for delete-and-recreate while nothing tested it; a memoising implementation passed all three other tests.\n\nTHE TICKET'S ALTERNATIVE WAS REJECTED WITH A REASON WORTH KEEPING: \"a hash-keyed index may want no order at all -- return something whose type admits it\" cannot be built. Every iterator has an order, and a type that merely DISCLAIMS one still hands the caller hash order. Disclaiming is not making unrepresentable.\n\nCost moves O(records) -\u003e O(cells). With no caller to benchmark, correctness is the defensible default and the doc says what a hot-path caller should bring.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-odbc","title":"[bug] bank-info.improv in the repo root does not parse","description":"Incidental finding from improvise-cr3 (2026-09-19), outside that ticket scope, unreproduced beyond the one load.\n\n Parse error at line 293, column 3: - JORDAN MARTINEZ (expected pipe_quoted)\n\nTWO READINGS, and they want different actions:\n * The file is STALE -- written before a grammar change -- in which case delete it or regenerate it, and the finding is that a sample file in the repo root is not exercised by anything.\n * The GRAMMAR HAS A GAP -- some name shape a writer emits that the reader rejects. That is the serious reading, because save/load round-trip is a property test (persistence proptests, parse(format(parse(generate())))) and a gap that survives it is a gap the GENERATOR does not reach.\n\nDecide which by checking whether improvise can WRITE that line today. If it can, the round-trip property is incomplete and the generator is the thing to fix, not the file.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:58Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T06:28:58Z","close_reason":"Closed by PR #22 (WU-L). THE TICKET'S PREMISE WAS FALSE, AND TESTING IT RATHER THAN ASSUMING EITHER READING IS WHAT MADE THIS CHEAP.\n\nThe ticket offered two readings and hoped for the serious one: that the grammar had a gap some writer could emit, which would mean the round-trip property is incomplete and the GENERATOR is the thing to fix. It said to decide by checking whether improvise can WRITE the offending line. IT CANNOT. item_list's name is `pipe_quoted | bare_name`, bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space, and quote_name mirrors it character for character -- so improvise writes |JORDAN MARTINEZ|. Reader and writer agree, the round-trip property is COMPLETE, and the grammar-walking generator needs no change.\n\nThe file was stale BY HAND: c48a5cd migrated it to v2025-04-09 by editing in place, pipe-quoting the Date items and not the Entity ones, and every line of its ## Data section names a category `Measure` where the file declares `_Measure`. Unloadable since April. Nothing loaded it.\n\nDELETED RATHER THAN REGENERATED, on the user's ruling 2026-09-19, together with foo.improv (the same class, one file over, with no version line at all). There is no generator for either -- they are hand-written fake data -- so regenerating meant inventing a canonical form and checking in 1200 lines whose only property is that they parse, which the grammar proptests already establish over a far wider input space.\n\nTHE EVIDENCE KEPT IS WORTH MORE THAN THE FILE:\n * an_item_name_the_item_line_cannot_carry_round_trips -- the deleted file's own names through format_md/parse_md.\n * a_bare_item_name_with_a_space_is_rejected -- the reader really refuses the unquoted line, with the quoted form parsing as a precondition. Without this the first test is consistent with BOTH spellings being accepted.\n * tests/shipped_documents_load.rs -- a DIRECTORY WALK over the two directories .gitignore un-ignores, not a list of names, because a list is a second place to remember and the forgotten file is the one nobody added to it. Fault injected and failed. Then HARDENED: it matched Path::extension() == \"improv\", which of x.improv.gz is \"gz\", so a gzipped shipped document would have been skipped -- the very hole the test exists to close, put back inside the fix for it.\n\nTHE FINDING, which is the opposite of what the ticket expected: a checked-in document in a format the repo parses is exercised by NOTHING unless something loads it, and grammar proptests cannot see it because they GENERATE their inputs.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gd28","title":"Drill over the wire needs the addressed command form","description":"From the ADR 002 spine landing (2026-09-19). The RESOLVER exists and is tested -- App::resolve_aggregate, layout-only by necessity since an aggregate has no existence outside the layout that derives it -- and CellId::Aggregate is emitted. What is missing is the command.\n\nModelCommand::Drill was deliberately NOT added. Drilling creates the _Drill view and writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a NULLARY Command -- exactly the reason ApplyAndClearDrill was removed from the wire in improvise-3x3u.\n\nSo a wire Drill needs the ADDRESSED form: the variant carries a SessionId and the staging map moves off the single App onto the session. That is ADR 001 layer 2 (context/adr/001-concurrent-edits.md), so this is blocked on it rather than being independent work.\n\nThe seam is ready: emit, resolve and the type-level guarantee (Drill takes an Aggregate and nothing else) are all in place and tested.","acceptance_criteria":"Drilling works over the wire. It needs the ADDRESSED command form — the variant carries a SessionId and the staging map moves off the single App onto the session — because drilling writes drill_state, which gotchas 30 and 38 rule session-local and therefore unaddressable by a nullary Command. That is ADR 001 layer 2, so this is blocked on improvise-7b8v rather than independent. The seam is ready: App::resolve_aggregate, CellId::Aggregate emission and the type-level guarantee that Drill takes an Aggregate and nothing else all exist and are tested.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-gd28","depends_on_id":"improvise-7b8v","type":"blocks","created_at":"2026-09-20T16:20:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7fce3874-00de-5f06-a481-e66fb873f24f","issue_id":"improvise-gd28","author":"wave3-webui","text":"BROWSER EVIDENCE, gathered under Chromium rather than read. The user reported\n\"drill also doesn't work in the webui: it was never confirmed working.\" This\nticket is the answer; nothing here asks to change the design. Confirming the\nticket's own claims empirically, plus one asymmetry it does not record.\n\nWHAT WORKS, verified in the running browser and not inferred:\n - project::grid_of emits CellId::Aggregate and the client RECEIVES it. In\n render_state: {\"Aggregate\":\"750ab1f5ffa96d5e00f605b3c8e36aac\"}.\n - render.rs::editable correctly answers false for it (ADR 002), so the page\n offers no editor on an aggregate.\n - resolve.rs reaches the CellId::Aggregate arm of edit_or_drill.\n - The TUI path is intact: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs,\n ui/effect.rs and ui/app.rs. So this is isolated to the client/wire and is\n NOT a general regression.\n\nTHE ONE MISSING LINK, confirmed: reduce.rs contains no `Drill` variant at all —\nneither Command nor ModelCommand. `grep -n Drill crates/improvise-protocol/src/\nreduce.rs` returns nothing. The ticket's \"what is missing is the command\" is\nexactly right, and the seam really is ready.\n\nTHE ASYMMETRY THIS TICKET DOES NOT RECORD, and the reason it reads to a user as\nbroken rather than as refused — the two entry points fail DIFFERENTLY:\n\n - `i` / `a` on an aggregate declines OUT LOUD. edit_or_drill answers the\n Aggregate arm with Command::SetStatus(\"drilling into an aggregate needs the\n host — not in the browser yet\"). The user sees why.\n\n - `\u003e` (drill-into-cell) is SILENT. It is a command name resolve::step does not\n handle, so it falls through to KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e\n Host::handle, whose Key arm is an explicit `Vec::new()`. Gotcha 47: an\n upstream key is a black hole, not a slow path. Pressing `\u003e` produces no\n status, no mode change, nothing at all.\n\n`\u003e` is the key the help hint advertises for drill (\"\u003e:drill\" in the Normal-mode\nhint line), so the advertised gesture is the silent one and the honest refusal\nis on a key the user was not told to press. Whoever picks this ticket up may\nwant to route drill-into-cell through the same declining arm in the interim, so\nthe gap SAYS something on the key that names it — that is a one-line change and\nis independent of the addressed-command design work. I have not made it: it is\nthis ticket's call, not mine.\n\nA FIXTURE FACT worth knowing before anyone tries to reproduce: web/demo.improv\nhas NO aggregate cell. Gotcha 46 puts exactly one category on each visible axis\nprecisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So\non the shipped demo you cannot reach the refusal at all without first changing\nan axis — which plausibly explains \"never confirmed working\". To reproduce:\npress T, select the Quarter tile, Enter once (Page -\u003e None); every cell is then\nan aggregate.\n\nREGRESSION GUARD LANDED: harness/tests/drill.spec.js, two tests, documenting the\nrefusal honestly rather than leaving folklore. It makes the aggregate through\nthe tile bar the way a user would and ASSERTS the precondition (td.editable\ncount 0) so it cannot pass against a grid of Records. Both proved by injection:\nsilencing the aggregate arm fails both tests (the second on its\ncheck_the_checker control, which is the point — with nothing answering, the\ncontrol can no longer distinguish \"`\u003e` is silent\" from \"the page is dead\");\nmaking `\u003e` resolve fails only the second. The tests assert what is TRUE today,\nso they will fail the day drill lands over the wire — which is the correct\nsignal to whoever closes this ticket, not an obstacle.\n","created_at":"2026-09-20T20:14:35Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf","title":"Read arbitrary SQLite databases as a data source","description":"SUPERSEDED BY context/adr/003-live-backed-models.md, and rewritten 2026-09-19 to match it. The original text framed this as an open choice between import-once and live-backed, and said \"READ-ONLY for now\" as though that were a scoping convenience. ADR 003 decided both, and in the opposite direction from what this ticket assumed.\n\nWHAT THE ADR DECIDED, and this ticket now means:\n\n * LIVE-BACKED, not import-once. Import-once was explicitly REJECTED as the only answer -- it makes the model a snapshot, so source changes are invisible until re-import and the data must fit in memory. The model is a view over the source and queries go to it as the user pivots.\n * READ-ONLY IS A FEATURE, not a limitation accepted reluctantly. Pointing a pivot tool at a production database and being unable to write to it is the point. No write-through, no overlay, no reconciliation.\n * THEREFORE: Model becomes a FACADE OVER A DATA SOURCE, of which today's in-RAM DataStore is one implementation and a SQL query engine is another. That is a change to what Model IS, not to where its bytes come from.\n\nSTATUS: NOT A PRIORITY, per the ADR's own header. Deferred 2026-09-19 during the whole-backlog campaign, by user ruling, together with improvise-qddo and improvise-mjy7. Nothing here outranks the browser work. It is written down so the option stays open, because several decisions made recently could foreclose it by accident.\n\nTHE THREE THINGS THAT MAKE IT TRACTABLE, from the ADR, which should shape the design rather than be discovered late:\n 1. Aggregation is ALREADY the interface and it is ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set; those are exactly SQL's aggregates. GridLayout asks for a VALUE, not for the rows behind it. The pushdown is not a translation layer to be invented.\n 2. Formula results are ALREADY separate from stored data. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore. Raw data is queried; derived data stays in RAM. Formulas do not become SQL.\n 3. Clients are ALREADY insulated. A client sees CellDisplays and never learns where a value came from, so live-backing and the browser deployment compose rather than compete. A browser never needs SQLite at all.\n\nTHE CONSEQUENCE NOBODY ASKED FOR, and the reason this is three tickets rather than one: a live-backed document splits into OUR configuration and THEIR data, and .improv does not distinguish them. The format grows a SOURCE SPECIFICATION where a live model would otherwise have a ## Data section, and categories become derived (SELECT DISTINCT) rather than declared. improv.pest is the single source of truth, so the grammar comes first.\n\nCHILDREN (all filed and deferred alongside this one):\n * the .improv source specification and the grammar change\n * the SQLite source implementation, native-only and feature-gated\n * projection freshness for a source an external writer can change\n\nWHAT ADR 001 DOES AND DOES NOT OWE THIS: a read-only model has no concurrent EDITS, so the basis check simply does not apply -- the guarantee is scoped to documents improvise owns, which is what it always meant. What remains is a different and smaller question, which is the freshness child.\n\nTHE MACHINERY TO EXPRESS READ-ONLY ALREADY EXISTS, built for something else: ADR 002 makes SetCell take a CellId::Record and nothing else, so a source that projects no Record ids makes a write UNCONSTRUCTIBLE by the client -- no runtime check, no error path, no read-only dialog. One design consequence for the source trait follows: writes must NOT be in the trait a query source implements, or a read-only source has to fail at runtime for a method it structurally cannot serve.\n\nTHE DEPENDENCY CONSTRAINT THAT CARRIES OVER FROM improvise-9ix: improvise-io is in the wasm bundle (the wasm job in .github/workflows/ci.yml gates it) and libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper. Native-only and feature-gated, with the wasm job passing untouched. Verify the gate rather than assuming it.\n\nWHAT THIS IS NOT: improvise-9ix, which proposed SQLite as improvise's OWN PERSISTENCE FORMAT and was declined on measurements (22% larger than .improv, 4.7x gzipped, a second implementation of the pest grammar). Reading someone else's database has no round-trip obligation and no grammar duplication.\n","notes":"\n--- SUPERSEDED IN FRAMING by ADR 003 (user, 2026-09-19) ---\nThe user chose LIVE-BACKED over import-once, and the goal is stated as: have\nTHE OPTION of essentially using improvise as a DB CLIENT. NOT A PRIORITY --\nnothing here outranks improvise-1ey. Re-prioritised P2 -\u003e P3.\n\nSo this ticket read-only import framing is not the target shape. See\ncontext/adr/003-live-backed-models.md. The import-once seam it described\n(csv_parser -\u003e Vec\u003cValue\u003e -\u003e analyze_records) is still accurate and still\nuseful, but as ONE implementation of a source rather than as the answer.\n\nWHAT SURVIVES UNCHANGED and is worth keeping from this ticket: the type-mapping\nnotes (SQLite columns carry declared types, which is BETTER input than CSV\nstring sampling -- detect_date_format currently guesses a date by pattern-\nmatching text; dates may be TEXT, INTEGER epoch or REAL julian, so detect\nrather than assume; NULL is ABSENCE not the string \"null\", per improvise-ych),\nand the wasm constraint (improvise-io is in the bundle, libsqlite3-sys does not\nbuild for wasm32 under this repo nix cc-wrapper, so any SQLite dependency is\nfeature-gated and native-only).","status":"deferred","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-20T00:14:11Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i9lt","title":"View::axis_of panics for a category the view has not seen","description":"Noticed during improvise-35e (2026-09-19), reported not fixed per the mention-do-not-fix rule for pre-existing issues.\n\nView::axis_of(cat) panics for a category the view has never seen, and View::tileable_cats admits exactly such categories -- it filters on try_axis_of(c).is_none_or(...). So the pairing is a live panic waiting for the right view state.\n\nThe old TileBar called axis_of over that list. The migrated widget cannot panic there any more, because tile_entries uses try_axis_of(...).unwrap_or(Axis::None) -- so the reachable path is gone and no failing test was written, since the old path no longer exists to fail.\n\nWorth a guard anyway: either make axis_of total (returning Axis::None like tile_entries does) or make it private and force callers through try_axis_of. A panicking accessor over a list built to include the panicking case is a trap for the next caller, and the next caller will not have the migration to protect them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:25:57Z","closed_at":"2026-09-20T00:25:57Z","close_reason":"The reachable path is gone. improvise-edp migrated CategoryContent to take (\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e) instead of \u0026View, and the cache is built with try_axis_of(...).unwrap_or(Axis::None) -- so the widget that called view.axis_of(name) over a list that admits unseen categories no longer exists. Same resolution as the TileBar path in improvise-35e.\n\nView::axis_of itself is unchanged and still panics for a category the view has not seen. Left deliberately: it is a pre-existing API sharpness with no remaining caller that can reach it, and the mention-do-not-fix rule applies. If it acquires a new caller, make it total (returning Axis::None like the cache does) or make it private and force callers through try_axis_of -- a panicking accessor over a list built to include the panicking case is a trap for whoever arrives next.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -256,7 +276,7 @@ {"_type":"issue","id":"improvise-jjx","title":"Scrolled grid suppresses first visible row-header label","description":"src/ui/grid.rs:338-350: repeat-suppression shows a row-header sub-label only when ri == 0 or the prefix differs from data_row_items[ri-1]. ri is the absolute data-row index (starts at row_offset, grid.rs:269), so when the view is scrolled the first visible row compares against the off-screen row above and its label is blanked — the user loses the group context at the top of the viewport. Fix: compare against the previous *visible* row, i.e. always show labels for the first rendered data row. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:38Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-byz","title":"records_display returns Some(\"\") for out-of-bounds columns instead of None","description":"crates/improvise-core/src/view/layout.rs:186-202: records_display() calls col_label(col), which returns an empty string for out-of-bounds indices; the empty string then fails the == \"Value\" check, is looked up as a category name, and the function returns Some(\"\") via unwrap_or_default(). cell_key() (~line 396) explicitly returns None for an empty col_label — records_display should be symmetric so view/data mismatches are not masked. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:35Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zgb","title":".improv formula loader mis-splits category suffix via rfind(\" [\")","description":"crates/improvise-io/src/persistence/mod.rs:403-411: the formulas_section walker finds the [Category] suffix with raw.rfind(\" [\") + ends_with(']'). Two failure modes: (1) a pipe-quoted identifier at the end of the formula containing ' [' (e.g. |Revenue [USD]|) is mis-split, treating part of the quoted name as the category; (2) a suffix written without a leading space (X=Y[Cat]) is not recognized and the whole line is parsed as a _Measure formula, losing the target category. Fix: move the category suffix into the pest grammar instead of string scanning. Write failing tests first per AGENTS.md. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:32Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:11:26Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-drg","title":"vb4 step 5: narrow Effect::apply signatures where one-slice","description":"Optional follow-up to vb4 structural split. For each impl Effect for Foo, audit which slice(s) it mutates. If apply only touches ModelState (or only ViewState), narrow its signature from fn apply(\u0026self, app: \u0026mut App) to fn apply(\u0026self, model: \u0026mut ModelState) (or ViewState). Cross-cutting effects (read layout, change mode that triggers layout rebuild, etc.) stay on \u0026mut App. Improves type-level documentation and enables finer-grained effect tests. Lower priority — vb4's acceptance criterion (4) explicitly allows leaving cross-cutting on \u0026mut App.","design":"Hidden trait-design decision surfaced during vb4 step 4 review: the issue's\nphrasing — \"narrow its signature from fn apply(\u0026self, app: \u0026mut App) to\nfn apply(\u0026self, model: \u0026mut ModelState)\" — is not directly possible. You\ncannot change one Effect impl's signature without changing the Effect\ntrait. Three approaches, none picked:\n\n(A) Split into multiple traits — ModelEffect / ViewEffect / AppEffect.\n Each impl becomes the narrower thing it actually is. Cost: ~50\n existing impls reorganize across three traits, and Vec\u003cBox\u003cdyn Effect\u003e\u003e\n has to become an enum wrapper (or three parallel queues) to keep\n apply_effects working. Highest fidelity to the issue's literal wording.\n\n(B) Keep one trait, rebind inside the impl body — e.g.\n fn apply(\u0026self, app: \u0026mut App) { let view = \u0026mut app.view_state; view.mode = ... }\n so the slice is visible at the call site without changing the\n signature. Lowest disruption. Doesn't strictly satisfy acceptance\n criterion (1) (a \"narrower borrow\") — only the body is narrower.\n\n(C) Default-method dispatch on a single trait — Effect grows\n apply_view(\u0026mut ViewState) and apply_model(\u0026mut ModelState) with\n default impls that fall through to apply(\u0026mut App). Each impl\n overrides only the method that matches its slice. Middle ground:\n type-level narrowing for impls that opt in, \u0026mut App stays available\n for cross-cutting effects. Wraps cleanly around the existing\n apply_effects loop.\n\nPicking (A) commits to the architecture for downstream effect tagging\n(improvise-mae) and the protocol extraction (improvise-cqi). (B) and (C)\nleave that decision unmade. Recommend deciding before claiming.","acceptance_criteria":"Each Effect whose apply touches only one slice takes \u0026mut ModelState or \u0026mut ViewState; cross-cutting effects stay on \u0026mut App, which vb4's own acceptance explicitly allows. The win is type-level documentation and finer-grained effect tests, not line count. CAUTION, new since this ticket was filed: App::apply_effects now reads dirtiness, layout_version and the record fingerprints off the EffectKind (improvise-gv9r, gotcha 40) — narrowing a signature must not put an effect somewhere that loop cannot see.","notes":"DECLINED 2026-09-20 (wave 2), with counts. Reasoning in context/code/command.md,\n'Effect::apply stays on \u0026mut App'. NOTE: this bead carries the `human` label, so\nthe decline is a recommendation the user may overturn; it is recorded rather than\nsmuggled.\n\nCOUNTED over the 49 `impl Effect` blocks in ui/effect.rs:\n now (this branch) 8 view_state-only, 9 model_state-only, 32 cross-cutting\n base 52507a6 9 view_state-only, 16 model_state-only, 24 cross-cutting\nThe narrow set fell from 25 to 17 in one wave. The mover was improvise-9mo9,\nwhich made nine reshaping effects call app.reset_cursor(): a reshape lives in\nmodel_state.workbook and the cursor it invalidates lives in ViewState, so the\neffects that matter most are precisely the ones that reconcile the two slices.\nNarrowing bets against the direction the code moves in.\n\nFOUR GROUNDS\n1. The narrow set is small and shrinking, for that architectural reason.\n2. The type-level documentation already exists and is CHECKED: Effect::kind()\n has no default (gotcha 3) and apply_effects reads that one answer three times\n (gotchas 40, 45). A narrowed signature is a second statement of the same fact,\n free to disagree -- the defect, not the cure.\n3. Every offered mechanism weakens a guard. (A) three traits needs three queues\n (destroys batch order, which gotchas 21/23/43 lean on) or an enum wrapper (a\n fourth classification beside EffectKind). (C) default methods give `apply` a\n DEFAULT, exactly what kind() does without and for the same reason: overriding\n apply_view on an effect that needs the model compiles and writes the wrong\n slice silently; overriding neither compiles as a no-op. (B) does not meet the\n ticket's own acceptance criterion.\n4. \"Finer-grained effect tests\" is not blocked: effect.rs's 64 tests already call\n Effect.apply(\u0026mut app) against a one-line App::new(wb, None) fixture.\n\nWHAT IS NOT DECLINED: approach (B), rebinding the slice at the top of an apply\nbody, remains available as a per-function style choice. It is not mandated.\nUSER CONFIRMATION 2026-09-20: the decline is CONFIRMED and the bead stays closed. This bead carries the 'human' label, so the WU-N agent correctly recorded its decline as a recommendation rather than acting on it unilaterally; the user was shown the counts (8 view-only / 9 model-only / 32 cross-cutting of 49, down from 9/16/24 at the wave base) and all three approaches (A: three traits; B: rebind in the body; C: default-method dispatch), and chose to confirm the decline.\n\nThe reasoning the user accepted: the classification ALREADY EXISTS and is checked — Effect::kind has no default and apply_effects reads that one answer three times — so a narrowed signature is a second statement free to disagree with it. Three traits need three queues (destroying batch order) or an enum wrapper (a fourth classification). Default methods would give apply a DEFAULT whose wrong answer is silent, which is precisely what kind()'s no-default rule exists to prevent.\n\nThe fact worth carrying past this bead: improvise-9mo9 moved nine effects from 'touches one slice' to 'cross-cuts' INSIDE THE SAME BRANCH that declined this, and that is not a coincidence to route around. The App exists because the grid's shape lives in model_state.workbook and the cursor into it lives in ViewState, so the effects that matter most are exactly the ones that RECONCILE the two slices. A design that rewards single-slice effects would have been pulling against the repo's actual centre of gravity.\nCOUNTS CORRECTED 2026-09-20, after adversarial review of PR #49. The decline\nstands; the numbers it was argued on were wrong twice and the user confirmed it\npartly on them, so the correction is recorded loudly rather than restated.\n\nPUBLISHED (wrong): 49 effects; 8 view-only, 9 model-only, 32 cross; narrow set\nfell 25 -\u003e 17.\nCORRECT: 57 effects; 16 view-only, 9 model-only, 32 cross; narrow set\nfell 32 -\u003e 25. Base 52507a6 was 16/16/25.\n\nTwo errors. (a) It counted only the 49 `impl Effect` blocks and omitted the 8\n`simple_effect!` invocations, which also expand to an impl and are ALL view-only.\n(b) It misclassified AddItem at the base commit, because the extraction regex\ncould not see `app` followed by a newline and `.model_state` -- which is\ndoctrine(derive_then_verify) catching the person quoting it, with the exact\nfailure that doctrine was written about.\n\nWHAT THIS COSTS THE ARGUMENT: 25 of 57 is 44% of effects that could narrow, not\nthe 35% claimed; and the fall is 22%, not 32%. The direction is unchanged and is\nstill the point -- the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup,\nTogglePruneEmpty, RemoveItem and RemoveCategory, every one an effect\nimprovise-9mo9 gave a reset_cursor, so the shrinkage is architectural rather than\nincidental. But the headline was overstated by about half again, and anyone\nre-opening this should argue from 32 -\u003e 25 of 57.\n\nThe other three grounds for the decline are untouched by this: the classification\nalready exists and is checked; three traits break batch order and default methods\nreintroduce a silent default; and finer-grained tests are not blocked.","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-04-26T18:16:52Z","created_by":"jeeves","updated_at":"2026-09-20T18:57:42Z","started_at":"2026-09-20T18:08:16Z","closed_at":"2026-09-20T18:11:26Z","close_reason":"Closed","labels":["area:command","human"],"dependencies":[{"issue_id":"improvise-drg","depends_on_id":"improvise-99k","type":"blocks","created_at":"2026-04-26T11:17:16Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1ud","title":"Document RecordsEditing variant in repo-map.md","description":"context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\\n\\nTrivial doc fix.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:16Z","created_by":"Ed L","updated_at":"2026-06-10T04:05:13Z","closed_at":"2026-06-10T04:05:13Z","close_reason":"Done in commit 0fe04de (2026-06-09 repo-map refresh): AppMode list now reads 16 variants including RecordsNormal and RecordsEditing, and notes MinibufferConfig + the ModelState/ViewState split.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8fy","title":"Introduce simple_effect! macro for single-field setter effects","description":"crates/improvise-command/src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(\u0026self, app: \u0026mut App) { app.field = self.0.clone(); } }.\\n\\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\\n\\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:14Z","created_by":"Ed L","updated_at":"2026-09-19T20:35:54Z","closed_at":"2026-09-19T20:35:54Z","close_reason":"simple_effect! macro added beside the Effect trait; eight single-field ViewOnly setters declared through it (-42 lines net). The macro HARD-CODES EffectKind::ViewOnly by design: an effect other clients must see is not 'simple' and keeps a hand-written impl, which is how gotcha 3's no-default-kind rule survives a macro at all. SetSelected (two fields) and SetNumberFormat (a Model change) stay hand-written. Existing effect tests already cover all eight, including view_only_effects_leave_shared_state_alone.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wu","title":"Extract axis label helper in view/layout.rs","description":"crates/improvise-core/crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\\n\\nFix: fn axis_label(items: \u0026[AxisEntry], idx: usize) -\u003e String, call from both. ~14 LOC.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T18:54:13Z","created_by":"Ed L","updated_at":"2026-08-14T01:11:07Z","closed_at":"2026-08-14T01:11:07Z","dependencies":[{"issue_id":"improvise-1wu","depends_on_id":"improvise-ai7","type":"supersedes","created_at":"2026-08-13T18:11:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -282,10 +302,14 @@ {"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-r08u","title":"IS and WHERE differ on trailing word boundary in formula.pest","description":"improvise-4zl7 gave `IS` a trailing word-boundary lookahead: `is_expr` is compound-atomic so `Region ISLAND` no longer parses as `Is(Region, LAND)`. `^\"WHERE\"` in `where_clause` and `inline_where` still has the identical hole — `SUM(Rev WHEREAS x = y)` would read as a WHERE on a category `AS`. It is far less exposed than IS was (WHERE sits in one fixed position after an expression, where IS follows any identifier in expression position), which is why it was left alone rather than fixed in the same pass: it is pre-existing and changing it is a grammar change with its own round-trip risk.\n\n## Steps to Reproduce\n\n1. `parse_formula(\"X = SUM(Rev WHEREAS x = y)\", \"Cat\")`\n2. It parses, reading `AS` as the filter category, where it should be a syntax error.","acceptance_criteria":"Either `^\"WHERE\"` gets the same `!bare_char` lookahead `IS` has and a test asserting `WHEREAS` does not parse as a WHERE clause, or the asymmetry is declined in writing in context/code/formula-and-io.md with the reason. The grammar-walking generator proptests must still pass either way.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:16:46Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:16:46Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1l9o","title":"furnished_client hand-builds a basis instead of taking the server's","description":"A nit from the adversarial review of PR #50, explicitly not worth a push at\nthe time.\n\nlockstep.rs's furnished_client builds its BasisCache by walking the client's\nown grid for Record cells and calling basis::published(0, 0, records) with\nevery version hard-coded to 0. That is a small re-implementation of\nproject.rs::basis_update's record selection, in a test whose entire purpose is\nto rule out re-implementations.\n\nIt is harmless today -- a fresh App really is at generation 0, layout 0, and\nevery record at version 0 -- which is exactly why it will stop being harmless\nsilently if any of those three stops being true.\n\nThe reviewer demonstrated the alternative while checking something else: seed\nthe ClientState from a real Sessions::connect snapshot and let the server's own\nProjection::Basis populate the cache. That removes the fabrication and makes\nthe fixture a client the server actually produced.\n\nNote the same fabrication exists in client_ready_to_edit, which predates this\nPR, so a fix should take both.","acceptance_criteria":"The lockstep fixtures get their basis from a real Sessions::connect\nsnapshot rather than constructing one. Both furnished_client and\nclient_ready_to_edit, since they share the fabrication. The tests that use\nthem must still pass unchanged -- if seeding from the server changes an\noutcome, that is a finding about the fixture and worth stopping for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:58:19Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:58:19Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-thpe","title":"help::compact discriminates a chord by parsing a string it formatted","description":"Finding 3 of the adversarial review of PR #50. Not live-broken and\ndeliberately not fixed in that PR.\n\nimprovise_protocol::help::compact drops chorded keys from a status-bar hint by\ntesting whether the formatted label starts with \"Ctrl-\" or \"Alt-\". The\nreviewer enumerated every format_key_label output and none begins with either,\nso there is no false positive today.\n\nThe defect is the shape, not the behaviour. keymap.rs::labels_for HAS the\nstructured fact -- it matches KeyPattern::Key(code, mods) and then throws mods\naway into a string -- and compact then reconstructs a worse version of it by\nparsing. A future modifier spelled any other way (Super-, Cmd-, a bare glyph)\nis silently not a chord, lands in the status bar, and nothing fails.\n\nThe fix is to carry the fact rather than re-derive it: either Hint keeps the\nKeyModifiers beside each label, or labels_for returns (KeyCode, KeyModifiers)\npairs and help.rs formats at the end. The second is tidier and is what\nwhich_key already does with key_label(code, *mods).\n\n## Steps to Reproduce\n1. Add a modifier to keymap.rs::key_label spelled with any prefix other than\n \"Ctrl-\" or \"Alt-\" -- \"Super-x\", say.\n2. Bind a command advertised by a mode's hint to that chord only.\n3. hint_line puts it in the status bar. Nothing fails.","acceptance_criteria":"compact stops parsing a string to learn something the keymap already\nknew: either Hint carries the KeyModifiers beside each label, or labels_for\nreturns (KeyCode, KeyModifiers) and help.rs formats last. Proved by adding a\nmodifier spelled differently and watching a test fail -- the point is that\ntoday it would not.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:36Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:45:36Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-20T18:48:10Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T18:14:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-20T18:13:31Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:14:01Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"TWO ADDITIONS, both instances of this ticket's one fact rather than new ones.\n\nFIRST, WHAT A CHECK MUST ACTUALLY DO — the part that gets lost, and the reason\nthis ticket is not a one-liner. Counting the four `.ap-player` elements PROVES\nNOTHING. `autoPlay` is false, so asciinema-player does not fetch a cast until\nthe viewer presses play; a page whose every cast path 404s constructs four\nplayers, throws no page error before the click, and is indistinguishable from a\nworking one. This was verified by injecting exactly that fault (`./casts/` -\u003e\n`./cats/`): the construction-only check passed on the broken page, and pressing\nplay returned 0 rendered lines and surfaced four 404s. So the spec must press\nplay on each of the four and assert a non-zero rendered line count, and its own\nfault injection must be recorded in `context/test-evidence/`.\n\nThe same applies to the page's one functional link. Since the user's ruling\n(improvise-kh8) the landing page IS the site root and its whole job is to reach\nthe app at `./app/`; a broken link there is a dead front door, and the page\nlooks entirely correct with one. Proved able to fail by pointing it at `./aap/`\n-- 404 while nothing on screen changes -- so the spec should resolve the\ncall-to-action href and assert the response status.\n\nSECOND, THE LINE BUDGET, which is an instance of \"asserted by nothing\" and not\na separate defect. improvise-e61's criterion is \"under 200 lines\" and\n`docs/index.html` is now 199 after the root/app restructure. A criterion met by\none line, with nothing checking it, is met by accident: the next person to touch\nthe file either trims something or silently breaks it, and no test, lint or CI\njob would say so. If this ticket's spec lands, the cheapest place for the budget\nis beside it -- a line count assertion is one statement and needs no browser.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` -- the landing page renders and the casts play,\n and no test in the repository would have noticed if they had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page is\n still structurally correct until a play button is pressed.\n5. `wc -l docs/index.html` -- 199, against a stated limit of 200, checked by\n nothing.\nWITHDRAWN FROM THIS TICKET: the 199-line budget paragraph above.\n\nIt was added here on the reasoning that \"a 200-line limit met by one line with\nnothing checking it\" is an instance of this ticket's one fact. On review that is\nthe wrong home: the better answer is to rewrite improvise-e61's criterion, since\nthe line count was only ever a proxy for single file / vanilla / no build step /\nno framework — properties nothing threatens and which a line count measures\nbadly. That has been done on improvise-e61.\n\nThis ticket is about the landing page being SERVED and ASSERTED BY NOTHING, and\nit should stay that. Read the paragraph above as superseded; the rest of the\nnotes — press play, count rendered lines, resolve the call-to-action href — is\nwhat this ticket is for.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-20T18:22:56Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:36Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T16:53:34Z","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zx8t","title":"draw.rs::mode_style matches AppMode with a wildcard, so the repo map's exhaustiveness promise is false there","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt, and it is a correction to something context/repo-map.md currently promises.\n\nThe repo map says of AppMode: \"Per-variant UI facts live on the enum as exhaustive-match methods -- label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); ADD A VARIANT AND THE COMPILER NAMES EVERY ONE YOU MUST FILL IN.\"\n\nThat is TRUE of the five enum methods and FALSE of src/draw.rs::mode_style, which matches AppMode with a `_ =\u003e` WILDCARD. So a new variant silently inherits whatever the wildcard arm gives -- SavePrompt currently takes ExportPrompt's DarkGray by accident rather than by decision, which happens to be defensible and was nobody's choice.\n\nWHY IT IS P4 AND NOT HIGHER: the consequence is a colour, and the wrong colour is visible rather than silent. But the reason to fix it is that the repo map states a PROPERTY, and a property that holds in five places and not the sixth is worse than one nobody claimed -- the next person adding a variant will trust the sentence.\n\nTHE FIX IS ONE OF TWO, and they are not equivalent:\n * Make mode_style exhaustive, so the compiler does name it. Note mode_style stays in src/draw.rs deliberately, because Style is a ratatui type and improvise-protocol must not name one (gotcha 26, the AxisColor/MinibufColor pattern).\n * Or give AppMode a semantic colour method in the protocol crate -- the AxisColor treatment -- and let draw.rs do the one mapping. That is the established shape here and it would also give a browser something to use, which draw.rs's Style cannot.\n\nThe second is probably right and is the same question improvise-k099 asks about hint_text/label. Decide them together.\n","acceptance_criteria":"mode_style either matches AppMode exhaustively, or AppMode gains a semantic colour method in improvise-protocol that draw.rs maps to a ratatui Style (the AxisColor/MinibufColor shape). Either way, adding an AppMode variant fails the build until someone chooses its colour. The repo-map sentence promising compiler-named sites is true of every listed method, or it is corrected to say which one it is not true of.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:44:19Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -310,5 +334,5 @@ {"_type":"issue","id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- crates/improvise-command/src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","notes":"GROOMED 2026-09-19 — DISSOLVED PREMISE, mostly. The four persistence/mod.rs sites this ticket was built on (model.category(name).unwrap().items.values()...) no longer exist in production code; grep for unwrap().items / unwrap().groups now hits only test modules (persistence/mod.rs is #[cfg(test)] from line 706 on, workbook.rs likewise). The stated correctness win — \"prevents panics if the category lookup fails\" — is therefore void: there is no production panic left to prevent. What survives is encapsulation in test code plus cat_tree.rs:32 (cat.map(|c| c.items.len()).unwrap_or(0)), which already handles the missing category. P3 -\u003e P4 on that basis; close it if the encapsulation alone is not worth 15 LOC.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-09-19T20:52:14Z","closed_at":"2026-09-19T20:52:14Z","close_reason":"OBSOLETE -- premise dissolved, verified item by item rather than accepted from the groom's note. (1) Model::item_names would DUPLICATE the existing Model::effective_item_names, and in a worse form: the ticket's version reading category(name).items would silently omit formula targets for _Measure, which is exactly the trap gotcha 7 warns about; the existing one routes _Measure through measure_item_names and returns Vec rather than Option\u003cVec\u003e, which is also the right call under 'keep Option at the boundaries'. (2) Model::group_names has no caller inside improvise-core -- the site the ticket cites now lives in an improvise-io test. (3) Category::item_count has no in-scope caller either; the live one is improvise-command's cat_tree.rs, which already handles the missing category.\n\nThe ticket's stated correctness win -- 'prevents panics if the category lookup fails' -- is void: every unwrap().items / unwrap().groups chain in the repo is now inside a #[cfg(test)] module. The single production reader of cat.items.values() is persistence/format.rs, the serializer legitimately walking items to write them, which is not an unwrap chain and not something a getter should hide from a formatter.\n\nIf the encapsulation is still wanted for improvise-command's cat_tree.rs, that is one method for whoever owns that crate, not the three proposed here.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","notes":"CORRECTION (2026-09-20): this ticket's text says source=main branch, folder=/docs. That is the WRONG SETTING for the pipeline that landed in improvise-d31.\n\nThe correct setting is Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS. The workflow uploads a Pages artifact from pages-build and deploys it with deploy-pages@v4; a branch/folder source would serve the repo instead and silently ignore the workflow.\n\nThe workflow attempts enablement itself (configure-pages@v5, enablement: true), so this may need no human at all -- verified as NOT currently enabled (the Pages API 404s). If the run fails at configure-pages, note that default_workflow_permissions on this repo is 'read'; the job declares permissions: {pages: write, id-token: write} explicitly, which should grant above the default, but it is the first thing to check on a 403.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-09-20T04:20:51Z","closed_at":"2026-09-20T04:20:51Z","close_reason":"Done. GitHub Pages is enabled and serving: https://fiddlerwoaroof.github.io/improvise/ renders the demo grid from the in-page worker, verified by the user in Safari 27 on 2026-09-20.\n\nNote the ticket's own text was WRONG about the setting and was corrected before this closed: it said source = main branch, folder = /docs. The pipeline improvise-d31 built uploads a Pages ARTIFACT and deploys it with deploy-pages@v4, so the correct source is GITHUB ACTIONS. configure-pages@v5 with enablement: true turned it on without a human touching the setting, which answers the open question about whether the workflow token may.","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:19:45Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 126eccdd..77ccc651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,25 @@ jobs: - name: improvise-gp run: cargo check -p improvise-gp --target wasm32-unknown-unknown + # --------------------------------------------------------------------------- + # The browser JavaScript's unit tests. Its own job, deliberately: a failure + # here is a decision `web/shell.js` or `web/serial.js` got wrong, and + # reporting that as "Browser harness" would put it behind an 80 MB browser + # download and two wasm-pack builds. It needs neither — `node --test` is in + # the runtime, and this job installs nothing at all. + js: + name: Browser JS unit tests (node --test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: 22 + # The glob is the SHELL's, not node's: `web/tests/bundle/` needs a built + # wasm bundle and runs in the browser job instead. + - name: What the page decides between its boundaries + run: node --test web/tests/*.test.js + kb: name: Knowledge base consistency runs-on: ubuntu-latest @@ -169,6 +188,12 @@ jobs: # `harness/serve` — the same tree `pages-build` publishes, plus /opfs/. - name: Build the harness run: ./harness/build.sh + # The JS↔wasm boundary: every call web/app.js makes is declared by the + # bundle, with that arity. It needs the bundle and nothing else, so it + # runs here rather than in the `js` job, and before Playwright — a + # renamed export is cheaper to read here than as a blank page. + - name: The page's calls match the bundle it calls + run: node --test web/tests/bundle/*.test.js - name: Run the harness working-directory: harness run: npx playwright test @@ -249,7 +274,7 @@ jobs: pages-deploy: name: Deploy to GitHub Pages - needs: [test, wasm, browser, pages-build] + needs: [test, wasm, js, browser, pages-build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: diff --git a/context/code/clients.md b/context/code/clients.md index 167409fe..a054d12c 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -880,6 +880,11 @@ folded one at a time in the order they arrive (a cell update that overtakes its labels is dropped, not replayed), and the *first* frame out must be the viewport, because the server builds the snapshot at it. +Everything in this file that is a *decision* rather than a paint now lives in +`web/shell.js` — `socketUrl`, the `#status` composition, the watchdog, the +extent arithmetic and `gridPlan` — so that `node --test` can reach it without a +browser. The sections below describe those rules; the code is one file over. + ### Two transports, one client The wasm bundle is the SAME artifact either way (improvise-xks6: one envelope @@ -1074,6 +1079,11 @@ makes "frames that arrived before `start()` finished" an ordinary case of the same rule instead of a second code path with its own drain loop — and that loop had the race in it twice over, since `server` was set before it ran. +The chaining itself is `web/serial.js::serialize`, which is promise plumbing and +nothing else, so `web/tests/serial.test.js` can force the interleaving a browser +cannot be asked to produce on demand — including the negative arm, which runs +the same two handlers *without* the chain and shows them coming back swapped. + **The document is named in the worker's own URL** — `new Worker('worker.js?doc=demo.improv')` — the analogue of the socket server naming it in the `ws://` path. It is deliberately not a `ClientFrame`: the envelope pair @@ -1099,12 +1109,13 @@ at all — no `package.json`, no runner, nothing in CI running a single line of the bug-first rule could not be honoured for a change to either. **PR #21 declined to add regression tests for its close-listener fix on exactly that ground, and that decline is only honest because those assertions live here -instead.** So **a change to `web/*.js` belongs in this spec directory**, not in a -second ad-hoc surface beside it. +instead.** Playwright rather than a headless-DOM runner because what goes wrong in these files is a real Worker, a real WebSocket, real OPFS and real event ordering, none -of which a DOM shim has. It is deliberately small (improvise-vvl3): the point is +of which a DOM shim has. That still holds, and it is why no shim was added; what +changed is the **scope** — see the section below, which is now the answer to +"where does a change to `web/*.js` belong". It is deliberately small (improvise-vvl3): the point is to be able to run `storage_contract` against `OpfsFs` and to read `#status` out of a real page, not to grow a second test framework beside `cargo test`. The server root is `harness/serve`, built by `build.sh` — the assembled site plus the @@ -1164,3 +1175,81 @@ is invisible to a document picker. `opfs_root`/`make_directory` exist only in the harness: `OpfsFs` has no directory vocabulary at all — it stores one flat entry per document — so making a directory, to check that `refresh` skips one, has to happen outside it. + +--- + +## Where a change to `web/*.js` is tested + +**E2E and integration tests are rare and pin boundary contracts; unit tests +assume those contracts and test between them.** (User ruling, 2026-09-20.) So +the question is not "can this assertion be made cheaper" but "which boundary is +it about", and the browser half has four: + +| boundary | the contract | where it is pinned | +|---|---|---| +| `RenderModel` → DOM | every field the render model projects reaches an element | `web/tests/render_surface.test.js` (statically) **and** `harness/tests/contract.spec.js` (on the screen) | +| JS → wasm | the calls the page makes exist, with the arity the bundle declares, and their answers are painted | `web/tests/bundle/wasm_surface.test.js` (the surface) **and** `contract.spec.js`'s pointer test (the wiring) | +| page → peer | the handshake goes out first, a frame is answered whole, and a failure is named | `harness/tests/shell.spec.js`, `harness/tests/drill.spec.js` | +| worker → OPFS | `storage_contract`, run against a real `OpfsFs` | `harness/tests/opfs.spec.js` | + +Everything **between** those seams is `node --test web/tests`, and it may assume +the boundary holds rather than re-establishing it: transport selection from +`?ws=`, the two facts `#status` composes, the watchdog's arm and disarm, the +`frameCost`/extent arithmetic, the collapse glyph, the grid's plan, and the +worker's tail-promise ordering. None of it needs a DOM, so none of it is +Playwright's. + +**The seam that makes this possible is `web/shell.js` and `web/serial.js`**: +pure modules that **return a description** rather than take a DOM double. +`gridPlan(model)` answers every question `gridTable` used to answer while it was +creating elements — which row index a click carries when group headers are +interleaved, what a group header spans, whether a `null` group label is a name, +whether an empty edit buffer means an editor is open — and `app.js` is then a +loop that creates elements from it. Painting stays untested by Node; the +decision about *what* to paint stopped being untestable. + +### Why the four widgets were one bug + +The tile bar, the view list, the formula panel and the category sidebar +ignoring `category_panel_open` were reported as four broken features and fixed +as four. They were **one failure at one boundary**: a `RenderModel` field with +no element painting it. Four per-feature specs cost four page loads and catch +the fifth only if somebody writes a fifth spec; one assertion that *every* +projected field is painted catches it by construction. That assertion is cheap +enough to make twice, from different sides: + +- **`render_surface.test.js`** reads the `pub` fields off + `improvise-wasm-client/src/render.rs` and requires each one to be read by + `app.js` or `shell.js` — or named in `UNPAINTED` **with a reason**. It needs + no browser, no bundle and no build, so it fails in seconds. The list is not + an escape hatch: it is the debt, written down, and it currently holds + `doc_name`/`doc_dirty`/`doc_stored_as` (improvise-5jna — the fifth instance, + found by writing this test). +- **`contract.spec.js`** drives the demo document into each state that fills a + widget and asserts the element is *on the screen*. Node cannot see that, and + the moment a reduction would have to weaken it, it stays here. + +What is deliberately **not** in Playwright any more is what each key or click +*means*: `improvise_protocol::resolve` decides that and its tests are where the +meaning is pinned. One instance of "a resolved change repaints" is kept, because +the repaint is the boundary; the other seven were the same instance again. + +### Two numbers, and neither is the other + +Measured on one machine at `1af51c9` versus this branch, both on a private +`IMPROVISE_HARNESS_PORT`: + +| suite | tests | execution | whole job | +|---|---|---|---| +| Playwright, before | 29 (+3 skipped) | 36.9s | ~4m30s in CI | +| Playwright, after | 16 (+3 skipped) | 15.7s | ~4m30s in CI | +| `node --test web/tests` | 26 | 0.11s | seconds | + +**Cutting Playwright tests barely moves the CI number**, because roughly four +minutes of that job is three `wasm-pack` builds, `npm ci` and a browser +download — not test execution. The feedback-time win is the `js` job, which +needs **no Rust toolchain and no wasm bundle at all**, and that is a constraint +rather than an accident: the moment a `web/tests/*.test.js` file needs +`web/pkg/`, it inherits the four-minute build and the argument for it +evaporates. That is why the one test that does need the bundle lives in +`web/tests/bundle/` and runs in the browser job instead. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index eca8bee0..f68ffa00 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -862,6 +862,13 @@ at all would pass by moving neither. #### harness/tests/widgets.spec.js — the file's own scope +**The file is gone; its subject is not.** All four widgets it named were one +failure at one boundary — a `RenderModel` field with no element painting it — +and that is now asserted once, from both sides, by +`web/tests/render_surface.test.js` and `harness/tests/contract.spec.js`. What +is *not* carried over is each key's meaning, which `improvise_protocol::resolve` +decides and tests. Everything below is the history of the bug, and still true. + A user reported three things as broken: "I can't expand categories, and the Tile + View UIs don't work." None of the three was a regression. Driving the assembled site under Chromium separated them, and the separation is the point: @@ -1047,6 +1054,13 @@ this one is only meaningful *because* its neighbours assert non-empty answers. #### harness/tests/pointer.spec.js — the file's own scope +**The file is gone; one test carries it.** `contract.spec.js`'s "each clickable +surface is wired to its own index" keeps what only a browser can say — the +handler is on the element, it passes *that* element's index, and the answer is +painted — against three surfaces. What each click *means* is +`improvise_protocol::resolve`'s, and its tests are named below. The six tests' +injection stands as recorded. + The user's ruling on improvise-7ybz ("build it — one entry point per widget") and improvise-wsaw ("honour the flag"), executed in a real browser. @@ -1137,3 +1151,65 @@ That control is load-bearing and not decoration: a test asserting *nothing happened* passes against a page that has stopped listening, so it presses `i` afterwards and requires `#status` to change. Without that, this file would be folklore with a green tick. + +--- + +## `web/tests/` — the Node suite, and what was injected into it + +No `###` headings here on purpose: `--check-evidence` resolves a heading to a +Rust `fn`, and a JavaScript test name under one would read as an orphan. These +are keyed by file. + +Every test below was proved able to fail by patching the code it names, +checking the patch was on disk, running the suite and reading the failure — +never by the absence of an error. Thirteen injections, thirteen failures: + +**`shell.test.js`** — `socketUrl` stops upgrading `ws://` on an https page; +`composeStatus` returns the model fact alone (which fails five tests, because +the watchdog's messages are shell facts and vanish with it); `fail()` stops +clearing the timer, so the watchdog *pre-empts* rather than being disarmed — +the bug PR #13 fixed and PR #21 half-lost; `extentOf` stops subtracting the +frame; `frameCostOf` stops counting the column-group line. + +**`grid_plan.test.js`** — the data-row index is taken from the row's position +in `rows`, so a group header consumes one (the click coordinate bug gotcha 1 +is about); the group label is tested for truthiness, so a group legitimately +named `""` draws as "in no group"; `typing` is `Boolean(model.editing)`, so an +empty edit buffer reads as no editor open. + +**`serial.test.js`** — the chain is replaced by `Promise.resolve().then(...)`, +which is what an `async` listener does: the slow first frame is overtaken. The +suite also keeps that fault as a *passing* test in its own right, asserting the +unchained pair really does come back swapped, so the ordering test cannot be +green for want of a race. + +**`render_surface.test.js`** — a `pub sparkline: String` added to `RenderModel` +is reported unpainted; deleting `paintTiles` reports `tiles` unpainted; a +`getElementById` that names an id `index.html` has not got is reported. The +first is the shape of the bug the four widgets shared, and the list it checks +against currently names `doc_name`/`doc_dirty`/`doc_stored_as` — improvise-5jna, +which this test found rather than remembered. + +**`bundle/wasm_surface.test.js`** — `client.render_model()` renamed to +`client.render_modal()` fails both its tests. It reads the generated `.d.ts`, so +a missing bundle is an assertion failure and never a skip. + +**`web/assemble.sh`** — dropping `web/shell.js` from the copy list, with the +page's own imports as the check. The first version of that guard anchored its +`sed` at `^import`, so it could not see the **multi-line** import that is the +only one it exists for, and the injection passed: the guard was written, landed, +and blind. That is what the injection was for. + +## `harness/tests/contract.spec.js` — eight injections into the served page + +Patched in `harness/serve/app/app.js`, which is what the browser loads, so no +rebuild stands between the fault and the run. The sidebar ignoring +`category_panel_open`; the tile bar, view panel, formula panel and help overlay +each never drawn; `click_cell` sending a constant row; `click_category` sending +a constant index; and the typed buffer drawn without its caret. All eight fail. + +The last one did **not** fail at first: the test asserted the `typing` class and +not the text, so a cell drawn blank passed. The assertion was strengthened until +the injection failed, which is the only reason it is worth having — and it is +the same lesson as the eleven tests above: a class is a flag, and a flag is not +a pixel. diff --git a/harness/playwright.config.js b/harness/playwright.config.js index 607879fe..f700b100 100644 --- a/harness/playwright.config.js +++ b/harness/playwright.config.js @@ -19,7 +19,7 @@ export default defineConfig({ trace: "retain-on-failure", }, // Chromium + Firefox run the OPFS battery; WebKit has no OPFS at all and is - // kept for the day it does. The shell spec is Chromium-only: clients.md. + // kept for the day it does. Every other spec is Chromium-only: clients.md. projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] } }, { diff --git a/harness/tests/contract.spec.js b/harness/tests/contract.spec.js new file mode 100644 index 00000000..f054a8a3 --- /dev/null +++ b/harness/tests/contract.spec.js @@ -0,0 +1,161 @@ +// The RenderModel→DOM boundary and the pointer's half of the JS→wasm one: the +// two contracts only a browser can establish. Why these and not more, and what +// moved to `node --test web/tests`: context/code/clients.md. + +import { test, expect } from "@playwright/test"; + +async function openApp(page) { + await page.goto("/app/index.html"); + await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); + await page.locator("#grid").focus(); +} + +test.describe("every field the render model projects reaches the screen", () => { + // The four widgets a user reported as "not working" — the tile bar, the view + // list, the formula panel and the category sidebar ignoring its flag — were + // ONE failure at this boundary: a projected field with no element painting + // it. This is that contract, and it is what catches the fifth. + test("the widgets the demo document fills are drawn, in the state that fills them", async ({ + page, + }) => { + await openApp(page); + + // `rows`, `col_levels`, `row_levels`: the grid itself. web/demo.improv is + // Product on row, _Measure on column (gotcha 46). + await expect(page.locator("#grid thead th", { hasText: "Revenue" })).toBeVisible(); + await expect(page.locator("#grid tbody th").first()).toHaveText("Espresso"); + await expect(page.locator("#grid tbody td").first()).not.toHaveText(""); + + // `mode`, `hint`, `view_name`, `status`: the header's four facts. + await expect(page.locator("#mode")).toHaveText("NORMAL"); + await expect(page.locator("#hint")).not.toHaveText(""); + await expect(page.locator("#view-name")).toHaveText("Plan"); + await expect(page.locator("#status")).toContainText("in-page worker"); + + // `tiles`: drawn on every frame, as the TUI draws them. The axis label is + // `Axis::short_label` — "Pag", and "·" for None, an answer not a stub. + await expect(page.locator("#tiles .tile")).toHaveCount(5); + await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText( + "Product Row", + ); + + // `categories` + `category_panel_open`: the flag is the client's answer, + // and the sidebar was drawn always until it read it (improvise-wsaw). + await expect(page.locator("#categories")).toBeHidden(); + await page.keyboard.press("Shift+C"); + await expect(page.locator("#categories")).toBeVisible(); + await expect(page.locator("#categories li").first()).toBeVisible(); + await page.keyboard.press("Shift+C"); + await expect(page.locator("#categories")).toBeHidden(); + + // `views`: empty unless open, so the page asks no question about the mode. + await expect(page.locator("#views")).toBeHidden(); + await page.keyboard.press("Shift+V"); + await expect(page.locator("#views li")).toHaveCount(2); + await expect(page.locator("#views li.active")).toContainText("Plan"); + // The axis line is the shared `view_axis_summary`; its absence would mean + // a second derivation had crept in. + await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText( + "R:Product C:Quarter", + ); + await page.keyboard.press("Escape"); + + // `formulas`: the third of `Panel::ALL`, on the same terms. + await page.keyboard.press("Shift+F"); + await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]); + await page.keyboard.press("Escape"); + + // `help`, `help_page`, `help_titles`: the rows and their keys are + // `improvise_protocol::help`'s, the same ones the TUI draws. + await expect(page.locator("#help")).toBeHidden(); + await page.keyboard.press("?"); + await expect(page.locator(".help-tab.active")).toHaveText("Welcome"); + await expect(page.locator(".help-body dt").first()).toBeVisible(); + await page.keyboard.press("l"); + await expect(page.locator(".help-tab.active")).toHaveText("Navigation"); + await page.keyboard.press("Escape"); + await expect(page.locator("#help")).toBeHidden(); + + // `editing`: what has been typed, drawn into the selected cell with a + // caret. The decision that an editor is open is made in Rust. + await page.keyboard.press("i"); + await expect(page.locator("#grid td.typing")).toHaveCount(1); + // The class alone would pass against a cell drawn blank: what is on the + // screen is the buffer AND the caret. + await expect(page.locator("#grid td.typing")).toContainText("▏"); + await page.keyboard.press("Escape"); + await expect(page.locator("#grid td.typing")).toHaveCount(0); + }); + + // The other half of the same contract: a change the client resolved must + // reach the pixels, not merely the cache. One instance is enough — what + // each key MEANS is `improvise_protocol::resolve`'s, and tested there. + test("a resolved change repaints the widget it moved and the grid with it", async ({ + page, + }) => { + await openApp(page); + const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); + const rowHeaders = page.locator("#grid tbody th"); + await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); + + await page.keyboard.press("Shift+T"); + for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight"); + await expect(product).toHaveClass(/selected/); + await page.keyboard.press("c"); + + await expect(product).toHaveText("Product Col", { timeout: 10_000 }); + // The label alone would be the bar reporting an axis the document has not + // got: the grid must have moved with it. + await expect( + page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(), + ).toBeVisible(); + await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); + }); +}); + +test.describe("a pointer event reaches the client and its answer is painted", () => { + // improvise-7ybz. What a click MEANS is resolved in Rust and tested in + // `improvise_protocol::resolve`; what this pins is the wiring — the handler + // is on the element, it passes THAT element's index, and the result repaints. + test("each clickable surface is wired to its own index", async ({ page }) => { + await openApp(page); + + // A grid cell: the coordinate is the data row, which group headers do not + // consume — `gridPlan` counts it and `web/tests/grid_plan.test.js` pins it. + const cells = page.locator("#grid tbody td"); + await expect(cells.nth(0)).toHaveClass(/selected/); + await cells.nth(4).click(); + await page.keyboard.press("Escape"); + await expect(cells.nth(4)).toHaveClass(/selected/); + await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); + // The cursor a click leaves is the one the keyboard moves from: one + // cursor, two input paths. + await page.keyboard.press("ArrowLeft"); + await expect(cells.nth(3)).toHaveClass(/selected/); + + // A tile: selects and cycles, because a click is the whole interaction. + const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); + await product.click(); + await expect(page.locator("#mode")).toHaveText("TILES"); + await expect(product).toHaveClass(/selected/); + await expect(product).toHaveText("Product Col", { timeout: 10_000 }); + await page.keyboard.press("Escape"); + + // A category row: focuses the panel, moves its cursor and activates the + // row — three things from one click, and the index must be the row's own. + await page.keyboard.press("Shift+C"); + await expect(page.locator("#categories")).toBeVisible(); + await page.keyboard.press("Escape"); + const productRow = page.locator("#categories li").filter({ hasText: "Product" }); + await expect(page.locator("#categories li.selected")).toHaveText(/_Index/); + await productRow.click(); + await expect(page.locator("#mode")).toHaveText("CATEGORIES"); + await expect(page.locator("#categories li.selected")).toHaveText(/Product/); + await expect(page.locator("#categories li.item")).toHaveText([ + "Espresso", + "Latte", + "Cold Brew", + "Pastry", + ]); + }); +}); diff --git a/harness/tests/pointer.spec.js b/harness/tests/pointer.spec.js deleted file mode 100644 index dea2fe17..00000000 --- a/harness/tests/pointer.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -// The pointer: improvise-7ybz (a click resolves in Rust) and improvise-wsaw -// (the sidebar obeys its flag). History: context/test-evidence/clients.md. - -import { test, expect } from "@playwright/test"; - -async function openApp(page) { - await page.goto("/app/index.html"); - await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); - await page.locator("#grid").focus(); -} - -/// Open the category panel the way a user does, and leave the keyboard in -/// Normal — so a later click has a focus change to make. -async function openPanelThenLeave(page) { - await page.keyboard.press("Shift+C"); - await expect(page.locator("#categories")).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(page.locator("#mode")).toHaveText("NORMAL"); - await expect(page.locator("#categories")).toBeVisible(); -} - -test.describe("the category sidebar obeys category_panel_open", () => { - // improvise-wsaw. The flag existed from the start and app.js never read it, - // so the sidebar was drawn always and `C` changed only the mode label. - test("the sidebar is hidden until C, and C hides it again", async ({ page }) => { - await openApp(page); - await expect(page.locator("#categories")).toBeHidden(); - - await page.keyboard.press("Shift+C"); - await expect(page.locator("#categories")).toBeVisible(); - await expect(page.locator("#mode")).toHaveText("CATEGORIES"); - await expect(page.locator("#categories li").first()).toBeVisible(); - - // The same key closes it, because `toggle-panel-and-focus` closes only - // when the panel is already both open AND focused. - await page.keyboard.press("Shift+C"); - await expect(page.locator("#categories")).toBeHidden(); - await expect(page.locator("#mode")).toHaveText("NORMAL"); - }); -}); - -test.describe("clicking the category panel", () => { - // improvise-7ybz. One click means three things — focus the panel, move its - // cursor, activate the row — and all three are resolved in Rust. - test("a click focuses the panel, moves the cursor and expands the row", async ({ - page, - }) => { - await openApp(page); - await openPanelThenLeave(page); - - const product = page.locator("#categories li").filter({ hasText: "Product" }); - await expect(product).toHaveText(/▶ Product \(4\)/); - await expect(page.locator("#categories li.selected")).toHaveText(/_Index/); - - await product.click(); - - // All three, and the third is what the user reported as impossible. - await expect(page.locator("#mode")).toHaveText("CATEGORIES"); - await expect(page.locator("#categories li.selected")).toHaveText(/Product/); - await expect(product).toHaveText(/▼ Product \(4\)/); - await expect(page.locator("#categories li.item")).toHaveText([ - "Espresso", - "Latte", - "Cold Brew", - "Pastry", - ]); - - // Clicking it again collapses: the click mirrors Enter, which is a toggle. - await product.click(); - await expect(page.locator("#categories li.item")).toHaveCount(0); - }); - - // The other arm of `filter-to-item`: on an ITEM row the same click filters, - // because the rule is "do what Enter does here" rather than "expand". - test("a click on an item row filters, as Enter does", async ({ page }) => { - await openApp(page); - await openPanelThenLeave(page); - - await page.locator("#categories li").filter({ hasText: "Product" }).click(); - await page.locator("#categories li.item").filter({ hasText: "Latte" }).click(); - - await expect(page.locator("#status")).toContainText("Filter: Product = Latte"); - await expect(page.locator("#categories li.selected")).toHaveText("Latte"); - }); -}); - -test.describe("clicking a tile", () => { - // The tile bar is keyboard-reachable only through `T` plus arrows; a click - // is the whole interaction, so it selects AND cycles. - test("a click selects the tile and cycles its axis", async ({ page }) => { - await openApp(page); - const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); - await expect(product).toHaveText("Product Row"); - - await product.click(); - - await expect(page.locator("#mode")).toHaveText("TILES"); - await expect(product).toHaveClass(/selected/); - // Row → Column is `Axis::next`; the page does not spell the rotation. - await expect(product).toHaveText("Product Col", { timeout: 10_000 }); - // And the grid moved with it, not just the label. - await expect( - page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(), - ).toBeVisible(); - }); -}); - -test.describe("clicking a grid cell", () => { - // Before improvise-7ybz a click reached only `edit()`, so the selection — - // which every keyboard command reads — stayed wherever it had been. - test("a click moves the selection, not only the editor", async ({ page }) => { - await openApp(page); - const cells = page.locator("#grid tbody td"); - // The cursor starts at the origin, so any other cell is a real move. - await expect(cells.nth(0)).toHaveClass(/selected/); - - await cells.nth(4).click(); - // demo.improv is all Records, so the click also opened an editor; close it - // without committing and the selection must have followed the pointer. - await page.keyboard.press("Escape"); - - await expect(cells.nth(4)).toHaveClass(/selected/); - await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); - }); - - // The selection a click leaves is the one the keyboard then moves from — - // the property that makes the two input paths one cursor rather than two. - test("the keyboard continues from where the pointer left the cursor", async ({ - page, - }) => { - await openApp(page); - const cells = page.locator("#grid tbody td"); - await cells.nth(4).click(); - await page.keyboard.press("Escape"); - await expect(cells.nth(4)).toHaveClass(/selected/); - - await page.keyboard.press("ArrowLeft"); - await expect(cells.nth(3)).toHaveClass(/selected/); - }); -}); diff --git a/harness/tests/shell.spec.js b/harness/tests/shell.spec.js index 381c823c..a0f60fdf 100644 --- a/harness/tests/shell.spec.js +++ b/harness/tests/shell.spec.js @@ -1,29 +1,20 @@ -// The browser shell: the three assertions improvise-hwpi could not write, plus -// the watchdog. The history is in context/test-evidence/clients.md. +// The page↔peer boundary: the handshake, a frame answered whole, and what the +// page says when a peer fails. What the arm/disarm RULE is, and every other +// decision between these seams, is `node --test web/tests` — clients.md. import { test, expect } from "@playwright/test"; const status = (page) => page.locator("#status"); /// A peer that accepts the TCP connection and then refuses the WebSocket -/// upgrade — deterministic where a closed port is not (evidence file). -const NO_WEBSOCKET_HERE = "127.0.0.1:8099"; -const json = (v) => JSON.stringify(v); +/// upgrade — deterministic where a closed port is not (evidence file). It is +/// the harness's OWN server, so it follows the port the config chose. +const NO_WEBSOCKET_HERE = `127.0.0.1:${process.env.IMPROVISE_HARNESS_PORT ?? "8099"}`; -/// `web/app.js`'s own HANDSHAKE_TIMEOUT_MS. Two tests have to outlast it, and a +/// `web/app.js`'s own HANDSHAKE_TIMEOUT_MS. One test has to outlast it, and a /// number that silently disagreed with the page would pass for the wrong reason. const HANDSHAKE_TIMEOUT_MS = 8_000; -/// Record every distinct #status the page shows, not just the last one: several -/// of these are about a message being REPLACED. -function logStatus() { - window.__statusLog = []; - new MutationObserver(() => { - const text = document.getElementById("status")?.textContent ?? ""; - if (text && window.__statusLog.at(-1) !== text) window.__statusLog.push(text); - }).observe(document, { childList: true, subtree: true, characterData: true }); -} - test.describe("the shell reports what its peer is doing", () => { // improvise-hwpi. The fault is injected at the *peer*, not in the page, so // this exercises the real `on_frame` and the real catch. @@ -35,31 +26,15 @@ test.describe("the shell reports what its peer is doing", () => { await expect(status(page)).toContainText("bad frame", { timeout: 15_000 }); }); - // improvise-hwpi, plus the watchdog half on the same page load because it - // needs the same nine-second wait. See the evidence file. - test("a peer that cannot be reached is named, and the watchdog stays out of it", async ({ + // A socket that never opened did not "disconnect" (improvise-taxx): nothing + // on the close event can discriminate, so the page records `opened` itself. + // That it then STAYS is the watchdog's disarming, which is Node's now. + test("a peer that cannot be reached is named, and one that dropped is not confused with it", async ({ page, }) => { - await page.addInitScript(logStatus); await page.goto(`/app/index.html?ws=${NO_WEBSOCKET_HERE}`); - await expect(status(page)).not.toHaveText("", { timeout: 15_000 }); - await page.waitForTimeout(HANDSHAKE_TIMEOUT_MS + 1_000); + await expect(status(page)).toContainText("cannot reach", { timeout: 15_000 }); - const log = await page.evaluate(() => window.__statusLog); - expect(log.some((t) => t.includes("cannot reach")), `#status said: ${json(log)}`).toBe( - true, - ); - expect(log.some((t) => t.includes("sent no document")), `#status said: ${json(log)}`).toBe( - false, - ); - }); - - // The other arm, which must keep working when the first is fixed. It runs - // past HANDSHAKE_TIMEOUT_MS on purpose — see the evidence file. - test("a socket that opened and then dropped reads as disconnected, and stays that way", async ({ - page, - }) => { - await page.addInitScript(logStatus); // Closes on the page's first frame, so "it opened first" is true by // construction rather than by timer. await page.routeWebSocket("ws://127.0.0.1:8790/", (ws) => { @@ -68,28 +43,19 @@ test.describe("the shell reports what its peer is doing", () => { await page.goto("/app/index.html?ws=127.0.0.1:8790"); await expect(status(page)).toContainText("connected", { timeout: 15_000 }); await expect(status(page)).toContainText("disconnected", { timeout: 15_000 }); - - // Outlast the watchdog it was armed against, with margin. - await page.waitForTimeout(HANDSHAKE_TIMEOUT_MS + 2_000); - await expect(status(page)).toContainText("disconnected"); - - const log = await page.evaluate(() => window.__statusLog); - expect(log.some((t) => t.includes("sent no document")), `#status said: ${json(log)}`).toBe( - false, - ); }); - // Two bugs this file once described as live, one of which never existed in - // the merged tree: context/test-evidence/clients.md keeps that history. - - // The first-frame watchdog, found by reading: no per-transport check would - // catch it. The mock accepts the connection and never answers. + // The one assertion that needs the real timer: that the watchdog is ARMED + // after the handshake goes out. What it then says, and that a loud failure + // disarms it, are unit tests. The mock accepts and never answers. test("a peer that accepts and then says nothing trips the watchdog", async ({ page, }) => { await page.routeWebSocket("ws://127.0.0.1:8788/", () => {}); await page.goto("/app/index.html?ws=127.0.0.1:8788"); - await expect(status(page)).toContainText("sent no document", { timeout: 20_000 }); + await expect(status(page)).toContainText("sent no document", { + timeout: HANDSHAKE_TIMEOUT_MS + 12_000, + }); }); }); @@ -100,13 +66,11 @@ test.describe("the standalone deployment", () => { await page.goto("/app/index.html"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(status(page)).toContainText("in-page worker"); - // web/demo.improv: Product on row, _Measure on column (gotcha 46). - await expect(page.locator("#grid th", { hasText: "Revenue" })).toBeVisible(); await expect(page.locator("#grid td.editable").first()).toBeVisible(); }); - // What lands on screen is the HOST's value, not the string typed. What this - // deliberately does NOT cover: context/test-evidence/clients.md. + // What lands on screen is the HOST's value, not the string typed — the + // round trip through a real worker, which no unit test can stand in for. test("committing an edit sends one frame and shows the host's value", async ({ page, }) => { @@ -128,26 +92,3 @@ test.describe("the standalone deployment", () => { await expect(page.locator("#grid input")).toHaveCount(0); }); }); - -test.describe("the help overlay", () => { - // improvise-ktv1. `?` has entered AppMode::Help in the browser since the - // keymap was shared; what was missing was anything to draw. - test("? opens help, and its rows are the shared ones", async ({ page }) => { - await page.goto("/app/index.html"); - await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); - await expect(page.locator("#help")).toBeHidden(); - - await page.locator("#grid").focus(); - await page.keyboard.press("?"); - await expect(page.locator("#help")).toBeVisible({ timeout: 10_000 }); - // A title from improvise_protocol::help::pages(), and a keys row whose - // key came off the keymap rather than out of the page. - await expect(page.locator(".help-tab.active")).toHaveText("Welcome"); - await page.keyboard.press("l"); - await expect(page.locator(".help-tab.active")).toHaveText("Navigation"); - await expect(page.locator(".help-body dt").first()).toBeVisible(); - - await page.keyboard.press("Escape"); - await expect(page.locator("#help")).toBeHidden(); - }); -}); diff --git a/harness/tests/widgets.spec.js b/harness/tests/widgets.spec.js deleted file mode 100644 index 79b1cc8d..00000000 --- a/harness/tests/widgets.spec.js +++ /dev/null @@ -1,142 +0,0 @@ -// The three widgets a user reported as "not working": the category panel's -// expand, the tile bar and the view list. History: context/test-evidence/clients.md. - -import { test, expect } from "@playwright/test"; - -/// Open the demo document and put the keyboard where the page listens. -async function openApp(page) { - await page.goto("/app/index.html"); - await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); - await page.locator("#grid").focus(); -} - -test.describe("the category panel", () => { - // Expanding is resolved client-side and has always worked by KEYBOARD; the - // panel is nevertheless the one of the three that has DOM, so this pins it. - test("Enter on a category header reveals its items", async ({ page }) => { - await openApp(page); - const rows = page.locator("#categories li"); - - await page.keyboard.press("Shift+C"); - await expect(page.locator("#mode")).toHaveText("CATEGORIES"); - // web/demo.improv: _Index, _Dim, _Measure, Product, Quarter. Product is - // the first with items a user would recognise. The glyphs are - // `indicator()`'s, which is the page's ONE spelling and the TUI's too. - for (let i = 0; i < 3; i++) await page.keyboard.press("j"); - await expect(rows.filter({ hasText: "Product" })).toHaveText(/▶ Product \(4\)/); - - await page.keyboard.press("Enter"); - await expect(rows.filter({ hasText: "Product" })).toHaveText(/▼ Product \(4\)/); - await expect(page.locator("#categories li.item")).toHaveText([ - "Espresso", - "Latte", - "Cold Brew", - "Pastry", - ]); - - await page.keyboard.press("Enter"); - await expect(page.locator("#categories li.item")).toHaveCount(0); - }); -}); - -test.describe("the tile bar", () => { - // Nothing was DRAWN: TileBarCache was folded, `T` entered the mode and the - // axis keys reached the host, with no tile bar on screen to aim them at. - test("the tiles are drawn, and tile-select moves a visible cursor", async ({ - page, - }) => { - await openApp(page); - const tiles = page.locator("#tiles .tile"); - await expect(tiles).toHaveCount(5); - // Axis labels are `Axis::short_label`: three columns, so Page is "Pag", - // and None is "·" — an answer rather than a truncation (gotcha 26). - await expect(tiles.filter({ hasText: "Product" })).toHaveText("Product Row"); - await expect(tiles.filter({ hasText: "Quarter" })).toHaveText("Quarter Pag"); - await expect(tiles.filter({ hasText: "_Index" })).toHaveText("_Index ·"); - - // Out of tile select nothing is highlighted, as the TUI's widget decides. - await expect(page.locator("#tiles .tile.selected")).toHaveCount(0); - - await page.keyboard.press("Shift+T"); - await expect(page.locator("#mode")).toHaveText("TILES"); - await expect(tiles.nth(0)).toHaveClass(/selected/); - await page.keyboard.press("ArrowRight"); - await expect(tiles.nth(1)).toHaveClass(/selected/); - await expect(page.locator("#tiles .tile.selected")).toHaveCount(1); - }); - - // The axis change was reaching the host all along; what it could not do was - // show the result. Setting Product to Column must redraw the tile AND the grid. - test("setting a tile's axis is visible in the bar it was aimed at", async ({ - page, - }) => { - await openApp(page); - const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); - await expect(product).toHaveText("Product Row"); - - await page.keyboard.press("Shift+T"); - for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight"); - await expect(product).toHaveClass(/selected/); - // Product's four items are the grid's ROW headers to begin with. - const rowHeaders = page.locator("#grid tbody th"); - await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); - - await page.keyboard.press("c"); - await expect(product).toHaveText("Product Col", { timeout: 10_000 }); - // The tile label is not the point on its own: the grid must have moved - // too, or the bar would be reporting an axis the document does not have. - // `.first()`: Product now shares the column axis with _Measure, so the - // label appears once per header level. That it appears at all is the point. - await expect( - page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(), - ).toBeVisible(); - await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); - }); -}); - -test.describe("the view panel", () => { - // Nothing was DRAWN: ViewListCache held both views and `V` entered ViewPanel - // mode, so Enter switched views off a cursor the user could not see. - test("V lists the views, marks the active one, and Enter switches", async ({ - page, - }) => { - await openApp(page); - await expect(page.locator("#views")).toBeHidden(); - await expect(page.locator("#view-name")).toHaveText("Plan"); - - await page.keyboard.press("Shift+V"); - await expect(page.locator("#mode")).toHaveText("VIEWS"); - await expect(page.locator("#views")).toBeVisible(); - // web/demo.improv carries two views; the axis line is the shared - // `view_axis_summary`, so its absence would be a second derivation. - await expect(page.locator("#views li")).toHaveCount(2); - await expect(page.locator("#views li").nth(0)).toContainText("Default"); - await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText( - "R:Product C:Quarter", - ); - await expect(page.locator("#views li.active")).toContainText("Plan"); - await expect(page.locator("#views li.selected")).toContainText("Default"); - - await page.keyboard.press("Enter"); - await expect(page.locator("#view-name")).toHaveText("Default", { timeout: 10_000 }); - // Switching returns to Normal but leaves the panel OPEN, exactly as - // `src/draw.rs` keeps drawing it off `view_panel_open` alone. The active - // marker is what must move, and it is the fact the list exists to carry. - await expect(page.locator("#mode")).toHaveText("NORMAL"); - await expect(page.locator("#views")).toBeVisible(); - await expect(page.locator("#views li.active")).toContainText("Default"); - }); -}); - -test.describe("the formula panel", () => { - // The third of `Panel::ALL`, equally undrawn and fixed by the same mechanism. - test("F lists the document's formulas", async ({ page }) => { - await openApp(page); - await expect(page.locator("#formulas")).toBeHidden(); - - await page.keyboard.press("Shift+F"); - await expect(page.locator("#mode")).toHaveText("FORMULAS"); - await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]); - await expect(page.locator("#formulas li.selected")).toHaveCount(1); - }); -}); diff --git a/web/app.js b/web/app.js index 50356864..c1fb2f15 100644 --- a/web/app.js +++ b/web/app.js @@ -2,30 +2,26 @@ // string calls, no protocol knowledge. See context/code/clients.md. import init, { Client } from "./pkg/improvise_wasm_client.js"; +import { + COL_PX, + ROW_PX, + createShell, + extentOf, + frameCostOf, + gridPlan, + indicator, + socketUrl, +} from "./shell.js"; const params = new URLSearchParams(location.search); // present => socket, absent => in-page worker. The value is a host:port, and // the scheme is NOT the caller's to get wrong: see `socketUrl`. const WS_PEER = params.get("ws"); -/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit - * `ws://` is upgraded rather than honoured: context/code/clients.md. */ -function socketUrl(peer) { - const secure = location.protocol === "https:"; - if (/^wss:\/\//i.test(peer)) return peer; - if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer; - return `${secure ? "wss" : "ws"}://${peer}`; -} - -const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null; +const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null; const DOC = params.get("doc") ?? "demo.improv"; const SEED = params.get("seed") ?? "demo.improv"; -// Rough character metrics, used only to turn the window size into an extent. -// Being wrong here costs a row of over- or under-fetch, nothing more. -const ROW_PX = 26; -const COL_PX = 110; - const el = { grid: document.getElementById("grid"), categories: document.getElementById("categories"), @@ -42,64 +38,38 @@ const el = { await init(); const client = new Client(); -// `#status` carries two independent facts, composed rather than overwritten: -// a repaint must not quietly erase a connection error. -let shellStatus = ""; -let modelStatus = ""; -function paintStatus() { - el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · "); -} -function setShellStatus(text) { - shellStatus = text; - paintStatus(); -} +// A peer that never answers the handshake, and the two facts `#status` shows. +// Both are decided in shell.js; this is the wire to the element. +const HANDSHAKE_TIMEOUT_MS = 8000; +const shell = createShell({ + onStatus: (text) => { + el.status.textContent = text; + }, + timeoutMs: HANDSHAKE_TIMEOUT_MS, +}); // What the grid's own frame costs, in whole cells, as of the last repaint — // measured off the painted model, never predicted. context/code/clients.md. let frameCost = { rows: 1, cols: 1 }; -/** The extent this window can show, in whole DATA cells. */ -function extent() { - const box = el.grid.getBoundingClientRect(); - return { - rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows), - cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols), - }; -} - function sendViewport() { - const { rows, cols } = extent(); + const box = el.grid.getBoundingClientRect(); + const metrics = { rowPx: ROW_PX, colPx: COL_PX }; + const { rows, cols } = extentOf(box, frameCost, metrics); // `client.set_viewport`, NOT the associated `Client.viewport`: the same // frame, but it also RECORDS the window. Why that matters: clients.md. transport.send(client.set_viewport(rows, cols)); } -// A peer that never answers the handshake. Deliberately about the *first -// frame*, not either peer's own failure modes — context/code/clients.md. -const HANDSHAKE_TIMEOUT_MS = 8000; -let watchdog = null; -function armWatchdog(message) { - clearTimeout(watchdog); - watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS); -} - -// A peer that failed *loudly* has already said something more precise, so the -// watchdog must be DISARMED and not merely pre-empted (PR #13 review). -function setShellFailure(text) { - clearTimeout(watchdog); - watchdog = null; - setShellStatus(text); -} - /** One frame, one fold, in arrival order. Never batch or reorder these. */ function onFrame(text) { - clearTimeout(watchdog); + shell.sawFrame(); try { client.on_frame(text); } catch (err) { // `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare // listener it reached the console alone and the grid just stopped. - setShellStatus("bad frame — the grid has stopped updating (see console)"); + shell.setShell("bad frame — the grid has stopped updating (see console)"); console.error("improvise: frame rejected", text, err); return; } @@ -116,13 +86,13 @@ function workerTransport() { const worker = new Worker(`./worker.js?${query}`, { type: "module" }); worker.addEventListener("message", (event) => onFrame(event.data)); worker.addEventListener("messageerror", () => - setShellFailure("the worker sent a frame this page could not read"), + shell.fail("the worker sent a frame this page could not read"), ); worker.addEventListener("error", (event) => { - setShellFailure(`worker failed to start: ${event.message ?? "see console"}`); + shell.fail(`worker failed to start: ${event.message ?? "see console"}`); console.error("improvise: worker error", event); }); - setShellStatus(`in-page worker · ${DOC}`); + shell.setShell(`in-page worker · ${DOC}`); // Frames posted before the worker has finished `start()` are held by // worker.js in arrival order, so the handshake can go out immediately. return { send: (text) => worker.postMessage(text), ready: Promise.resolve() }; @@ -138,20 +108,20 @@ function socketTransport() { const ready = new Promise((resolve) => { socket.addEventListener("open", () => { opened = true; - setShellStatus(`connected · ${WS_URL}`); + shell.setShell(`connected · ${WS_URL}`); resolve(); }); }); socket.addEventListener("message", (event) => onFrame(event.data)); // Without this, a connection that never opens left #status blank and // "server not running" read exactly like "still connecting". - socket.addEventListener("error", () => setShellFailure(unreachable)); + socket.addEventListener("error", () => shell.fail(unreachable)); // A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms - // route through `setShellFailure`, which disarms the watchdog — clients.md. + // route through `shell.fail`, which disarms the watchdog — clients.md. socket.addEventListener("close", () => - setShellFailure(opened ? "disconnected" : unreachable), + shell.fail(opened ? "disconnected" : unreachable), ); - setShellStatus(`connecting to ${WS_URL}…`); + shell.setShell(`connecting to ${WS_URL}…`); return { send: (text) => { if (socket.readyState === WebSocket.OPEN) socket.send(text); @@ -173,7 +143,7 @@ function repaint() { try { model = JSON.parse(client.render_model()); } catch (err) { - setShellStatus("cannot render the current state (see console)"); + shell.setShell("cannot render the current state (see console)"); console.error("improvise: render failed", err); return; } @@ -185,8 +155,7 @@ function repaint() { // itself; see `RenderModel::hint`. el.hint.textContent = model.hint; paintHelp(model); - modelStatus = model.status; - paintStatus(); + shell.setModel(model.status); el.viewName.textContent = model.view_name; el.grid.replaceChildren(gridTable(model)); // The flag is the client's answer, not the page's: `C` toggles it and the @@ -199,10 +168,7 @@ function repaint() { // What the frame costs, so the NEXT viewport announcement is honest. // Inline group ROWS are deliberately NOT counted — context/code/clients.md. - const next = { - rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0), - cols: model.row_levels, - }; + const next = frameCostOf(model); const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols; frameCost = next; if (changed) sendViewport(); @@ -247,32 +213,30 @@ function paintHelp(model) { el.help.replaceChildren(tabs, body); } -/** The grid, painted from a model in which every frame decision is already - * made (improvise-anr9). The page decides nothing here: which labels repeat - * and are therefore blank, how many header levels there are, which columns - * share a group and whether it is collapsed all arrive answered, by the same - * `AxisFrame` the TUI widget reads. */ +/** The grid, painted from a plan in which every decision is already made + * (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything: + * which labels repeat and are therefore blank, how many header levels there + * are, which columns share a group and whether it is collapsed all arrive + * answered, by the same `AxisFrame` the TUI widget reads. */ function gridTable(model) { + const plan = gridPlan(model); const table = document.createElement("table"); const head = table.createTHead(); const corner = () => { const th = document.createElement("th"); - th.colSpan = model.row_levels; + th.colSpan = plan.cornerSpan; return th; }; - // The column-group line, when there is one. An empty `col_groups` means - // this grid has no column groups at all -- not that every group is blank. - if (model.col_groups.length > 0) { + // The column-group line, when there is one. An empty `groups` means this + // grid has no column groups at all -- not that every group is blank. + if (plan.groups.length > 0) { const groups = head.insertRow(); groups.appendChild(corner()); - for (const group of model.col_groups) { + for (const group of plan.groups) { const th = document.createElement("th"); th.colSpan = group.span; - // `label` is null for a run in no group; an empty STRING is a legal group - // name, so this is a null check and not a truthiness test. - th.textContent = - group.label === null ? "" : indicator(group.collapsed) + " " + group.label; + th.textContent = group.text; th.classList.add("group"); groups.appendChild(th); } @@ -280,7 +244,7 @@ function gridTable(model) { // One header line per level. A blank entry is repeat suppression, decided // in Rust -- never an absent label. - for (const level of model.col_levels) { + for (const level of plan.levels) { const tr = head.insertRow(); tr.appendChild(corner()); for (const label of level) { @@ -291,23 +255,16 @@ function gridTable(model) { } const body = table.createTBody(); - // The cell coordinates a click sends are DATA row indices and a group header - // is not a data row, so this counts rather than trusting `model.rows`. - let r = -1; - for (const row of model.rows) { + for (const row of plan.body) { const tr = body.insertRow(); if (row.kind === "group") { const th = document.createElement("th"); - th.colSpan = model.row_levels + model.col_levels[0].length; - th.textContent = indicator(row.collapsed) + " " + row.label; + th.colSpan = row.colSpan; + th.textContent = row.text; th.classList.add("group"); tr.appendChild(th); continue; } - r += 1; - // `r` is one binding shared by every closure below, so a click handler - // that captured it would fire with the LAST row's index. Snapshot it. - const dataRow = r; for (const label of row.labels) { const header = document.createElement("th"); @@ -316,39 +273,26 @@ function gridTable(model) { tr.appendChild(header); } - row.cells.forEach((cell, c) => { + for (const cell of row.cells) { const td = tr.insertCell(); - // A keyboard edit is open when `model.editing` is a string, and it holds - // what has been typed. Both facts are decided in Rust. - const typing = cell.selected && typeof model.editing === "string"; - td.textContent = typing ? model.editing + "\u258f" : cell.text; - td.classList.toggle("numeric", cell.numeric && !typing); - td.classList.toggle("error", cell.error); - td.classList.toggle("selected", cell.selected); - td.classList.toggle("typing", typing); - // `editable` is the server's answer relayed through Rust: a stored cell. - // Drill is not on the wire yet, so an aggregate is not offered. - td.classList.toggle("editable", cell.editable); + td.textContent = cell.text; + for (const [name, on] of Object.entries(cell.classes)) { + td.classList.toggle(name, on); + } // Every cell moves the selection; an editable one also opens its editor, // which is the pointer's `i`. Not repainted in that branch: `edit` has // set `editing`, and a repaint would take the away. td.addEventListener("click", () => { - const frame = client.click_cell(dataRow, c); + const frame = client.click_cell(cell.row, cell.col); if (frame) transport.send(frame); - if (cell.editable) edit(td, dataRow, c, cell.edit_text); + if (cell.editable) edit(td, cell.row, cell.col, cell.editText); else repaint(); }); - }); + } } return table; } -/** The collapse glyph. `collapsed` is the fact, decided by the view and - * carried on the frame; only this spelling is the page's (gotcha 26). */ -function indicator(collapsed) { - return collapsed ? "\u25b6" : "\u25bc"; -} - /** Open an inline editor. Committing sends a frame; the value that comes * back is the server's, never the string typed here. */ function edit(td, row, col, seed) { @@ -489,7 +433,7 @@ window.addEventListener("keydown", (e) => { await transport.ready; sendViewport(); // the handshake: the snapshot is built at this extent -armWatchdog( +shell.arm( WS_URL ? `${WS_URL} accepted the connection but sent no document` : "the in-page worker did not answer — this browser may not allow it (see console)", diff --git a/web/assemble.sh b/web/assemble.sh index 796fca50..73d74c6b 100755 --- a/web/assemble.sh +++ b/web/assemble.sh @@ -49,7 +49,10 @@ node "$root/scripts/build-doc-site.mjs" "$out/docs" "$token" app="$out/app" mkdir -p "$app" -cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$app/" +# shell.js and serial.js are imported by app.js and worker.js: leaving either +# out is a page that fails to load its own module graph, not a degraded one. +cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/shell.js" \ + "$root/web/serial.js" "$root/web/style.css" "$root/web/worker.js" "$app/" # The document the page opens on a browser that has never seen one. # web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust, # which writes them through the VFS — so leaving it out is an empty grid, not @@ -87,3 +90,16 @@ stamped "$app/app.js" "pkg/improvise_wasm_client.js?v=${token}" stamped "$app/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}" stamped "$app/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}" stamped "$app/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}" + +# Every relative import the two entry points make must have been copied. The +# cp list above is a second answer to "what ships", and a missing sibling is a +# 404 at module-load time, which the page cannot report. +for entry in app.js worker.js; do + deps=$(sed -n 's|.*from "\./\([A-Za-z0-9_.-]*\.js\)".*|\1|p' "$app/$entry") + for dep in $deps; do + test -f "$app/$dep" || { + echo "assemble: $entry imports ./$dep, which was not copied" >&2 + exit 1 + } + done +done diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..213bf2e5 --- /dev/null +++ b/web/package.json @@ -0,0 +1,6 @@ +{ + "name": "web", + "private": true, + "type": "module", + "description": "The browser page. `node --test web/tests` runs what needs no browser (improvise-cr3)." +} diff --git a/web/serial.js b/web/serial.js new file mode 100644 index 00000000..92e20ad6 --- /dev/null +++ b/web/serial.js @@ -0,0 +1,19 @@ +// The one rule `web/worker.js` exists to keep: one frame at a time, in arrival +// order. No DOM, no worker globals — context/code/clients.md. + +/** Chain every arrival onto one tail promise, starting from `head`. + * + * An `async` listener does NOT serialize: the event loop is free to start the + * next message while an earlier one is suspended at an `await`, so two + * handler runs interleave and a later frame's replies can be posted before an + * earlier frame's — which the client's fold answers by DROPPING the overtaken + * update, not by replaying it. Chaining is also what makes "frames that + * arrived before `start()` finished" an ordinary case of this rule rather + * than a drain loop with the race in it twice over. */ +export function serialize(head, handler, onError) { + let tail = head.catch(() => {}); + return (value) => { + tail = tail.then(() => handler(value)).catch((err) => onError(err, value)); + return tail; + }; +} diff --git a/web/shell.js b/web/shell.js new file mode 100644 index 00000000..ced087f1 --- /dev/null +++ b/web/shell.js @@ -0,0 +1,144 @@ +// Everything `web/app.js` decides that is not painting: no DOM, no browser +// API, one `node --test` away. What belongs here: context/code/clients.md. + +/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit + * `ws://` is upgraded rather than honoured: context/code/clients.md. */ +export function socketUrl(peer, pageProtocol) { + const secure = pageProtocol === "https:"; + if (/^wss:\/\//i.test(peer)) return peer; + if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer; + return `${secure ? "wss" : "ws"}://${peer}`; +} + +/** `#status` carries two independent facts, composed rather than overwritten: + * a repaint must not quietly erase a connection error. */ +export function composeStatus(shell, model) { + return [shell, model].filter(Boolean).join(" · "); +} + +/** The collapse glyph. `collapsed` is the fact, decided by the view and + * carried on the frame; only this spelling is the page's (gotcha 26). */ +export function indicator(collapsed) { + return collapsed ? "▶" : "▼"; +} + +// Rough character metrics, used only to turn the window size into an extent. +// Being wrong here costs a row of over- or under-fetch, nothing more. +export const ROW_PX = 26; +export const COL_PX = 110; + +/** The extent a box of this size can show, in whole DATA cells: the frame is + * charged for first, and at least one cell is always asked for. */ +export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) { + return { + rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows), + cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols), + }; +} + +/** What the grid's own frame costs, in whole cells — measured off the painted + * model, never predicted. Inline group ROWS are deliberately not counted. */ +export function frameCostOf(model) { + return { + rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0), + cols: model.row_levels, + }; +} + +/** The grid, as a description: every decision made, nothing created. The page + * decides nothing here and neither does this — which labels repeat, how many + * header levels there are and which columns share a group all arrive + * answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */ +export function gridPlan(model) { + const cornerSpan = model.row_levels; + const groups = model.col_groups.map((group) => ({ + span: group.span, + // `label` is null for a run in no group; an empty STRING is a legal group + // name, so this is a null check and not a truthiness test. + text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`, + })); + + // The cell coordinates a click sends are DATA row indices and a group header + // is not a data row, so this counts rather than trusting the row's position. + let dataRow = -1; + const body = model.rows.map((row) => { + if (row.kind === "group") { + return { + kind: "group", + colSpan: model.row_levels + model.col_levels[0].length, + text: `${indicator(row.collapsed)} ${row.label}`, + }; + } + dataRow += 1; + return { + kind: "data", + row: dataRow, + labels: row.labels, + cells: row.cells.map((cell, col) => { + // A keyboard edit is open when `model.editing` is a string, and it + // holds what has been typed. Both facts are decided in Rust. + const typing = cell.selected && typeof model.editing === "string"; + return { + row: dataRow, + col, + text: typing ? `${model.editing}▏` : cell.text, + editText: cell.edit_text, + editable: cell.editable, + classes: { + numeric: cell.numeric && !typing, + error: cell.error, + selected: cell.selected, + typing, + // `editable` is the server's answer relayed through Rust: a stored + // cell. Drill is not on the wire, so an aggregate is not offered. + editable: cell.editable, + }, + }; + }), + }; + }); + + return { cornerSpan, groups, levels: model.col_levels, body }; +} + +/** The page's two status facts and the handshake watchdog, which share a home + * because the one rule between them is that a *failure disarms the timer*. */ +export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) { + let shell = ""; + let model = ""; + let watchdog = null; + const paint = () => onStatus(composeStatus(shell, model)); + + return { + setShell(text) { + shell = text; + paint(); + }, + setModel(text) { + model = text; + paint(); + }, + /** A peer that never answers the handshake. Deliberately about the *first + * frame*, not either peer's own failure modes — clients.md. */ + arm(message) { + clearTimer(watchdog); + watchdog = setTimer(() => { + watchdog = null; + this.setShell(message); + }, timeoutMs); + }, + /** A frame arrived, so the peer answered: the watchdog has no question + * left to ask. */ + sawFrame() { + clearTimer(watchdog); + watchdog = null; + }, + /** A peer that failed *loudly* has already said something more precise, so + * the watchdog must be DISARMED and not merely pre-empted (PR #13). */ + fail(text) { + this.sawFrame(); + this.setShell(text); + }, + armed: () => watchdog !== null, + }; +} diff --git a/web/tests/bundle/wasm_surface.test.js b/web/tests/bundle/wasm_surface.test.js new file mode 100644 index 00000000..da62adcd --- /dev/null +++ b/web/tests/bundle/wasm_surface.test.js @@ -0,0 +1,101 @@ +// The JS↔wasm boundary: every call the page makes is declared by the bundle, +// with the arity it declares. Needs `web/build.sh` to have run. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const web = (name) => fileURLToPath(new URL(`../../${name}`, import.meta.url)); + +/// Declared methods and free functions, as `name -> [min, max]` arity. A +/// missing bundle is a FAILURE and never a skip: this file exists to be run +/// after `web/build.sh`, and a silent pass would report exactly nothing. +function declarations(dts) { + const text = readFileSync(web(dts), "utf8"); + const found = new Map(); + const decl = /(?:^|\n)\s*(?:export function |static )?([a-z_][A-Za-z0-9_]*)\(([^)]*)\)\s*:/g; + for (const [, name, params] of text.matchAll(decl)) { + const list = params.trim() === "" ? [] : params.split(","); + const required = list.filter((p) => !p.includes("?:")).length; + found.set(name, [required, list.length]); + } + assert.ok(found.size > 0, `${dts} declares nothing — was web/build.sh run?`); + return found; +} + +/// Every `.(...)` call in a source file, with how many +/// top-level arguments it passes. +function callsOn(file, receiver) { + const text = readFileSync(web(file), "utf8"); + const calls = []; + const head = new RegExp(`${receiver}\\.([a-z_][A-Za-z0-9_]*)\\(`, "g"); + for (const match of text.matchAll(head)) { + let depth = 1; + let i = match.index + match[0].length; + let commas = 0; + let body = ""; + while (depth > 0 && i < text.length) { + const ch = text[i]; + if ("([{".includes(ch)) depth += 1; + else if (")]}".includes(ch)) depth -= 1; + else if (ch === "," && depth === 1) commas += 1; + if (depth > 0) body += ch; + i += 1; + } + assert.equal(depth, 0, `unbalanced call to ${match[1]} in ${file}`); + calls.push({ name: match[1], args: body.trim() === "" ? 0 : commas + 1 }); + } + return calls; +} + +test("every client call web/app.js makes is declared, with that arity", () => { + const declared = declarations("pkg/improvise_wasm_client.d.ts"); + const calls = callsOn("app.js", "client"); + assert.ok(calls.length >= 6, `found only ${calls.length} client calls`); + for (const call of calls) { + const arity = declared.get(call.name); + assert.ok(arity, `web/app.js calls client.${call.name}, which the bundle does not declare`); + const [min, max] = arity; + assert.ok( + call.args >= min && call.args <= max, + `client.${call.name} takes ${min}..${max} arguments; app.js passes ${call.args}`, + ); + } +}); + +test("the page still reaches the client through a handful of calls", () => { + const names = new Set(callsOn("app.js", "client").map((c) => c.name)); + // The named surface, so a call added without an argument fails here and + // has to come and say so. `viewport` is deliberately absent: `set_viewport` + // is the one that records the window (gotcha 47). + assert.deepEqual( + [...names].sort(), + [ + "click_category", + "click_cell", + "click_tile", + "on_frame", + "on_key_event", + "render_model", + "set_viewport", + "typed_cell", + ], + ); +}); + +test("every worker call web/worker.js makes is declared, with that arity", () => { + const declared = declarations("worker-pkg/improvise_worker_server.d.ts"); + const calls = callsOn("worker.js", "server"); + assert.ok(calls.length >= 2, `found only ${calls.length} server calls`); + for (const call of calls) { + const arity = declared.get(call.name); + assert.ok(arity, `web/worker.js calls server.${call.name}, undeclared by the bundle`); + assert.ok( + call.args >= arity[0] && call.args <= arity[1], + `server.${call.name} takes ${arity[0]}..${arity[1]}; worker.js passes ${call.args}`, + ); + } + // `start` is a free function rather than a method, and takes the seed. + assert.deepEqual(declared.get("start"), [1, 2]); +}); diff --git a/web/tests/grid_plan.test.js b/web/tests/grid_plan.test.js new file mode 100644 index 00000000..335d71ff --- /dev/null +++ b/web/tests/grid_plan.test.js @@ -0,0 +1,110 @@ +// The grid's plan: the decisions `gridTable` used to make while creating +// elements, where a wrong answer showed up as a wrong pixel. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { gridPlan } from "../shell.js"; + +const cell = (text, extra = {}) => ({ + text, + edit_text: text, + numeric: true, + error: false, + selected: false, + editable: true, + ...extra, +}); + +/// A grid with a group header between its data rows — the shape the row +/// index has to survive. Two column levels under one group run. +function grouped() { + return { + row_levels: 2, + col_levels: [["Q1", ""], ["Revenue", "Cost"]], + col_groups: [{ span: 2, label: "2026", collapsed: false }], + editing: null, + rows: [ + { kind: "group", label: "Hot", collapsed: false }, + { kind: "data", labels: ["Hot", "Espresso"], cells: [cell("1"), cell("2")] }, + { kind: "data", labels: ["", "Latte"], cells: [cell("3"), cell("4")] }, + { kind: "group", label: "Cold", collapsed: true }, + { kind: "data", labels: ["Cold", "Cold Brew"], cells: [cell("5"), cell("6")] }, + ], + }; +} + +test("a group header consumes no data row index", () => { + const plan = gridPlan(grouped()); + const data = plan.body.filter((r) => r.kind === "data"); + assert.deepEqual(data.map((r) => r.row), [0, 1, 2]); + // And every cell in a row carries that row's index, not its position in + // `rows` — the coordinate a click sends. + assert.deepEqual( + data.at(-1).cells.map((c) => [c.row, c.col]), + [[2, 0], [2, 1]], + ); +}); + +test("a group header spans the labels and the cells together", () => { + const plan = gridPlan(grouped()); + const groups = plan.body.filter((r) => r.kind === "group"); + assert.deepEqual(groups.map((r) => r.colSpan), [4, 4]); + assert.deepEqual(groups.map((r) => r.text), ["▼ Hot", "▶ Cold"]); + assert.equal(plan.cornerSpan, 2); +}); + +test("a column run in no group is blank, and an empty name is a name", () => { + const model = grouped(); + model.col_groups = [ + { span: 1, label: null, collapsed: false }, + { span: 1, label: "", collapsed: true }, + ]; + const plan = gridPlan(model); + assert.deepEqual(plan.groups, [ + { span: 1, text: "" }, + { span: 1, text: "▶ " }, + ]); +}); + +test("the typed text replaces only the selected cell, and carries a caret", () => { + const model = grouped(); + model.editing = "12"; + model.rows[1].cells[1].selected = true; + const plan = gridPlan(model); + const row = plan.body[1]; + assert.equal(row.cells[0].text, "1"); + assert.equal(row.cells[1].text, "12▏"); + assert.equal(row.cells[1].classes.typing, true); + // A cell being typed into is not drawn as a number: the buffer is text. + assert.equal(row.cells[1].classes.numeric, false); + assert.equal(row.cells[0].classes.numeric, true); +}); + +test("a selected cell with no editor open is not typing", () => { + const model = grouped(); + model.editing = null; + model.rows[1].cells[0].selected = true; + const plan = gridPlan(model); + const first = plan.body[1].cells[0]; + assert.equal(first.classes.typing, false); + assert.equal(first.classes.selected, true); + assert.equal(first.text, "1"); +}); + +test("an empty edit buffer still means an editor is open", () => { + const model = grouped(); + model.editing = ""; + model.rows[1].cells[0].selected = true; + const plan = gridPlan(model); + assert.equal(plan.body[1].cells[0].classes.typing, true); + assert.equal(plan.body[1].cells[0].text, "▏"); +}); + +test("the editor is seeded from the stored value, not the drawn one", () => { + const model = grouped(); + model.rows[1].cells[0] = cell("20,000", { edit_text: "20000" }); + const plan = gridPlan(model); + assert.equal(plan.body[1].cells[0].text, "20,000"); + assert.equal(plan.body[1].cells[0].editText, "20000"); +}); diff --git a/web/tests/render_surface.test.js b/web/tests/render_surface.test.js new file mode 100644 index 00000000..1fe8e1b2 --- /dev/null +++ b/web/tests/render_surface.test.js @@ -0,0 +1,52 @@ +// The RenderModel→DOM boundary, statically: a projected field that nothing +// paints is the bug four widgets shared. See context/code/clients.md. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + +/// Fields the page deliberately does not read, each with the reason. A field +/// belongs here only with an argument; the list is the debt, written down. +// improvise-z754 put the title bar's facts in one shared cache and taught +// `src/draw.rs::draw_title` to fold it; this page never learned to. That is +// improvise-5jna, open, and this list is what stops it being forgotten. +const UNPAINTED = { + doc_name: "improvise-5jna: the title bar is drawn by the TUI and not here", + doc_dirty: "improvise-5jna", + doc_stored_as: "improvise-5jna", +}; + +/// The `pub` fields of `RenderModel`, read off the Rust that defines it. +function renderModelFields() { + const src = read("../../crates/improvise-wasm-client/src/render.rs"); + const start = src.indexOf("pub struct RenderModel {"); + assert.ok(start > 0, "RenderModel moved — this test reads the struct itself"); + const body = src.slice(start, src.indexOf("\n}", start)); + const fields = [...body.matchAll(/\n {4}pub ([a-z_0-9]+):/g)].map((m) => m[1]); + assert.ok(fields.length > 10, `found only ${fields.length} fields`); + return fields; +} + +test("every field the render model projects is painted, or named as debt", () => { + // Both halves of the page: `app.js` paints, `shell.js` plans the grid. + const page = read("../app.js") + read("../shell.js"); + const unpainted = renderModelFields().filter((field) => !page.includes(`model.${field}`)); + assert.deepEqual( + unpainted.sort(), + Object.keys(UNPAINTED).sort(), + "a projected field reaches no element — paint it, or add it to UNPAINTED with the reason", + ); +}); + +test("every element the page looks up exists in the page", () => { + const app = read("../app.js"); + const html = read("../index.html"); + const ids = [...app.matchAll(/getElementById\("([^"]+)"\)/g)].map((m) => m[1]); + assert.ok(ids.length >= 8, `found only ${ids.length} element lookups`); + for (const id of ids) { + assert.ok(html.includes(`id="${id}"`), `app.js looks up #${id}, which index.html has not got`); + } +}); diff --git a/web/tests/serial.test.js b/web/tests/serial.test.js new file mode 100644 index 00000000..5cfaad35 --- /dev/null +++ b/web/tests/serial.test.js @@ -0,0 +1,79 @@ +// The worker's one rule: one frame at a time, in arrival order. The bug this +// guards against is an `async` listener, which does not serialize. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { serialize } from "../serial.js"; + +const tick = (n) => new Promise((resolve) => setTimeout(resolve, n)); + +test("a slow frame does not let a later one overtake it", async () => { + const posted = []; + // The first frame suspends for longer than the second: an `async` listener + // would post B's replies while A is still awaiting. + const handler = async (frame) => { + await tick(frame === "A" ? 20 : 0); + posted.push(frame); + }; + const onMessage = serialize(Promise.resolve(), handler, () => {}); + + onMessage("A"); + await onMessage("B"); + assert.deepEqual(posted, ["A", "B"]); +}); + +test("the same interleaving without the chain is what this rules out", async () => { + const posted = []; + const listener = async (frame) => { + await tick(frame === "A" ? 20 : 0); + posted.push(frame); + }; + listener("A"); + await listener("B"); + await tick(30); + assert.deepEqual(posted, ["B", "A"]); +}); + +test("frames that arrive before startup finishes are held, not dropped", async () => { + const posted = []; + let ready; + const head = new Promise((resolve) => { + ready = resolve; + }); + const onMessage = serialize(head, (f) => posted.push(f), () => {}); + + onMessage("first"); + onMessage("second"); + assert.deepEqual(posted, [], "nothing runs before startup settles"); + ready(); + await onMessage("third"); + assert.deepEqual(posted, ["first", "second", "third"]); +}); + +test("a startup that failed still lets the chain run, so the handler can decline", async () => { + const seen = []; + const head = Promise.reject(new Error("no bundle")); + const onMessage = serialize(head, (f) => seen.push(f), () => {}); + await onMessage("frame"); + assert.deepEqual(seen, ["frame"]); +}); + +test("one bad frame does not poison the frames after it", async () => { + const posted = []; + const errors = []; + const onMessage = serialize( + Promise.resolve(), + (frame) => { + if (frame === "bad") throw new Error("rejected"); + posted.push(frame); + }, + (err, frame) => errors.push([frame, err.message]), + ); + + onMessage("good"); + onMessage("bad"); + await onMessage("later"); + assert.deepEqual(posted, ["good", "later"]); + assert.deepEqual(errors, [["bad", "rejected"]]); +}); diff --git a/web/tests/shell.test.js b/web/tests/shell.test.js new file mode 100644 index 00000000..8d229d4a --- /dev/null +++ b/web/tests/shell.test.js @@ -0,0 +1,162 @@ +// What the page decides between the boundaries: transport selection, the two +// status facts, the watchdog and the extent arithmetic. No DOM is involved. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + composeStatus, + createShell, + extentOf, + frameCostOf, + indicator, + socketUrl, +} from "../shell.js"; + +test("a bare peer takes the page's own scheme, and https never falls back", () => { + assert.equal(socketUrl("127.0.0.1:8787", "http:"), "ws://127.0.0.1:8787"); + assert.equal(socketUrl("example.test", "https:"), "wss://example.test"); + // An explicit `ws://` on an https page is UPGRADED, not honoured: the + // browser would refuse the mixed-content connection either way. + assert.equal(socketUrl("ws://example.test", "https:"), "wss://example.test"); + assert.equal(socketUrl("ws://example.test", "http:"), "ws://example.test"); + // `wss://` is honoured on both, and the match is case-insensitive. + assert.equal(socketUrl("WSS://example.test", "http:"), "WSS://example.test"); +}); + +test("a peer whose host merely starts with ws is not a scheme", () => { + assert.equal(socketUrl("wsserver.test:9000", "http:"), "ws://wsserver.test:9000"); +}); + +test("the two status facts compose, and neither can erase the other", () => { + assert.equal(composeStatus("connected", "Filter: Product = Latte"), "connected · Filter: Product = Latte"); + // An empty fact contributes nothing rather than a stray separator. + assert.equal(composeStatus("connected", ""), "connected"); + assert.equal(composeStatus("", "saved"), "saved"); + assert.equal(composeStatus("", ""), ""); +}); + +test("a repaint's model status does not overwrite a connection failure", () => { + const seen = []; + const shell = createShell({ onStatus: (t) => seen.push(t), timeoutMs: 10 }); + shell.fail("cannot reach 127.0.0.1:8787"); + shell.setModel("Filter: Product = Latte"); + assert.equal(seen.at(-1), "cannot reach 127.0.0.1:8787 · Filter: Product = Latte"); +}); + +/// A clock the test drives, so the watchdog's arming is asserted rather than +/// waited for. `fire()` runs what is pending, as the event loop would. +function fakeClock() { + let next = 1; + const pending = new Map(); + return { + setTimer: (fn) => { + const id = next++; + pending.set(id, fn); + return id; + }, + clearTimer: (id) => pending.delete(id), + fire: () => { + const due = [...pending.values()]; + pending.clear(); + for (const fn of due) fn(); + }, + count: () => pending.size, + }; +} + +test("the watchdog speaks when the peer never answers the handshake", () => { + const clock = fakeClock(); + const seen = []; + const shell = createShell({ + onStatus: (t) => seen.push(t), + timeoutMs: 8000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + shell.setShell("connecting…"); + shell.arm("sent no document"); + assert.equal(shell.armed(), true); + clock.fire(); + assert.equal(seen.at(-1), "sent no document"); +}); + +test("a loud failure DISARMS the watchdog rather than being replaced by it", () => { + const clock = fakeClock(); + const seen = []; + const shell = createShell({ + onStatus: (t) => seen.push(t), + timeoutMs: 8000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + shell.arm("sent no document"); + shell.fail("disconnected"); + assert.equal(shell.armed(), false); + assert.equal(clock.count(), 0); + clock.fire(); + // The precise message is what stands, eight seconds later and forever. + assert.equal(seen.at(-1), "disconnected"); +}); + +test("a frame that arrives is the answer the watchdog was waiting for", () => { + const clock = fakeClock(); + const seen = []; + const shell = createShell({ + onStatus: (t) => seen.push(t), + timeoutMs: 8000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + shell.setShell("connected"); + shell.arm("sent no document"); + shell.sawFrame(); + clock.fire(); + assert.equal(seen.at(-1), "connected"); +}); + +test("arming twice leaves one timer, not two", () => { + const clock = fakeClock(); + const shell = createShell({ + onStatus: () => {}, + timeoutMs: 8000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + shell.arm("first"); + shell.arm("second"); + assert.equal(clock.count(), 1); +}); + +test("the frame is charged for before the extent is announced", () => { + // 10 rows and 4 columns fit; one header level and one row-label column are + // not data, so the peer is asked for 9 by 3. + const box = { height: 10 * 26, width: 4 * 110 }; + assert.deepEqual(extentOf(box, { rows: 1, cols: 1 }), { rows: 9, cols: 3 }); +}); + +test("a window smaller than its own frame still asks for one cell", () => { + assert.deepEqual(extentOf({ height: 0, width: 0 }, { rows: 3, cols: 2 }), { + rows: 1, + cols: 1, + }); +}); + +test("the frame costs a line per column level, plus one for a group line", () => { + const model = (levels, groups, rowLevels) => ({ + col_levels: levels, + col_groups: groups, + row_levels: rowLevels, + }); + assert.deepEqual(frameCostOf(model([["a"]], [], 1)), { rows: 1, cols: 1 }); + // Two category levels on the column axis, and a group line above them. + assert.deepEqual(frameCostOf(model([["a"], ["b"]], [{ span: 1 }], 2)), { + rows: 3, + cols: 2, + }); +}); + +test("the collapse glyph points right when collapsed", () => { + assert.equal(indicator(true), "▶"); + assert.equal(indicator(false), "▼"); +}); diff --git a/web/worker.js b/web/worker.js index 5238ef52..278f9a82 100644 --- a/web/worker.js +++ b/web/worker.js @@ -2,6 +2,7 @@ // every decision in Rust. The two rules it must not break: context/code/clients.md. import init, { start } from "./worker-pkg/improvise_worker_server.js"; +import { serialize } from "./serial.js"; const params = new URLSearchParams(self.location.search); const DOC = params.get("doc") || "demo.improv"; @@ -43,21 +44,21 @@ started.catch((err) => { console.error("improvise worker: failed to start", err); }); -// One frame at a time, in arrival order: the listener is deliberately NOT -// `async` — see context/code/clients.md. -let tail = started.catch(() => {}); +// One frame at a time, in arrival order, and the listener is deliberately NOT +// `async`: the chaining is `serial.js`'s and is tested there. +const onFrame = serialize( + started, + async (data) => { + // `start()` failed. There is nothing to answer with, and the main + // thread's handshake watchdog is what tells the user. + if (!server) return; + await handle(data); + }, + // An unhandled rejection here reaches the devtools console and nothing + // else. Caught, so one bad frame does not poison every later one. + (err, data) => console.error("improvise worker: frame rejected", data, err), +); self.onmessage = (event) => { - tail = tail - .then(async () => { - // `start()` failed. There is nothing to answer with, and the main - // thread's handshake watchdog is what tells the user. - if (!server) return; - await handle(event.data); - }) - .catch((err) => { - // An unhandled rejection here reaches the devtools console and nothing - // else. Caught, so one bad frame does not poison every later one. - console.error("improvise worker: frame rejected", event.data, err); - }); + onFrame(event.data); };