{"_type":"issue","id":"improvise-i0v9","title":"[bug] a second browser tab on the same document is dead and silent, and blames the browser","description":"DEMONSTRATED, real Chromium, two tabs in one context, at 1f28868.\n\n tab2 console: error: improvise worker: failed to start\n Error: createSyncAccessHandle(two-tab.improv):\n NoModificationAllowedError: Access Handles cannot be created if there is\n another open Access Handle or Writable stream associated with the same file.\n\nbindings.rs::boot awaits storage.attach(\u0026path) before anything else; attach\ncalls create_sync_access_handle, which is exclusive per file ACROSS CONTEXTS.\nThe failure is at attach, NOT at open: OpfsFs::open() (getDirectory) and\nrefresh() both succeed in the second tab. Only handle acquisition fails. This\nconfirms for the OPEN the half of improvise-vvl3 that was anticipated for the\nINDEX.\n\nWHAT THE SECOND TAB SHOWS, MEASURED:\n\n t=13ms #status \"in-page worker - two-tab.improv\" page blank, 0 grid tables\n t=8016ms #status \"the in-page worker did not answer -- this browser may not\n allow it (see console)\" still blank\n\n8.4s wall from navigation to the message. Two things wrong beyond its lateness:\n\n - It BLAMES THE BROWSER for a condition the code knows precisely. It holds the\n exact NoModificationAllowedError naming the exact document.\n - The first 8 seconds ACTIVELY REASSURE. \"in-page worker - \u003cdoc\u003e\" is written at\n 13ms and reads as success. The user watches a blank page under a green-looking\n status for eight seconds and is then told it is probably their browser's fault.\n\nDiagnosis chain, exactly as gotcha 46 predicts: start() rejects -\u003e worker.js's\nstarted.catch logs to console and nothing else -\u003e a worker that rejects in\nstart() fires NO error on the main thread -\u003e onFrame's `if (!server) return;`\nsilently drops every frame -\u003e only the generic first-frame watchdog is left.\n\nIT IS NOT READ-ONLY, IT IS DEAD, AND IT IS SILENT. Measured in the failed tab:\n\n SECOND after C: mode=CATEGORIES #categories present status=(watchdog message)\n\nThe client is a fully live ClientState. View-only keys still work: C changes the\nmode and paints an empty category panel. So the tab does not present as\nread-only -- it presents as WORKING and then swallows every write into a socket\nwith no peer.\n\nDOES IT RECOVER? The handle IS released on tab close (the browser tears the\nworker context down; no detach needed) and a RELOAD of the second tab then\nsucceeds. But the page never retries: no reconnect, no watchdog rearm. So\n\"read-only until the other tab closes\" is in practice \"dead until you reload\".\n\nNote OpfsFs::detach (opfs.rs:106) has NO CALLER anywhere in the workspace, and\nweb/ has no beforeunload/pagehide/visibilitychange handler. Neither turns out to\nmatter for this case -- recorded so nobody \"fixes\" this by adding one.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:04Z","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-g3no","title":"[bug] d on a _Measure formula target deletes nothing and reports success","description":"DEMONSTRATED, twice, at 1f28868.\n\n_Measure's items are \"explicit items UNION formula targets\" (gotcha 7). Profit\nin web/demo.improv exists ONLY because Formula { target: \"Profit\" } exists; it\nis not in the category's own item list. Model::remove_item calls\ncat.remove_item(name) -- which no-ops, there being no such item -- and then\ndeletes cells whose key names it, of which there are none because the value is\nderived. effective_item_names(\"_Measure\") then re-synthesises Profit from the\nsurviving formula, so the row comes straight back.\n\nThe separate and worse half is that both front ends LIE about it. Each composes\nan unconditional optimistic status beside the mutation:\n\n browser: resolve.rs::delete_at_cat_cursor\n [Command::Model(RemoveItem..), Command::SetStatus(\"Deleted item ..\")]\n TUI: cmd/panel.rs::DeleteCategoryAtCursor\n [effect::RemoveItem.., effect::set_status(\"Deleted item ..\")]\n\nSetStatus is ViewOnly, folded locally, never contradicted by the host. Note the\nordering is the OPPOSITE of gotcha 20's rule, where CommitBuffer emits the\nfallible effect LAST so its failure message wins. Here the fallible effect is\nfirst and the optimistic status wins.\n\nMODEL-LEVEL EVIDENCE (no browser):\n\n let mut wb = Workbook::new(\"T\");\n wb.add_item(\"_Measure\", \"Revenue\").unwrap();\n wb.add_item(\"_Measure\", \"Cost\").unwrap();\n wb.model.add_formula(parse_formula(\"Profit = Revenue - Cost\", \"_Measure\").unwrap());\n wb.remove_item(\"_Measure\", \"Profit\");\n // FAILS: reported success, row still there: [\"Revenue\", \"Cost\", \"Profit\"]\n\nBROWSER EVIDENCE (real Chromium, real worker, real OPFS, real keystrokes via\nharness/, driving C -\u003e j*n -\u003e Enter -\u003e j -\u003e d, no state hand-placed):\n\n MEASURE on: \"item selected|Profit\"\n MEASURE after d: _Measure (2) -- Cost, Profit \u003c- UNCHANGED\n MEASURE status: \"Deleted item 'Profit' from '_Measure'\" \u003c- false\n\nEverything else about d works and was checked: regular category on Row; on Page\nwith that item as the page selection; reached by mouse click rather than j;\ngrouped items and group members; ordinary _Measure items (Revenue really is\ndeleted). The ONLY failing input is a formula target.\n\nNOT BROWSER-SPECIFIC. The TUI runs the same Model::remove_item and composes the\nsame optimistic status. Confirm in the TUI before any fix says \"browser\".\n\nFiles: crates/improvise-core/src/model/types.rs::remove_item,\ncrates/improvise-core/src/workbook.rs::remove_item,\ncrates/improvise-protocol/src/resolve.rs::delete_at_cat_cursor,\ncrates/improvise-command/src/command/cmd/panel.rs::DeleteCategoryAtCursor\n","acceptance_criteria":"Two separable changes. Truthfulness first, it is the reportable half:\n\n1. TRUTHFULNESS. Model::remove_item returns (). Give it a Removed answer, or\n give effect::RemoveItem one, and let the failure message win -- the shape\n gotcha 20 already uses for CommitBuffer, whose rule this violates. Cheapest\n correct version: have the resolver and the Cmd refuse BEFORE composing\n anything, since both ends can already see the tree.\n\n2. CORRECTNESS. Deleting the row a user sees under _Measure should delete the\n FORMULA. The browser cannot: delete-formula-at-cursor is in declined()\n (resolve.rs:232). So the honest browser answer today is declined()-style\n wording -- \"Profit is a formula target; delete the formula instead\" -- and\n deleting formulas from the browser is a separate ticket.\n\nAcceptance: a failing test at the model level first (the snippet in the\ndescription), then the front ends. Both surfaces, one change.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:38:52Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:38:52Z","labels":["browser","bug","core","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-pnf5","title":"Saving while an unfinished records row exists writes an unreadable .improv file","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/grid.rs:518-546; crates/improvise-io/src/persistence/format.rs:202-211; improv.pest:97-101\n\nToggleRecordsMode then AddRecordRow on a populated workbook produces a cell with no coordinates. format_md emits a data line \" = 0\"; parse_md rejects it because coord_list requires at least one coord. CleanEmptyRecords runs on leaving records mode, not before Save. Saving in records mode can therefore overwrite a good document with one the application cannot reopen. Actual command/effect reproduction below. Related improvise-o9uq concerns the cursor editing an unrelated row; fixing that does not make the empty key serializable. Older improvise-s0h mentions cleanup on leaving records but no saving contract. Define persistence for placeholder rows, and prove save/load while one exists. Do not discard legitimate data blindly.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn save_with_unfinished_record_is_readable() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,grid::{ToggleRecordsMode,AddRecordRow}},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n let fx=ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode());\n let fx=AddRecordRow.execute(\u0026app.cmd_context(KeyCode::Char('o'),KeyModifiers::NONE));app.apply_effects(fx);\n let text=format_md(\u0026app.model_state.workbook);\n assert!(parse_md(\u0026text).is_ok(),\"saved unparseable file: {text}\");\n}\n```\nRun cargo test save_with_unfinished_record_is_readable -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:47Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:35:47Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-16af","title":"[bug] In-page worker intermittent blank grid: OPFS SyncAccessHandle / NotAllowedError","description":"## Summary\nDefault `/improvise/` (in-page worker) intermittently shows a blank grid. Status flips between `in-page worker · demo.improv` and `the in-page worker did not answer`. Console reports OPFS `createSyncAccessHandle` NotAllowedError / AccessHandle conflict.\n\n## Repro\n1. Open https://fiddlerwoaroof.github.io/improvise/ (or local web/) in Chromium.\n2. Optionally open a second tab on the same origin/doc, or reload after a prior session left a handle open.\n3. Observe blank grid and/or watchdog message; DevTools console shows SyncAccessHandle / NotAllowedError.\n\n## Expected\nWorker starts, handshake completes, demo grid paints. Concurrent tabs either share safely or fail with a loud, specific \"document open elsewhere\" status.\n\n## Actual\nIntermittent blank UI; generic watchdog text; OPFS exclusive-access conflict in console.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `storage.attach(\u0026path).await?` / OPFS path.\n- `crates/improvise-io/src/opfs.rs`: `attach` acquires sync access handle.\n- `web/app.js` / `web/shell.js`: handshake watchdog arms when peer never answers.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Do not claim this is a black-hole key issue.\n","status":"open","priority":1,"issue_type":"bug","created_at":"2026-09-21T20:32:37Z","created_by":"box","updated_at":"2026-09-21T20:32:37Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-2cw9","title":"Import silently overwrites repeated coordinate tuples with the last row","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/wizard.rs:230-236\n\nCSV Region,Revenue / East,10 / East,20 imports successfully with one cell East/Revenue=20 and no warnings. A sum over the imported data is 20 instead of 30; the first row disappeared. Each input record directly calls set_cell at the same key. The desired collision policy needs an explicit choice: preserve records, aggregate appropriately, or reject/warn; silent last-row-wins is not acceptable. The reproducer deliberately accepts a warning so it does not impose sum as the only valid policy. Duplicate checks: import, collision, coordinates, overwrite; improvise-5j9 covers missing category values, not complete repeated rows.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn repeated_import_coordinates_not_silently_overwritten() {\n use improvise_io::import::wizard::ImportPipeline;\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nEast,20\\n\").unwrap();\n let (wb,warnings)=ImportPipeline::new(serde_json::Value::Array(records)).build_model_with_warnings().unwrap();\n let total:f64=wb.model.data.iter_cells().filter_map(|(_,v)|v.as_f64()).sum();\n assert!(total==30.0 || !warnings.is_empty(),\"total {total}, no warning {warnings:?}\");\n}\n```\nRun cargo test repeated_import_coordinates_not_silently_overwritten -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:28Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-8l0c","title":"Formula panel truncation panics on a valid Unicode formula","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: src/ui/formula_panel.rs:54-55\n\nRendering |é| = 1 in a formula panel with outer width 7 panics: end byte index 4 is not a char boundary; it is inside é (bytes 3..5). Formula identifiers support pipe-quoted Unicode names. The panel truncates bytes instead of display columns, so ordinary resizing can crash the TUI. Use a shared Unicode/display-width safe truncator. Duplicate checks: formula panel, UTF, unicode. Test drove the actual Panel widget, not a copied substring expression.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn formula_panel_unicode_does_not_panic() {\n use improvise::{protocol::FormulaCache,ui::{app::AppMode,formula_panel::FormulaContent,panel::{Panel,PanelContent}}};\n use ratatui::{buffer::Buffer,layout::Rect,widgets::Widget};\n let cache=FormulaCache{formulas:vec![\"|é| = 1\".into()]};\n let area=Rect::new(0,0,7,5);let mut buffer=Buffer::empty(area);\n Panel::new(FormulaContent::new(\u0026cache,\u0026AppMode::Normal),\u0026AppMode::Normal,0).render(area,\u0026mut buffer);\n}\n```\nRun cargo test formula_panel_unicode_does_not_panic -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:27Z","comments":[{"id":"661a4de2-dee0-52c2-ac12-e095e1ee414c","issue_id":"improvise-8l0c","author":"Edward Langley","text":"Same truncation line also panics on ASCII when inner width is zero. Demonstrated by rendering FormulaCache { formulas: vec![\"A = 1\".into()] } through Panel into Rect::new(0,0,2,5): formula_panel.rs:55 subtracts 1 from width 0. In release the wrapped slice bound also panics. Include widths 0/1/2 in the shared safe-truncation regression; no separate bead filed.","created_at":"2026-09-21T20:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
{"_type":"issue","id":"improvise-o9uq","title":"o in records mode edits someone else's record and can rename it away","description":"Found by the command-layer bug hunt. DEMONSTRATED end to end through App::handle_key. THIS IS SILENT DATA CORRUPTION FROM A DOCUMENTED KEY.\n\n## Steps to Reproduce\nWorkbook: Region{East, West}; _Measure{Revenue}; cells Region=East/Revenue=10, Region=West/Revenue=20. term 120x40.\nKeys: R (records mode), j (cursor to row 1 = West), o, then N o r t h, Enter.\n\n after j sel=(1,0) rows[0:East | 1:West]\n after o sel=(0,0) edit_buf=Some(\"East\")\n rows[0:East | 1:West | 2:\u003cempty coords\u003e]\n after \"North\"+Enter\n rows[0:West | 1:\u003cempty coords\u003e | 2:Region=EastNorth]\n\n`o` is bound and documented as \"add a record row\" (help.rs:91, 265). The user should be editing the row it just added. Instead: the new row 2 is created and left empty, THE CURSOR LANDS ON ROW 0, the editor is pre-filled with row 0's Region coordinate \"East\", the typed text APPENDS, and Enter goes through commit_plain_records_edit -\u003e RecordCol::Category -\u003e rename_record_coordinate, WHICH RENAMES THE PRE-EXISTING EAST RECORD TO EastNorth and moves all its measure cells.\n\nA user adding a row silently corrupts an unrelated one.\n\n## Two independent causes, both needed for the full damage\n1a (longstanding). AddRecordRow::execute (cmd/grid.rs:518) returns only SetCell + set_status — it NEVER POSITIONS THE CURSOR. EnterEditAtCursorCmd then opens an editor on whatever the cursor is on. Contrast CommitAndAdvance's Tab-at-bottom-right path (commit.rs:547), which calls the same AddRecordRow and DOES follow it with viewport_effects(cursor.row + 1, 0, ..). One of the two AddRecordRow call sites places the cursor; the `o` binding is the one that does not.\n\n1b (regression of improvise-ni6k / gotcha 24). AddRecordRow's SetCell is Model(CellData), so the frames_basis arm diffs every view's frame; appending a row changes the hash, moved.contains(view) is true, and reset_cursor() fires MID-BATCH, before EnterEditAtCursor. Isolated and demonstrated:\n apply_effects([SetCell(CellKey::new(vec![]), Number(0.0))]) from sel=(1,0)\n -\u003e sel (1,0) -\u003e (0,0), cursor_epoch 2 -\u003e 3\n\n1b IS A DEFECT ON ITS OWN TERMS: appending a row does not renumber any existing row, yet view_frame hashes entries.len() and every entry, so ANY APPEND REFRAMES and throws the cursor to the origin. In records mode that is every record insert. The Tab path only escapes it by re-placing the cursor afterwards.\n\n## Also observed, same root, lower severity\nPressing `o` twice with no page-axis category adds ONE row, because both emit SetCell(CellKey::new(vec![])) — the same empty-coordinate record — while the status says \"Added new record row\" both times.\n\n## Acceptance Criteria\n- AddRecordRow::execute appends viewport_effects(new_last_row, 0, ..) after its SetCell, so the cursor lands on the row it created. Harmless at the CommitAndAdvance call site, whose own later viewport_effects wins and survives because the observation is inside the loop. Fixes 1a and masks 1b at this site.\n- A test driving the exact key sequence above and asserting the East record is UNTOUCHED.\n- 1b proper wants the frame observation to distinguish \"rows appended\" from \"rows renumbered\" — a separate, larger question. File it rather than fold it in.\n- Two `o` presses should add two rows or say why not.\n\nScope: TUI only. resolve.rs:237 puts add-record-row in declined(), so no browser client reaches it.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:02Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:25:02Z","dependency_count":0,"dependent_count":0,"comment_count":0}
@@ -64,6 +65,10 @@
{"_type":"issue","id":"improvise-s2w3","title":"SUM of a computed measure ignores its formula and returns zero","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-core/src/model/types.rs:75-83\n\nWith stored Revenue=10 and Double=Revenue*2, A=SUM(Double) evaluates to 0 while B=SUM(Double+0) evaluates to 20. eval_agg special-cases Expr::Ref into raw DataStore lookup, bypassing formula evaluation entirely. The general expression branch uses per-record evaluation and sees the derived value. Test should cover Sum/Avg/Min/Max/Count as applicable, both direct and cached paths, and derived targets with existing raw data (which must not leak through). Duplicate checks: SUM, aggregate, formula, reference and full descriptions of 418 existing beads.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn sum_of_derived_measure_agrees_with_identity_expression() {\n use improvise_io::{formula::parse_formula,model::cell::CellKey};\n let mut w=revenue_book();\n for raw in [\"Double = Revenue * 2\",\"A = SUM(Double)\",\"B = SUM(Double + 0)\"] {w.model.add_formula(parse_formula(raw,\"_Measure\").unwrap());}\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into())]);\n let a=w.model.evaluate(\u0026key.clone().with(\"_Measure\",\"A\"));let b=w.model.evaluate(\u0026key.with(\"_Measure\",\"B\"));\n assert_eq!(a,b);\n}\n```\nRun cargo test sum_of_derived_measure_agrees_with_identity_expression -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:35:49Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-cj4u","title":"New views pivot Label categories that were explicitly excluded from pivot axes","description":"Report-only hunt at 70025e2. Location: crates/improvise-core/src/workbook.rs:133-140; crates/improvise-core/src/view/types.rs:71-89\n\nAdd Label category Description: Default correctly uses Axis::None. create_view(Other) puts Description on Row. Workbook::create_view passes only category names to on_category_added, whose CategoryKind::of_name cannot distinguish Label from Regular. With existing row/column categories this can instead place a high-cardinality label on Page and filter the document to one description. Keep Label on None in each new view and consider passing real category kind across the seating boundary. This is independent of improvise-dy3j: no save/load is needed. Duplicate checks: label, view, current in-progress list.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn label_stays_off_axes_in_new_view() {\n use improvise_io::view::Axis;\n let mut w=Workbook::new(\"test\");w.add_label_category(\"Description\").unwrap();\n assert_eq!(w.active_view().axis_of(\"Description\"),Axis::None);\n assert_eq!(w.create_view(\"Other\").axis_of(\"Description\"),Axis::None);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-vw47","title":"Creating a view after deleting another silently resets a surviving view","description":"Report-only hunt at 70025e2. Location: crates/improvise-command/src/command/cmd/panel.rs:657; crates/improvise-protocol/src/resolve.rs:373; crates/improvise-core/src/workbook.rs:133\n\nCreate View 2 and View 3, customize View 3, delete View 2, then use the view panel new action. Both resolvers choose View {views.len()+1}, which is View 3 again. Workbook::create_view replaces that entry with defaults: its number format, axes, page selections and hidden/collapsed state are lost. The test executes the actual CreateAndSwitchView command and applies its effects; .4 becomes ,.0 and no view is added. Choose an unused name for user-created views in a shared helper. Preserve intentional replacement of _Records/_Drill; globally refusing create_view replacement would break those callers. Duplicate checks: create, View 3, view.\n\n## Steps to Reproduce\nStandalone Cargo test with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\n#[test] fn new_view_after_delete_does_not_overwrite_survivor() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::CreateAndSwitchView},key::{KeyCode,KeyModifiers}}};\n let mut w=Workbook::new(\"test\");w.create_view(\"View 2\");w.create_view(\"View 3\").number_format=\".4\".into();\n w.delete_view(\"View 2\").unwrap();\n let mut app=App::new(w,None);\n let effects=CreateAndSwitchView.execute(\u0026app.cmd_context(KeyCode::Char('n'),KeyModifiers::NONE));\n app.apply_effects(effects);\n assert_eq!(app.model_state.workbook.views[\"View 3\"].number_format,\".4\");\n assert_eq!(app.model_state.workbook.views.len(),3);\n}\n```\nObserved failing against unchanged source.\n\n## Acceptance Criteria\nThe reproduction passes and ordinary new-view creation still works. Add a control for the intentional cases described above.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:33:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-tg27","title":"[bug] Narrow window clips grid columns without usable horizontal scroll","description":"## Summary\nOn a narrow window the grid clips horizontally with no obvious horizontal scroll affordance, so rightmost columns are unreachable without widening the window.\n\n## Repro\n1. Open the web client with a demo that has several columns.\n2. Shrink the viewport width until columns overflow.\n3. Try to reach clipped columns (scrollbars / trackpad).\n\n## Expected\nOverflowing grid content is reachable via horizontal scroll (or another explicit navigation), consistent with `#grid { overflow: auto }` intent.\n\n## Actual\nContent appears clipped; horizontal overflow is not obviously scrollable in the smoke environment (may be a flex/min-width layout trap swallowing overflow).\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/style.css`: `#grid { overflow: auto; }` / main flex layout — suspected min-width:auto flex child clipping.\n- Closed `improvise-jq8l` was about `scrollIntoView` for selection, not narrow-window column clipping — different bug.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-x0dc","title":"[bug] Esc from FORMULAS leaves formula_panel_open so panel chrome stays visible","description":"## Summary\nAfter opening the formulas panel with `F`, pressing Esc (or Tab→normal) returns mode to NORMAL but leaves formula-panel chrome visible because `formula_panel_open` stays true.\n\n## Repro\n1. Open web client with a document that has formulas.\n2. Press `F` — formulas panel opens and mode is FORMULAS.\n3. Press Esc (or the panel Tab binding that runs `enter-mode normal`).\n4. Mode indicator shows NORMAL; formulas sidebar/chrome may still be visible.\n\n## Expected\nLeaving the panel mode closes the panel (or Esc mirrors `F`'s toggle-shut: `SetPanelOpen { open: false }` + `ChangeMode(Normal)`), matching TUI intuition that Esc dismisses the panel.\n\n## Actual\n`PanelKeymap` binds Esc/Tab to `enter-mode normal` only. `entered()` emits `ChangeMode` and does not clear `formula_panel_open` / `view_panel_open`. Render still emits `formulas` rows while the flag is true (`render.rs` `formula_rows`), so `paintPanel` keeps the aside visible. By contrast, a second `F` correctly toggles shut (`SetPanelOpen false` + Normal) — see `a_focused_panel_toggles_shut` in `resolve.rs`.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-protocol/src/keymap.rs` `PanelKeymap::new`: Esc/Tab → `enter-mode normal`.\n- `crates/improvise-protocol/src/resolve.rs` `entered()` — ChangeMode only.\n- `crates/improvise-wasm-client/src/render.rs` `formula_rows` gated on `formula_panel_open`.\n- `web/app.js` `paintPanel` hides only when `rows.length === 0`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Category panel uses an explicit `category_panel_open` bool painted by the page; formula/view use empty-vec-as-closed, which makes this stickier.\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-9cma","title":"[bug] Invalid ?doc= yields blank UI and only generic watchdog text","description":"## Summary\nInvalid `?doc=` values (e.g. empty, `///`) produce a blank UI and only the generic handshake watchdog message — no specific error that the document name/path was rejected.\n\n## Repro\n1. Open `/improvise/?doc=` or `?doc=///`\n2. Observe blank UI; status eventually like `the in-page worker did not answer` (or similar watchdog text).\n\n## Expected\nA specific, actionable status: invalid document name / path rejected (and what is allowed).\n\n## Actual\nWorker start fails or never answers; UI collapses to the same generic watchdog path used for OPFS/browser blocks.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `crates/improvise-worker-server/src/bindings.rs`: `PathBuf::from(document)` with little/no validation before `attach`.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Related to but distinct from OPFS AccessHandle conflicts.\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","updated_at":"2026-09-21T20:32:38Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-iobc","title":"[bug] Bad/missing ?seed= creates empty doc with no loud error","description":"## Summary\nA bad or missing `?seed=` is silent: the worker creates an empty document and the UI never surfaces a loud, actionable error. CORS/404 appears only in the console.\n\n## Repro\n1. Open `/improvise/?doc=probe-missing.improv\u0026seed=https://example.com/missing.improv`\n2. Or `?seed=nope.improv` (404 relative to the page).\n3. Observe an empty document with normal shell status; no toast/status naming the seed failure.\n\n## Expected\nStatus (or a blocking notice) names that the seed could not be fetched/parsed (404, CORS, corrupt), and ideally does not pretend the empty doc was intentional without saying so.\n\n## Actual\n`web/worker.js` `seedBytes()` returns `undefined` on `!res.ok` or fetch throw; comment says \"A missing seed is not an error\"; `start(DOC, undefined)` creates empty model. User sees empty grid.\n\n## Evidence\n- Browser smoke (external Grok Bot agent for Ed Langley), 2026-09-21 PT.\n- `web/worker.js` lines ~13–22 (`seedBytes`).\n- `crates/improvise-worker-server/src/vfs.rs` / `bindings.rs`: open-with-optional-seed.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude).\n","status":"open","priority":2,"issue_type":"bug","created_at":"2026-09-21T20:32:38Z","created_by":"box","updated_at":"2026-09-21T20:32:38Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-55v2","title":"Nonfinite CellValue numbers serialize into JSON that the wire cannot read","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-core/src/model/cell.rs:65-70; crates/improvise-protocol/src/resolve.rs:805-813\n\nserde_json serializes CellValue::Number(infinity) as {\"Number\":null}, which cannot deserialize as an f64. The test uses Command::SetYanked; ModelCommand::SetCell contains the same CellValue. typed() accepts inf, NaN and overflowing numeric literals, so a browser can compose a valid Rust command that cannot cross its JSON transport. The websocket reader skips unparseable frames and the worker reports only a console error. Choose an explicit representation for supported nonfinite numbers or refuse them before sending, with an observable error. Duplicate checks: serialization, finite, NaN. improvise-jc95 is the separate CSV zero-substitution bug.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn nonfinite_cell_value_survives_wire() {\n use improvise::protocol::{Command,CellValue};\n let cmd=Command::SetYanked(Some(CellValue::Number(f64::INFINITY)));\n let json=serde_json::to_string(\u0026cmd).unwrap();\n assert!(serde_json::from_str::\u003cCommand\u003e(\u0026json).is_ok(),\"unreadable wire: {json}\");\n}\n```\nRun cargo test nonfinite_cell_value_survives_wire -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:29Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-l1f5","title":"A blank numeric CSV field makes the entire measure a Label","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/analyzer.rs:129-132\n\nCSV Region,Revenue with East,10 and West,\u003cblank\u003e yields a Revenue proposal of Label instead of Measure. parse_csv_field correctly maps blank to Null, but all_numeric tests every value including Null. Default import therefore stops importing all numeric Revenue cells; they become label coordinates instead. Infer from present values, define all-null behavior explicitly, and test both blank CSV and JSON null. Duplicate checks: blank, numeric, null, import. improvise-ych concerns missing CATEGORY values, a separate stage.\n\n## Steps to Reproduce\nUse a standalone Rust 2024 package with path dependencies on improvise and improvise-io, plus serde_json=1 and ratatui=0.30.\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn blank_measure_still_infers_numeric() {\n use improvise_io::import::{wizard::ImportPipeline,analyzer::FieldKind};\n let (records,_)=parse_csv_bytes_with_warnings(b\"Region,Revenue\\nEast,10\\nWest,\\n\").unwrap();\n let p=ImportPipeline::new(serde_json::Value::Array(records));\n let proposal=p.proposals.iter().find(|p|p.field==\"Revenue\").unwrap();\n assert_eq!(proposal.kind,FieldKind::Measure);\n}\n```\nRun cargo test blank_measure_still_infers_numeric -- --nocapture. The current implementation failed this test.\n\n## Acceptance Criteria\nAddress the behavior above with a focused regression that fails before the fix and preserves ordinary input behavior.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:31:27Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:27Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-m4rv","title":"Loading a document invents a Default view the file does not contain, and saving writes it back","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nweb/demo.improv declares exactly ONE view, `Plan`. Parsing it produces TWO:\n\n $ cargo run -q --example pretty-print \u003c web/demo.improv | grep '^## View'\n ## View: Default \u003c- not in the file\n ## View: Plan\n\n $ grep -c '^## View' web/demo.improv\n 1\n\n`Default` comes out auto-laid-out (Product: row, Quarter: column, _Measure: page) because Workbook::new seeds a Default view and on_category_added assigns each parsed category into every existing view. examples/demo.improv is unaffected only because its view IS named Default.\n\n## Two consequences, the second worse\n1. THE SHIPPED DEMO'S VIEW PANEL SHOWS A PHANTOM ROW, AND IT IS INDEX 0. The panel cursor starts on `Default`, so the first V+Enter a visitor presses takes them OFF the curated view onto an auto-arrangement. harness/tests/contract.spec.js:71 already encodes this (toHaveCount(2), cursor on Default) as expected behaviour — so the test pins the bug.\n\n2. OPEN-AND-SAVE REWRITES THE USER'S DOCUMENT. A round trip with no edits adds the whole `## View: Default` block permanently. Demonstrated with `improvise cmd 'set-format ,.0'`.\n\n## Acceptance Criteria\n- parse_md drops a `Default` view that the parsed text never named and that holds no non-default configuration — the same class of rule as the virtual-view drop already in persistence.\n- Update contract.spec.js, which currently asserts the phantom.\n- A test: parse(format(x)) names exactly the views x named.\n\n## Adjacent, same experience, probably a different owner\nThe same round trip REORDERS the ## Data block — |Cold Brew| moves from the middle to the end. That is DataStore ordering (gotcha 18 territory) and lands in the same \"save produced a different file\" complaint. Worth checking whether it is the same insertion-order guarantee that gotcha 18 says persistence relies on.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:19Z","comments":[{"id":"a2b602bb-1f71-5d7b-9712-7e42113db440","issue_id":"improvise-m4rv","author":"Edward Langley","text":"Independently reproduced in this report-only hunt at 70025e2: create Custom, delete Default, parse_md(format_md(wb)); expected [Custom], actual [Default, Custom]. A failing standalone Rust regression and exact source location are retained in improvise-izx2. That ticket was filed at 20:27 before this concurrently-created report existed; consolidating here because this report adds shipped-demo and harness impact.","created_at":"2026-09-21T20:32:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
@@ -241,6 +246,7 @@
{"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:58Z","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-pcje","title":"Render parity: the browser drops totals, col_widths, page axis and every AxisColor","description":"DERIVED at 1f28868 by a mechanical grep of every cache field across src/ui/*.rs,\nsrc/draw.rs, wasm-client/src/render.rs, web/app.js and web/shell.js.\n\nGOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields and\nevery nested field are read by app.js or shell.js. The four-widget gap that\ndesign-principles.md section 7 records as its motivating case is CLOSED. No\norphan DOM ids either.\n\nWhat differs, none of it silent-wrong, all of it cosmetic or structural:\n\n GridCache::col_widths TUI yes / browser NO -- never reaches RenderModel;\n the browser lets CSS table layout size columns\n GridCache::totals TUI yes / browser NO -- not on RenderModel at all\n GridCache::col_frame browser has no shares_ancestry equivalent, so the TUI's\n selected-column ancestry underline has no counterpart\n PageAxis::{cat,selected} TUI yes / browser NO\n CellDisplay::numeric TUI NO (it right-aligns every cell unconditionally,\n grid.rs:349) / browser yes\n CellDisplay::error read by NOBODY in src/, despite the field's doc saying\n \"rendered distinctly\"\n CellDisplay::id MIRROR-IMAGE PARTIALS: the TUI reads only Aggregate -\u003e\n italic; the browser collapses it to\n editable = matches!(id, Some(Record(_))) and so cannot\n tell Aggregate from Derived from None\n CategoryRow::axis TUI to_string() + axis_fg(axis.color());\n browser axis.initial(), NO COLOUR\n TileEntry::axis TUI short_label() + colour; browser label only\n TileBarCache::tiles TUI horizontal scroll with left/right overflow arrows;\n browser no scroll or overflow handling\n\nThe colour ones are one question, not four: AxisColor has no CSS counterpart at\neither site, unlike MinibufColor which crosses as a semantic enum. Answer it once\n(gotcha 26's shape: the decision is a semantic enum in the shared layer and only\nthe last translation is local) rather than per widget.\n\nCellDisplay::id's mirror-image partials are the one with teeth -- see the\nseparate ADR 002 / editable ticket.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:57Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:57Z","labels":["browser","parity","ui"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-yae4","title":"Column width overflows before clamping a 65535-column text value","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-command/src/ui/viewport.rs:66,85\n\nA stored 65535-character ASCII text value causes compute_col_widths/grid_body to panic at w+1 in debug builds. Width is narrowed to u16 before adding the cell gap and clamping to MAX_COL_WIDTH. In release the sum wraps and chooses a minimum-width column instead of the configured maximum. Longer widths can truncate during the earlier as u16 cast even when addition does not overflow. Clamp while still usize or use bounded/saturating conversion before arithmetic. Test widths around 65535 and 65536 as well as ordinary text. Duplicate checks: width, overflow, full existing descriptions.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn wide_cell_clamps_without_overflow() {\n use improvise::{ui::app::App,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Text(\"x\".repeat(65535)));\n let app=App::new(w,None);\n let widths=improvise::ui::viewport::compute_col_widths(\u0026app.model_state.workbook.model,\u0026app.layout,false,0);\n assert!(widths.iter().all(|w| *w\u003c=32));\n}\n```\nRun cargo test wide_cell_clamps_without_overflow -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:41Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:19Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"improvise-ppru","title":"Nondefault measure aggregation is lost by the .improv roundtrip","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs; crates/improvise-core/src/model/types.rs:234,473\n\nSet model.measure_agg[Price] to AggFunc::Avg; after parse_md(format_md(wb)) measure_agg is empty and lookup falls back to Sum. This is API/legacy-JSON reachable: no current production setter for measure_agg was found, so priority is lower than ordinary UI persistence defects. Add a grammar-backed representation or explicitly resolve this unsupported state. Duplicate checks: measure_agg, aggregation, average, persist.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn aggregation_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_item(\"_Measure\",\"Price\").unwrap();\n w.model.measure_agg.insert(\"Price\".into(),AggFunc::Avg);\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert!(matches!(r.model.measure_agg.get(\"Price\"),Some(AggFunc::Avg)), \"loaded aggregation: {:?}\",r.model.measure_agg);\n}\n```\nRun cargo test aggregation_survives_save. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:45Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:26Z","dependency_count":0,"dependent_count":0,"comment_count":0}
<h2id="harness-the-browser-instrument"><aclass="anchor"href="#harness-the-browser-instrument">#</a><ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/harness"><code>harness/</code></a> — the browser instrument</h2>
<h3id="why-it-exists-and-what-belongs-in-it"><aclass="anchor"href="#why-it-exists-and-what-belongs-in-it">#</a>Why it exists, and what belongs in it</h3>
<p>Before <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/harness"><code>harness/</code></a> there was no JavaScript test infrastructure in the repository at all — no <code>package.json</code>, no runner, nothing in CI running a single line of <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> or <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/worker.js"><code>web/worker.js</code></a> — so every bug in them was found by reading and the bug-first rule could not be honoured for a change to either. <strong>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.</strong></p>
<p>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. That still holds, and it is why no shim was added; what changed is the <strong>scope</strong> — see the section below, which is now the answer to "where does a change to <code>web/*.js</code> belong". It is deliberately small (<aclass="tid"href="../tickets.html#improvise-vvl3"title="A headless browser harness: run storage_contract and the shell assertions under Playwright">improvise-vvl3</a>): the point is to be able to run <code>storage_contract</code> against <code>OpfsFs</code> and to read <code>#status</code> out of a real page, not to grow a second test framework beside <code>cargo test</code>. The server root is <code>harness/serve</code>, built by <code>build.sh</code> — the assembled site plus the harness's own page; a module script will not load from <code>file://</code>, so an http server is not optional.</p>
<p>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. That still holds, and it is why no shim was added; what changed is the <strong>scope</strong> — see the section below, which is now the answer to "where does a change to <code>web/*.js</code> belong". It is deliberately small (<aclass="tid"href="../tickets.html#improvise-vvl3"title="A headless browser harness: run storage_contract and the shell assertions under Playwright">improvise-vvl3</a>): the point is to be able to run <code>storage_contract</code> against <code>OpfsFs</code> and to read <code>#status</code> out of a real page, not to grow a second test framework beside <code>cargo test</code>. The server root is <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/harness/serve"><code>harness/serve</code></a>, built by <code>build.sh</code> — the assembled site plus the harness's own page; a module script will not load from <code>file://</code>, so an http server is not optional.</p>
<p><aclass="tid"href="../tickets.html#improvise-xf1"title="Verify OpfsFs against Safari — Chromium and Firefox are covered">improvise-xf1</a>'s acceptance criterion names Chrome, Firefox and Safari, and the OPFS spec is the reason: <code>createSyncAccessHandle</code> is the one API the whole design rests on, its sync read/write/truncate signatures were added later than the handle itself, and each engine implemented it separately. Chromium alone would be one engine's opinion.</p>
<p><strong>Result today: Chromium and Firefox run the whole battery and pass.</strong> Playwright's WebKit provides <strong>no OPFS at all</strong> — <code>getDirectory()</code> itself rejects with <code>UnknownError</code> — so Safari is NOT covered and xf1's criterion is met for two engines of three. The project is kept rather than deleted so that it starts running the day that build gains OPFS; <code>opfs.spec.js</code> skips on a <strong>runtime probe</strong>, not on the browser name, so if Chromium or Firefox ever stops providing OPFS it fails rather than skipping. An unexpected skip on any other engine is a failure — a silently skipped check is exactly what this harness exists to prevent.</p>
@@ -354,7 +354,7 @@
<p><strong>What gave it away was the count, not the clock — and that is the lesson.</strong> The contaminated run read 29 passed where a clean <code>1af51c9</code> reads 26, because three extra tests really were there; but it read 36.9s against the clean tree's 36.2s, because those three cost under a second between them. <strong>A wall clock absorbs a contaminated tree without a trace; an arithmetic identity does not.</strong> The same error survived three separate corrections — it reached PR #61's body as "26 chromium" (23 + the 3), survived a fix that corrected the chromium column and left the run count beside it, and was caught each time only by someone checking that the columns add up.</p>
<p><strong>Where the saving actually came from, since the obvious answer is wrong.</strong> The eleven per-widget tests that were deleted cost <strong>2.7 seconds of test time between them</strong> — restored verbatim and re-measured — against <strong>1.55s</strong> for the three contract tests replacing them. Those are the per-test sums, not the two wall clocks, which read 3.3s and 3.4s and <strong>flattered the replacements</strong>: they were separate <code>npx playwright test</code> invocations, so each paid its own cold start (~0.6s and ~1.9s) and a near-tie appeared between two things not measured the same way. Warm inside a full suite those three cost under a second, which is why the contaminated baseline's clock barely moved.</p>
<p>Eleven tests at 2.7s cannot buy a twenty-second saving, and they did not. Essentially all of 36.2s → 15s was two <code>shell.spec.js</code> tests that <strong>waited out the 8s watchdog in real time</strong>, and one of those two was the routing guard this section explains should never have gone. So the deletion of the eleven bought shape, not speed; the speed came from a deletion that cost coverage. Both are back at ~75ms each, because <code>page.clock</code> fakes the page's timers — the honest version of the trade: <em>the wait was the cost, not the test.</em></p>
<p><strong>Cutting Playwright tests barely moves the CI number</strong> in any case, because roughly four minutes of that job is three <code>wasm-pack</code> builds, <code>npm ci</code> and a browser download — not test execution. The feedback-time win is the <code>js</code> job, which needs <strong>no Rust toolchain and no wasm bundle at all</strong>, and that is a constraint rather than an accident: the moment a <code>web/tests/*.test.js</code> file needs <code>web/pkg/</code>, 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 <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests/bundle"><code>web/tests/bundle/</code></a> and runs in the browser job instead.</p>
<p><strong>Cutting Playwright tests barely moves the CI number</strong> in any case, because roughly four minutes of that job is three <code>wasm-pack</code> builds, <code>npm ci</code> and a browser download — not test execution. The feedback-time win is the <code>js</code> job, which needs <strong>no Rust toolchain and no wasm bundle at all</strong>, and that is a constraint rather than an accident: the moment a <code>web/tests/*.test.js</code> file needs <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/pkg"><code>web/pkg/</code></a>, 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 <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests/bundle"><code>web/tests/bundle/</code></a> and runs in the browser job instead.</p>
<p>Until <aclass="tid"href="../tickets.html#improvise-4ktt"title="The js job's bundle-freedom is a convention, not a gate">improvise-4ktt</a> the mechanism was a directory convention and a glob, and nothing checked it. <code>bundle_freedom.test.js</code> does now: it reads its own sibling files and refuses one that names the bundle directory, and its companion asserts <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests/bundle"><code>web/tests/bundle/</code></a> still holds a test that <em>does</em> — a scoping rule is uninteresting if nothing falls outside it. Evidence in <ahref="../test-evidence/tooling.html"><code>context/test-evidence/tooling.md</code></a>.</p>
<p>These are Playwright tests, so the checker's <code>--check-evidence</code> pass (which scans Rust <code>fn</code> names) cannot see their names. They are recorded here under their exact <code>test("…")</code> titles anyway, because they are where the JavaScript evidence lives.</p>
<h4id="harness-tests-shell-spec-js-the-file-s-own-scope"><aclass="anchor"href="#harness-tests-shell-spec-js-the-file-s-own-scope">#</a>harness/tests/shell.spec.js — the file's own scope</h4>
<p>The three assertions <aclass="tid"href="../tickets.html#improvise-hwpi"title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a> could not write. That ticket fixed four things and said of itself that the fixes were "as unverified as the bugs", because three of them are one-assertion tests that needed a browser. These are those three, plus the watchdog — which was found by reading, and only because someone went looking. Until this file, <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a> and <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/worker.js"><code>web/worker.js</code></a> had never been rendered or run.</p>
<p>Two fixtures are load-bearing. <code>NO_WEBSOCKET_HERE</code> is a peer that accepts the TCP connection and then refuses the WebSocket upgrade — the harness's own static file server — <strong>deterministic where a closed port is not</strong>: a refused port's <code>close</code> timing is a property of the host's network stack, and on the GitHub runner it did not arrive inside the window. The page cannot tell the two apart and neither can <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>: in both cases the socket never opened. <code>HANDSHAKE_TIMEOUT_MS</code> is <strong>imported from <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a></strong> and restates nothing (PR #65), and <strong>one</strong> test outlasts it — this sentence said the opposite of both for a while, which is gotcha 48's third clause: a fact stated twice is changed by a grep, not an edit. Since <aclass="tid"href="../tickets.html#improvise-669b"title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a> the spec also compares the <em>running page's</em> window against the one it imported, because the single home is in the TREE and the page loads <code>harness/serve/app/shell.js</code> — see <code>outlastTheWatchdog</code> below. <code>logStatus</code> records <strong>every distinct <code>#status</code></strong>, not just the last one, because several of these tests are about a message being <em>replaced</em>, which the final value cannot see.</p>
<p>Two fixtures are load-bearing. <code>NO_WEBSOCKET_HERE</code> is a peer that accepts the TCP connection and then refuses the WebSocket upgrade — the harness's own static file server — <strong>deterministic where a closed port is not</strong>: a refused port's <code>close</code> timing is a property of the host's network stack, and on the GitHub runner it did not arrive inside the window. The page cannot tell the two apart and neither can <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>: in both cases the socket never opened. <code>HANDSHAKE_TIMEOUT_MS</code> is <strong>imported from <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a></strong> and restates nothing (PR #65), and <strong>one</strong> test outlasts it — this sentence said the opposite of both for a while, which is gotcha 48's third clause: a fact stated twice is changed by a grep, not an edit. Since <aclass="tid"href="../tickets.html#improvise-669b"title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a> the spec also compares the <em>running page's</em> window against the one it imported, because the single home is in the TREE and the page loads <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/serve/app/shell.js"><code>harness/serve/app/shell.js</code></a> — see <code>outlastTheWatchdog</code> below. <code>logStatus</code> records <strong>every distinct <code>#status</code></strong>, not just the last one, because several of these tests are about a message being <em>replaced</em>, which the final value cannot see.</p>
<h4id="a-frame-the-client-cannot-fold-says-so-on-screen"><aclass="anchor"href="#a-frame-the-client-cannot-fold-says-so-on-screen">#</a>"a frame the client cannot fold says so on screen"</h4>
<p><aclass="tid"href="../tickets.html#improvise-hwpi"title="The browser shell fails quietly, and never reads a key">improvise-hwpi</a>: <code>on_frame</code> returns a <code>Result</code>, which wasm-bindgen throws. Thrown in a bare listener it reached the devtools console and nothing else — the grid simply stopped updating with nothing on screen saying why. <strong>The fault is injected at the <em>peer</em>, not in the page</strong> — a mocked socket that answers the handshake with a frame the client cannot fold — so this exercises the real <code>on_frame</code> and the real catch.</p>
<h4id="a-peer-that-cannot-be-reached-is-named-and-not-called-a-disconnect"><aclass="anchor"href="#a-peer-that-cannot-be-reached-is-named-and-not-called-a-disconnect">#</a>"a peer that cannot be reached is named, and not called a disconnect"</h4>
@@ -151,7 +151,7 @@
<p><strong>It runs past <code>HANDSHAKE_TIMEOUT_MS</code> on purpose</strong> — through <code>page.clock</code>, not through a real wait. On this arm the watchdog IS armed — <code>armWatchdog</code> runs after <code>transport.ready</code> resolves, i.e. on <code>open</code> — so "disconnected" is written while a timer is pending, and the only reason it survives is that <code>close</code> goes through <code>setShellFailure</code>, which clears it. That was argued in a comment and asserted nowhere, and <strong>the argument was got wrong from the outside</strong>: a bug report (<aclass="tid"href="../tickets.html#improvise-xps8"title="The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later">improvise-xps8</a>) claimed the timer fired 8s later and overwrote "disconnected" with "sent no document". It had been measured, honestly, but against a tree predating PR #21 — and the "confirmation" that it survived PR #21 simulated only that PR's <code>opened</code> flag and <strong>not its other half</strong>, routing BOTH close arms through <code>setShellFailure</code>. <em>A simulated fix that is not the fix you named reproduces a bug the real code does not have, and reads exactly like a confirmation.</em> The ticket was filed and closed <strong>invalid</strong>. Hence this assertion rather than another paragraph — proved discriminating by making <code>setShellFailure</code> skip its <code>clearTimeout</code>, which makes it fail. Same rule as <code>doctrine(an_injected_fault_must_be_the_fault_you_named)</code>, one level up.</p>
<p><strong>And it was lost a third time, by the PR that was arguing about exactly this.</strong> PR #61 split the browser tests into boundary contracts (Playwright) and unit tests (<code>node --test</code>), and merged these two tests into one that kept only the last-value assertions. <code>shell.test.js</code>'s "a loud failure DISARMS the watchdog" covers <code>createShell</code> in isolation and <strong>cannot see whether <code>app.js</code> routes the close event through <code>fail()</code> rather than <code>setShell()</code></strong> — which is the half PR #21 fixed and the half that keeps getting lost. Reverting both socket arms to <code>setShell</code> left the node suite 26/26 and Playwright 13/13 green. Restored under their original names, and re-proved with that exact revert: the two-arm revert and the close-arm-only revert each fail "…and stays that way", and dropping the <code>opened</code> flag fails "…the watchdog stays out of it". <strong>Three injections, three different tests, none of them the unit test.</strong></p>
<p><strong>And the helper guards its own premise.</strong><code>page.clock.runFor</code> on a page where <code>clock.install()</code> was never called does <strong>nothing and throws nothing</strong>, so a test that forgot the install would assert "the watchdog never spoke" against a watchdog never given the chance — vacuous, and green. <code>outlastTheWatchdog</code> reads <code>Date.now()</code> inside the page either side of the jump and requires it to have moved. Demonstrated both ways: with the install removed the suite passed 6/6, and with the guard in place the same omission fails by name.</p>
<p><strong>And it guards its second premise since <aclass="tid"href="../tickets.html#improvise-669b"title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a>: that the page runs the constant the spec imported.</strong> PR #65 single-homed the handshake window in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, which closed the <em>understated spec constant</em> — but the home is single in the <strong>tree</strong>, not in what the page runs: the spec imports <code>../../web/shell.js</code> and the page loads <code>harness/serve/app/shell.js</code>, the copy <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a> put there. A stale <code>serve/</code> restores the exact vacuity, and review demonstrated it — shrink the constant in source only, revert both socket arms, 6/6 green. <code>createShell</code> now exposes <code>timeoutMs()</code> beside <code>timesArmed()</code> and <code>outlastTheWatchdog</code> reads the running page's value and requires it to equal the import. Injected with the ticket's own recipe (<code>8000</code> → <code>100</code> in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, no rebuild): the run goes to 5 passed / 1 failed, on "the page's handshake window is not the one this spec imported", where the same staleness was previously invisible.</p>
<p><strong>And it guards its second premise since <aclass="tid"href="../tickets.html#improvise-669b"title="The harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble">improvise-669b</a>: that the page runs the constant the spec imported.</strong> PR #65 single-homed the handshake window in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, which closed the <em>understated spec constant</em> — but the home is single in the <strong>tree</strong>, not in what the page runs: the spec imports <code>../../web/shell.js</code> and the page loads <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/serve/app/shell.js"><code>harness/serve/app/shell.js</code></a>, the copy <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a> put there. A stale <code>serve/</code> restores the exact vacuity, and review demonstrated it — shrink the constant in source only, revert both socket arms, 6/6 green. <code>createShell</code> now exposes <code>timeoutMs()</code> beside <code>timesArmed()</code> and <code>outlastTheWatchdog</code> reads the running page's value and requires it to equal the import. Injected with the ticket's own recipe (<code>8000</code> → <code>100</code> in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a>, no rebuild): the run goes to 5 passed / 1 failed, on "the page's handshake window is not the one this spec imported", where the same staleness was previously invisible.</p>
<p>Note what this does and does not reach: it guards the tests that <em>call the helper</em>. <code>"a peer that accepts and then says nothing trips the watchdog"</code> also names <code>HANDSHAKE_TIMEOUT_MS</code>, but as a Playwright <code>timeout:</code> margin rather than as a window to outlast — a stale value there costs patience, not evidence.</p>
<p><strong>The window is single-homed, and that is the second half of the same finding.</strong><code>HANDSHAKE_TIMEOUT_MS</code> lived twice — once in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/app.js"><code>web/app.js</code></a>, once in the spec — with a comment saying a silent disagreement "would pass for the wrong reason" and nothing checking it. Setting the spec's copy to 1000 against the page's 8000 made the whole suite green <strong>against the reverted routing</strong>: the jump landed inside the window, the watchdog had not yet fired, and the denial held vacuously. It is now exported from <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a> and imported by both, so the two cannot disagree; shrinking it in its one home still catches the routing revert, which is the demonstration that the guard did not go quiet.</p>
<p><strong>The faked clock is what makes the restoration cheap without weakening it</strong>, and the injections are the proof of that too: if <code>page.clock.runFor</code> did not really advance the page's timers, a broken routing could not surface the watchdog's message and those injections would have passed. They failed. The test keeps the real page, the real socket, the real listeners and the real <code>setTimeout</code> call — only the <em>passage of time</em> is simulated, and 9 seconds of wall clock became 75 milliseconds.</p>
@@ -364,7 +364,7 @@
<p><strong><code>bundle/wasm_surface.test.js</code></strong> — <code>client.render_model()</code> renamed to <code>client.render_modal()</code> fails both its tests. It reads the generated <code>.d.ts</code>, so a missing bundle is an assertion failure and never a skip.</p>
<p><strong><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a></strong> — dropping <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/shell.js"><code>web/shell.js</code></a> from the copy list, with the page's own imports as the check. The first version of that guard anchored its <code>sed</code> at <code>^import</code>, so it could not see the <strong>multi-line</strong> 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.</p>
<h2id="harness-tests-contract-spec-js-eight-injections-into-the-served-page"><aclass="anchor"href="#harness-tests-contract-spec-js-eight-injections-into-the-served-page">#</a><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> — eight injections into the served page</h2>
<p>Patched in <code>harness/serve/app/app.js</code>, which is what the browser loads, so no rebuild stands between the fault and the run. The sidebar ignoring <code>category_panel_open</code>; the tile bar, view panel, formula panel and help overlay each never drawn; <code>click_cell</code> sending a constant row; <code>click_category</code> sending a constant index; and the typed buffer drawn without its caret. All eight fail.</p>
<p>Patched in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/serve/app/app.js"><code>harness/serve/app/app.js</code></a>, which is what the browser loads, so no rebuild stands between the fault and the run. The sidebar ignoring <code>category_panel_open</code>; the tile bar, view panel, formula panel and help overlay each never drawn; <code>click_cell</code> sending a constant row; <code>click_category</code> sending a constant index; and the typed buffer drawn without its caret. All eight fail.</p>
<p>The last one did <strong>not</strong> fail at first: the test asserted the <code>typing</code> 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.</p>
<p><aclass="tid"href="../tickets.html#improvise-rmvx"title="Six advertised keys do nothing in the browser, and the guard is scoped to one mode">improvise-rmvx</a>. Gotcha 47 says an upstream key is a black hole, and <code>the_command_names_that_still_need_the_host_are_these</code> is the guard that was supposed to keep the list of them honest. It was built with <code>in_mode(AppMode::Normal)</code>, so it only ever asked about Normal-mode bindings: <code>add-record-row</code> (RecordsNormal) and <code>delete-formula-at-cursor</code> (FormulaPanel) were outside its reach entirely and could never have failed it. <strong>A named list that silently scopes itself to one mode is a guard that looks broader than it is.</strong></p>
@@ -392,7 +392,7 @@
<p>It also found a real overlap on its first run. <code>enter-mode</code> is answered by <code>declined</code> for the three stranding modes <em>and</em> by <code>composed</code> for every mode, so <code>composed</code>'s arm would have opened <code>SavePrompt</code>, <code>ExportPrompt</code> or <code>CommandMode</code> if <code>declined</code> were ever bypassed. Rather than moving the check, <strong>both halves now hold the rule</strong> — <code>composed</code> returns <code>None</code> for a stranded mode — so the failure direction if one is removed is the pre-PR <code>Upstream</code>, not a dead room. ---</p>
<h2id="harness-tests-help-spec-js-the-overlay-asserted-as-words"><aclass="anchor"href="#harness-tests-help-spec-js-the-overlay-asserted-as-words">#</a><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/help.spec.js"><code>harness/tests/help.spec.js</code></a> — the overlay, asserted as words</h2>
<p>PR #61 deleted <code>"? opens help, and its rows are the shared ones"</code> and nothing replaced it, so <code>paintHelp</code>, <code>help</code>, <code>help_page</code> and <code>help_titles</code> reached no assertion at all (<aclass="tid"href="../tickets.html#improvise-78v6"title="PR #61 deleted the help-overlay spec and nothing replaced it">improvise-78v6</a>). What <code>contract.spec.js</code> carried in its place said only that a <code>dt</code> was VISIBLE — and an empty <code>dt</code> is a visible <code>dt</code> (<aclass="tid"href="../tickets.html#improvise-igxp"title="The browser help overlay's rows are asserted as present, never as content">improvise-igxp</a>), which is the same shape as the caret bug above: a row is a frame, and a frame is not a word.</p>
<p><strong>The injection both tickets name</strong>: blank <code>keys.textContent</code> in <code>paintHelp</code>, patched in <code>harness/serve/app/app.js</code>. Before this work it left all three contract tests passing. It now fails both tests in this file (the Welcome banner reads empty; the advertised key reads empty) <strong>and</strong><code>contract.spec.js</code>, whose two help lines were strengthened from <code>toBeVisible</code> to the banner's text and the first <code>dt.help-keys</code>'s text.</p>
<p><strong>The injection both tickets name</strong>: blank <code>keys.textContent</code> in <code>paintHelp</code>, patched in <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/serve/app/app.js"><code>harness/serve/app/app.js</code></a>. Before this work it left all three contract tests passing. It now fails both tests in this file (the Welcome banner reads empty; the advertised key reads empty) <strong>and</strong><code>contract.spec.js</code>, whose two help lines were strengthened from <code>toBeVisible</code> to the banner's text and the first <code>dt.help-keys</code>'s text.</p>
<p><strong>What the pairs helper is for.</strong> The overlay is a <code><dl></code>, and the association between a key row and its description is document order — <code>dt</code> then <code>dd</code>. Read as two lists they zip wrong, because a prose row emits <strong>no</strong><code>dd</code> at all (deliberately: an empty one would leave a blank grid cell). Welcome is prose end to end, so <code>dd</code> count zero there is an assertion about that rule and not a coincidence.</p>
<h4id="a-key-the-overlay-advertises-does-what-the-overlay-says-it-does"><aclass="anchor"href="#a-key-the-overlay-advertises-does-what-the-overlay-says-it-does">#</a>"a key the overlay advertises does what the overlay says it does"</h4>
<p>The assertions above are still about a string on a screen; this one asks whether the string is <strong>true</strong>. The key for <code>Jump to last row</code> is read OUT OF THE DOM, the overlay closed, the key pressed, and the selection asserted onto the last row. A page drawing a plausible key it does not have — what a hand-spelled table decays into — fails here, and the spec holds no second copy of the keymap.</p>
@@ -401,7 +401,7 @@
<p><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/demo.improv"><code>web/demo.improv</code></a> has no groups and one category per visible axis, deliberately (gotcha 46), so the multi-level header, the group span, the collapse indicator and the inline group row — exactly the code <aclass="tid"href="../tickets.html#improvise-anr9"title="Project the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once">improvise-anr9</a> added — had never been rendered anywhere a test could see (<aclass="tid"href="../tickets.html#improvise-plcx"title="The browser harness needs a grouped, multi-level fixture — the demo has neither">improvise-plcx</a>). The fixture is <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/fixtures/grouped.improv"><code>harness/fixtures/grouped.improv</code></a>, served beside the shipped demo by <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/build.sh"><code>harness/build.sh</code></a>, and the shipped document is unchanged.</p>
<p><strong>A missing seed is not an error</strong>: <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/worker.js"><code>web/worker.js</code></a> answers a fetch that 404s with an empty model. So a typo in the filename yields a page that draws a grid, and "a grid is drawn" would pass on it. Every assertion here is about labels the fixture really carries.</p>
<p><strong><code>openGrouped</code> waits with retrying assertions and the tests then read once.</strong> The head and the row labels are read in a single <code>evaluate</code> each, because what these tests assert is the RELATION between the lines — and a one-shot read cannot wait. The grid is painted from the first projection and reframed by the viewport the page then announces, so the helper gates on the head's shape, its first group and the first row's innermost label before anything is read.</p>
<p><strong>Five injections, five distinct failures</strong>, patched in <code>harness/serve/</code> so no rebuild stands between the fault and the run:</p>
<p><strong>Five injections, five distinct failures</strong>, patched in <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/harness/serve"><code>harness/serve/</code></a> so no rebuild stands between the fault and the run:</p>
<ul>
<li><code>gridPlan</code> slash-joins the level tuples into one line (the exact defect anr9 fixed) — fails all three, because <code>openGrouped</code> waits on the head's shape before any test reads it;</li>
<li><code>th.colSpan = 1</code> on every column group header — fails the span test alone;</li>
@@ -413,7 +413,7 @@
<h2id="harness-tests-landing-spec-js-the-front-door"><aclass="anchor"href="#harness-tests-landing-spec-js-the-front-door">#</a><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/landing.spec.js"><code>harness/tests/landing.spec.js</code></a> — the front door</h2>
<p><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/docs/index.html"><code>docs/index.html</code></a> is copied to the site root by <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/assemble.sh"><code>web/assemble.sh</code></a>, so the harness has been serving it all along with nothing asserted about it (<aclass="tid"href="../tickets.html#improvise-bgtf"title="The landing page is served by the harness and asserted by nothing">improvise-bgtf</a>).</p>
<p><strong>Counting the four <code>.ap-player</code> elements proves nothing</strong>, and that is the whole reason this is a spec rather than a one-liner: <code>autoPlay</code> is false, so asciinema-player fetches no cast until play is pressed. A page whose every cast path 404s constructs four players, throws no page error, and looks identical to a working one. Injected while the page was being written (<code>./casts/</code> -><code>./cats/</code>) the construction-only check passed on the broken page.</p>
<p>Re-injected into <code>harness/serve/index.html</code> now, the spec fails at the first player: <code>.ap-line</code> elements exist only once there is a cast to lay out. The spec also asserts, BEFORE pressing anything, that no <code>.cast</code> has been requested — without which the network evidence below could be satisfied by a page that fetched them eagerly, which is not the page we ship.</p>
<p>Re-injected into <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/serve/index.html"><code>harness/serve/index.html</code></a> now, the spec fails at the first player: <code>.ap-line</code> elements exist only once there is a cast to lay out. The spec also asserts, BEFORE pressing anything, that no <code>.cast</code> has been requested — without which the network evidence below could be satisfied by a page that fetched them eagerly, which is not the page we ship.</p>
<p>Two assertions where one looks sufficient: the rendering says a cast played, and the recorded responses say <strong>four distinct</strong> casts answered 200. Three players showing the same recording satisfies the first and fails the second.</p>
<p><code>"the front door reaches the app it advertises"</code> follows the footer link rather than asserting its text, because whether <code>./app/</code> resolves is a fact about the assembled tree. Injecting <code>href="./aplication/"</code> fails it.</p>
<h2id="harness-tests-contract-spec-js-the-ninth-injection-and-a-lost-guard"><aclass="anchor"href="#harness-tests-contract-spec-js-the-ninth-injection-and-a-lost-guard">#</a><ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/harness/tests/contract.spec.js"><code>harness/tests/contract.spec.js</code></a> — the ninth injection, and a lost guard</h2>
<p>Evidence for the checks in <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/scripts"><code>scripts/</code></a> and for the tests that hold CI's shape. The arguments behind the checkers themselves are in <ahref="../code/tooling.html"><code>context/code/tooling.md</code></a>; this file records what fault each test was watched to catch.</p>
<p>Note what is deliberately absent. <code>scripts/check-comment-runs.py --self-test</code>, <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/scripts/kb-test.sh"><code>scripts/kb-test.sh</code></a> and <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/scripts/assemble-stamp-test.sh"><code>scripts/assemble-stamp-test.sh</code></a> are check-the-checker suites whose cases are already injected faults, and none of their case names is a Rust <code>fn</code> or a JS <code>test(</code> the evidence checker could resolve — so their evidence is the case list itself plus <ahref="../code/tooling.html"><code>context/code/tooling.md</code></a>, and inventing headings for them here would be headings naming nothing.</p>
<h3id="no-test-in-web-tests-reaches-for-a-built-bundle"><aclass="anchor"href="#no-test-in-web-tests-reaches-for-a-built-bundle">#</a>"no test in web/tests/ reaches for a built bundle"</h3>
<p>The <code>js</code> CI job runs <code>node --test web/tests/*.test.js</code> and installs nothing; the <code>browser</code> job runs <code>web/tests/bundle/*.test.js</code> after three wasm-pack builds. The moment a flat <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests"><code>web/tests/</code></a> file reads <code>web/pkg/</code>, the fast job inherits the slow one's dependency — and the likely repair is to add a build step to it, silently spending the ~4m30s the split exists to avoid. Until <aclass="tid"href="../tickets.html#improvise-4ktt"title="The js job's bundle-freedom is a convention, not a gate">improvise-4ktt</a> that constraint was a directory convention and a glob, checked by nothing, and it failed in the direction that looks like ordinary breakage: a confusing ENOENT in CI on a machine where the bundle happens not to be built.</p>
<p>The <code>js</code> CI job runs <code>node --test web/tests/*.test.js</code> and installs nothing; the <code>browser</code> job runs <code>web/tests/bundle/*.test.js</code> after three wasm-pack builds. The moment a flat <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests"><code>web/tests/</code></a> file reads <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/pkg"><code>web/pkg/</code></a>, the fast job inherits the slow one's dependency — and the likely repair is to add a build step to it, silently spending the ~4m30s the split exists to avoid. Until <aclass="tid"href="../tickets.html#improvise-4ktt"title="The js job's bundle-freedom is a convention, not a gate">improvise-4ktt</a> that constraint was a directory convention and a glob, checked by nothing, and it failed in the direction that looks like ordinary breakage: a confusing ENOENT in CI on a machine where the bundle happens not to be built.</p>
<p>Fault injected, 2026-09-21: appended <code>const injected = readFileSync("../pkg/improvise_wasm_client.js");</code> to <ahref="https://github.com/fiddlerwoaroof/improvise/blob/main/web/tests/grid_plan.test.js"><code>web/tests/grid_plan.test.js</code></a>. The test failed naming that file and pointing at <ahref="https://github.com/fiddlerwoaroof/improvise/tree/main/web/tests/bundle"><code>web/tests/bundle/</code></a>. Reverted; green again.</p>
<p>Two things the test does that a simpler one would not. It asserts its own filename is among the files it scans, so it cannot become the one place a violation may hide. And it strips comments before matching, because this file's own header and the test's header both name the directory in prose — a lexical grep over raw text would have had to exempt them, and an exemption is where the next violation goes.</p>
<p><strong>What it cannot see</strong>, stated because the check itself uses the evasion: a path assembled at runtime (<code>"../" + "p" + "kg"</code>) is invisible to a lexical match, and the test's own <code>FORBIDDEN</code> constant is built exactly that way to stay out of its own scan. That is a deliberate, disclosed hole — closing it needs a parser, and the failure it would catch is one somebody has to write on purpose, where this gate exists for the one written by accident.</p>
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.