diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 137658b5..2473e568 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,10 @@ +{"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:17Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uej8","title":"[bug] the category panel cursor does not follow a shrinking list, so d deletes the next category","description":"DEMONSTRATED in a real browser at 1f28868, found while probing improvise-g3no.\n\nAfter deleting the last item of an expanded category, cat_panel_cursor stays at\nits index and the row now under it is the NEXT CATEGORY'S HEADER. Observed live:\n\n GRP d on Valley -\u003e rows: .. \"item|West\", \"category selected|\u003e Store (2) R\", ..\n\nSo a user holding d to clear out a category deletes the next CATEGORY on the\nfollowing press. RemoveCategory is LayoutGeometry: Workbook::on_category_removed\nrewrites EVERY view's axis map, and Model::remove_category also drops every\nformula targeting it (gotcha 35). High blast radius, one keypress away, no\nconfirmation, no undo.\n\nSame class from the other side, also observed: with the cursor at its default 0,\nd deleted the virtual category _Dim outright. Deleting _Index/_Dim/_Measure may\nitself deserve a guard -- they are the categories that AppMode-agnostic code\nassumes always exist (gotcha 7, and GridLayout's records-mode auto-detection\nkeys off _Index on Row and _Dim on Column).\n\nThe cursor-follows-a-shrinking-list problem is general: any panel whose list can\nshrink under the cursor has it. Check the formula and view panels for the same\nshape rather than fixing the category panel alone.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:05Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:05Z","labels":["browser","bug","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-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} {"_type":"issue","id":"improvise-bd9o","title":"The browser reaches records mode through the tile bar, and everything it can do there is silent","description":"Found by the protocol bug hunt. DEMONSTRATED end to end with a real Host and a real Client, using only BrowserKeys — no hand-built fixture.\n\ndeclined() (resolve.rs:230) answers \"toggle-records-mode\" with \"records mode is not available in the browser yet\", and the code is written as though that closes the route. IT DOES NOT. The tile bar carries the VIRTUAL categories (project::tile_entries -\u003e View::tileable_cats includes _Index/_Dim), and set-axis-for-tile is fully composable by the browser. Records mode auto-detects from _Index on Row + _Dim on Column (gotcha 1), so FIVE ORDINARY KEYS get there.\n\n T -\u003e [SetTileCatIdx(0), ChangeMode(TileSelect)]\n r -\u003e [Model{SetAxis{_Index, Row}}, SetStatus(\"_Index -\u003e Row\")]\n l -\u003e [SetTileCatIdx(1)]\n c -\u003e [Model{SetAxis{_Dim, Column}}, SetStatus(\"_Dim -\u003e Col\")]\n Esc -\u003e [ChangeMode(Normal)]\n =\u003e records = true, mode = RecordsNormal\n\nOnce there:\n l l i -\u003e [SetBuffer{edit,\"1200\"}, ChangeMode(RecordsEditing)]\n 9 9 -\u003e [SetBuffer{edit,\"120099\"}]\n Enter -\u003e Key{Enter} \u003c- BLACK HOLE\n after Enter: mode still RecordsEditing, status unchanged, grid unchanged\n\ncommit (resolve.rs:696) returns None -\u003e Step::Upstream -\u003e KeyOutcome::Upstream -\u003e ClientFrame::Key -\u003e Host::handle answers an EXPLICIT EMPTY VEC (gotcha 47). Enter on a typed edit does nothing, says nothing, and leaves the editor open holding the text.\n\nAnd create_column (improvise-gcvb) is consequently UNREACHABLE from any key path: the only door into RecordsEditing is edit-or-drill, which returns None for CellId::Create; `o` is declined at its add-record-row step.\n\n## The shape\nFailure-catalogue #4 — an unreachability claim only as wide as the routes enumerated. The author considered the TOGGLE, not the AXIS ASSIGNMENT. THIRD instance of this shape in one week (see improvise-e5pf and the PR #98 round). And it is exactly the dead room stranding()/declined() exist to prevent: improvise-rmvx, \"a door this client will not open still has to say so\".\n\n## Acceptance Criteria\n- CHEAPEST HONEST CLOSE: refuse in edit_or_drill (resolve.rs:594) when state.grid.records, with the existing declined() wording — so the editor never opens on a cell the client cannot commit, and create_column's deadness becomes explicit rather than accidental.\n- A test driving the five-key route and asserting the decline is SPOKEN, not silent.\n- The larger fix — composing the records commit, the browser half of commit_plain_records_edit — needs RecordCol, which is session-local by gotcha 1. That is a design question, not a patch; do not attempt it as part of closing this.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:37Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:37Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pv3g","title":"fit-measure panics on a document whose target values are large but finite","description":"Found by the gp bug hunt. DEMONSTRATED end-to-end through the shipped binary. This is a HARD CRASH IN THE TUI, not a refusal — FitMeasure::execute has no catch, so the terminal app dies with unsaved work.\n\n $ improvise cmd 'fit-measure Score' -f big.improv\n thread 'main' panicked at crates/improvise-gp/src/lib.rs:288:5:\n number_text wants a finite value, got NaN\n\nAn ordinary .improv: 8 records, one Region category, a Cost feature, a Score target of 1e20/4e20.\n\n## Mechanism — the guard checks the wrong side of the computation\nimprovise-gp/src/fit.rs:279 checks the INPUTS: every r.target, every r.feat, every data.ranges entry is finite. 1e20 is a perfectly finite f32. The overflow is in the DERIVED quantity: standardize (gp-tree/src/fit.rs:185) sums (t - mean)^2 in f32, and 8 x (1.5e20)^2 = 1.8e40 exceeds f32::MAX = 3.4e38, so std = inf. Every normalized target becomes +/-0, fit returns leaves near zero, and denormalize computes 0.0 * inf + mean = NaN. number_text then asserts.\n\nMeasured in isolation:\n scale 1e18: mean=2.5e18 std=1.5e18 leaf(0.0)-\u003eLeaf(2.5e18) OK\n scale 1e20: mean=2.5e20 std=inf leaf(0.0)-\u003eLeaf(NaN) PANIC\n scale 1e25, 1e30: same\n\n## Reachability SCALES WITH RECORD COUNT, which is the part that matters\nThe condition is sum((t-mean)^2) \u003e f32::MAX, so at k records the crashing spread is about sqrt(3.4e38 / k):\n ~6e18 at 8 records\n ~4e18 at 100\n ~1.3e17 at the 20,000-record MAX_RECORDS ceiling\nA sibling path: sum(t) overflowing gives mean = inf, and NaN.max(1e-6) returns 1e-6 (Rust f32::max prefers the non-NaN operand), so std is FINITE while mean is infinite — same panic, different arithmetic.\n\nThis is exactly the failure family the guard's own comment names (\"Reachable from a .improv cell\").\n\n## Acceptance Criteria\n- fit-measure REFUSES rather than panics, with a message the user can act on. Suggested wording: the values are too large for 32-bit arithmetic because their spread overflows; scale the measure (thousands, millions) and fit that.\n- The check is on the DERIVED quantities (mean, std) after standardize, not only on the inputs.\n- A test at a spread that overflows and one just below it, so the guard is not satisfied by refusing everything.\n- Note the same overflow silently corrupts cv_regress's quality numbers today with NO panic to announce it — so a fix that only catches the panic leaves wrong R2 values being reported. The structurally better answer is a type standardize returns that cannot carry a non-finite (mean, std), the way Row\u003cT\u003e made a truncating zip unrepresentable.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:58Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:23:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -25,7 +32,7 @@ {"_type":"issue","id":"improvise-8tk","title":"ZZ / :wq quits even when the save fails, silently discarding the model","description":"SaveAndQuit (crates/improvise-command/src/command/cmd/mode.rs) returns [Save, ChangeMode(Quit)] unconditionally. The Save effect reports failure only by setting view_state.status_msg — it never emits AbortChain — so the Quit lands regardless, and src/draw.rs:90 breaks out of the loop before another frame renders. The user never sees the error.\n\nWORST CASE — no file path, total silent loss:\nStart improvise with no file argument, do a session of work, press ZZ (bound in the normal-mode Z prefix, keymap.rs:562). Save takes the else branch, sets 'No file path — use :w \u003cpath\u003e', and the app exits. autosave_if_needed() cannot rescue it either: it early-returns unless model_state.file_path is Some. Everything the user typed is gone, with no prompt and no visible message.\n\nSECOND CASE — save errors on an existing path (read-only dir, full disk, bad permissions): same silent exit. Here a *.autosave file may exist, but only if 30s had elapsed since the last autosave, and the user is not told to look for it.\n\nNote ZZ is a vim reflex, so this is easy to hit by muscle memory.\n\nFIX SKETCH: Save needs to report failure to the effect chain, not just to the status bar — emit AbortChain (or have SaveAndQuit consult a save result) so the Quit is skipped and the error stays on screen. Decide separately what ZZ should do with no file path: most likely open the export/save prompt rather than exiting.\n\nPer AGENTS.md, write the failing test first. The command layer is pure, so assert on the emitted effects; the abort behavior itself is already covered by the AbortChain test at effect.rs:1373.\n\nBLOCKS improvise-git (replace SaveAndQuit with Binding::Sequence): a Sequence has no way to stop between steps, so converting first would cement this bug into the keymap where it is harder to fix. Fix this, then decide whether git is still worth doing.\n\nFound while grooming the backlog 2026-08-13, evaluating whether git/aaw/s13 could be combined.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-08-14T01:07:58Z","created_by":"Edward Langley","updated_at":"2026-08-29T17:34:46Z","started_at":"2026-08-29T17:31:50Z","closed_at":"2026-08-29T17:34:46Z","close_reason":"Save effect now sets abort_effects on failure (no file path, or write error), so the Quit chained by wq/ZZ is skipped and the error stays on screen. Extracted save_to_current_path() -\u003e Result\u003cString,String\u003e. Three tests in ui/effect.rs: two regression guards (no-path and failed-write both abort) plus one guaranteeing a successful save still quits.\n\nNote for improvise-git: a Binding::Sequence flattens into a single effect vec applied by one apply_effects call, so abort_effects DOES carry across sequence steps. Its stated blocker ('a Sequence has no way to stop between steps') no longer holds — it can proceed.\n\nFollow-up filed as improvise-lae for the no-file-path UX (prompt for a path rather than only erroring).","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-1cz","title":"export_csv emits unquoted CSV — commas in values corrupt output","description":"persistence/mod.rs:581-630 builds CSV by joining labels and values with bare commas (col_labels.join(\",\"), row_values.join(\",\")) with no RFC 4180 quoting/escaping. Any comma in a row/col label, page label, or cell value shifts columns. This is guaranteed to fire when the view uses a comma-grouping number format (e.g. ,.2f): display_text() renders 1200 as |1,200.00|, so every formatted value over 999 corrupts the row. Fix: use the csv crate (already a dependency) or quote fields per RFC 4180. Found in systematic review 2026-06-09; distinct from improvise-k8i (import side).","acceptance_criteria":"A view exported with a comma-grouping number format (,.2f) and an item label containing a comma re-imports with identical column structure. Failing test first: export a grid where display_text yields |1,200.00| and a row label contains a comma; assert the CSV parses to the original shape (use the csv crate to read it back).","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:57:49Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:45:52Z","closed_at":"2026-06-10T04:45:52Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4yc","title":"Malformed numbers in .improv file silently become 0.0","description":"crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error.","acceptance_criteria":"Loading a .improv file with a malformed number (e.g. 12.3.4 or 1e999) surfaces a parse error or CellValue::Error naming the offending text — never silently 0.0. Regression test demonstrates the current silent-zero behavior first.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:17Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","acceptance_criteria":"Importing a CSV whose rows are shorter/longer than the header either errors or surfaces a per-column warning with a count of affected rows — never a silent drop. Test feeds a 5-column header with 4-value rows and asserts the warning/error mentions the missing column.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-k8i","title":"CSV import silently drops columns when row widths mismatch","description":"crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard.","acceptance_criteria":"Importing a CSV whose rows are shorter/longer than the header either errors or surfaces a per-column warning with a count of affected rows — never a silent drop. Test feeds a 5-column header with 4-value rows and asserts the warning/error mentions the missing column.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:15Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","comments":[{"id":"22ff12ad-bc50-5f84-a9f0-3bca62e24f60","issue_id":"improvise-k8i","author":"Edward Langley","text":"Report-only bug hunt at 70025e2: the LONG-row half of this closed issue still fails. parse_csv_bytes_with_warnings(b\"A,B\\n1,2,3\\n\") succeeds with A=1, B=2 and no warnings; 3 is discarded. The original acceptance criteria explicitly include longer rows. A temporary test asserting error-or-warning failed. Existing short-row repair and tests do not cover this direction. No duplicate ticket filed; no code changed.","created_at":"2026-09-21T20:33:43Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","acceptance_criteria":"A syntactically invalid .improv file produces an error containing the source line number (and file path when loading from disk); pest spans already carry this. Test asserts the error message contains the expected line:col.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-09-20T02:10:39Z","closed_at":"2026-09-20T02:10:39Z","close_reason":"Done. The server as a wasm blob in a Web Worker: same App, same Sessions, same Host, behind self.onmessage/self.postMessage with OPFS underneath. Closed with --force because the graph still lists improvise-xf1 (browser verification of OpfsFs), which is DEFERRED by user decision -- that block is waived, not satisfied.\n\nAC 1 met (wasm32 clean, builds through wasm-pack, added to the CI wasm job). AC 3 met with room: 1,334,892 bytes raw / 496,965 GZIPPED plus 6,700 of JS glue -- about 492 KB against a 3 MB budget; with the client's unchanged 122,599 the pair is ~600 KB. AC 4 met: tests/post_message.rs drives a real Worker with nothing but the JSON a MessagePort carries.\n\nAC 2 IS MET HOST-SIDE, NOT BROWSER-SIDE, and the line is exact. Tested: worker.rs (16t), vfs.rs, post_message.rs (6t) -- handshake, Addressed filter, rejection path, persist trigger, seeding and list are all on this side. Untested: bindings.rs (cfg wasm32) and web/worker.js -- compile-checked, node --check-ed, all three calls confirmed in the generated .d.ts with matching arity, nothing more. createSyncAccessHandle exists in no harness, so OPFS ITSELF REMAINS NEVER RUN: if persistence misbehaves in a browser it is the first place to look.\n\nHOST WAS MOVED, NOT MIRRORED -- the one non-additive call, sanity-checked at merge. Host is App + Sessions + handle, contains no socket, and lived in a tokio crate the worker cannot depend on. It now lives in improvise_command::ui::host beside Sessions; ws-server re-exports so every path resolves; registry stayed behind because a worker hosts ONE document. Re-implementing the loop would have been the mirror improvise-xks6 had just deleted for the frames, and a drifting Applied/Broadcast match is invisible until a client sees it. Verified at merge: host.rs imports only crate::ui::{app,project} and improvise_protocol, and improvise-command's dependency list is unchanged.\n\nTHE DOCUMENT IS NAMED OUT OF BAND, in the worker's URL -- the analogue of the socket naming it in the ws:// path. NO FOURTH ClientFrame. Per the user's ruling mid-flight, loading is THE VFS, not a bespoke entry point: a seed is a Storage::write read back like any other document, so a fetched demo, an upload and a saved file share one parse path and a seed never clobbers an edit.\n\nClientFrame::Key IS LEFT ANSWERING NOTHING, and the brief's premise that a worker could resolve it was WRONG for a reason worth keeping. A worker CAN call reduce_key; the blocker is downstream and identical on both transports -- NOTHING IN THE PROJECTION STREAM CARRIES THE HOST'S OWN ViewState. A client's slice moves only by folding Commands, and project emits document projections, a grid diff and a basis, none of which is a mode or cursor change. So resolving a key host-side does not under-deliver, it DIVERGES: the host believes it is in Editing while the client draws Normal, and viewport() reads the mirror's cursor so the grid would not even scroll. That is improvise-bpq2, now a DEMONSTRATED property rather than prose -- a_key_resolved_on_the_host_moves_state_no_client_can_hear fires reduce_key against a real App and asserts the mirror did not follow.\n\nFOUR REAL OBSTACLES, AND NONE WAS OPFS: Host in the wrong crate; Save unreachable from the wire; dirty never set by a wire edit; the async seam unreachable through a Box. THREE OF THE FOUR ARE GAPS IN THE SHARED LAYER THAT THE SOCKET SERVER HAS TOO -- it never noticed, because its documents are opened from a CLI and saved by a human pressing a key in a TUI that is not there. Filed separately. Being the second real consumer of an abstraction is what audits it.\n\n1155 passing (+22), clippy/fmt clean, wasm32 clean on all SEVEN bundle crates, actionlint clean. Delivery stayed cheap: two lines in build.sh, one cp -rf plus one cp in the assemble step.","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-xf1","type":"blocks","created_at":"2026-08-31T23:11:01Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":3,"comment_count":0} @@ -44,14 +51,25 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-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","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:55Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:19Z","labels":["bug","parity","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:18Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7iww","title":"Cached circular formula publishes the last iteration as a valid number","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-core/src/model/types.rs:549-571,624-641\n\nStore Revenue=10, add Revenue=Revenue+1. Direct Model::evaluate correctly returns an error. After recompute_formulas, evaluate_aggregated(OverHidden) returns Number(26). The cached evaluator seeds from raw data then iterates 16 times without convergence and leaves the last numeric result in formula_cache. This is the displayed path because default views hide virtual categories. On a workbook with an explicit stored measure target, a circular formula looks like a real calculated value. Separate from closed improvise-ubu, whose depth guard prevents recursive stack overflow; that guard does not run in the cached reference path. Mark nonconverging results as errors and use this direct/cached disagreement to drive a shared evaluator-policy refactor. Duplicate audit: circular, fixed, full descriptions; read improvise-ubu.\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 circular_formula_not_a_plausible_cached_number() {\n use improvise_io::{formula::parse_formula,model::{Aggregation,cell::{CellKey,CellValue}}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Revenue = Revenue + 1\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))));\n w.model.recompute_formulas(\u0026[\"_Index\".into(),\"_Dim\".into()]);\n let actual=w.model.evaluate_aggregated(\u0026key,Aggregation::OverHidden);\n assert!(matches!(actual,Some(CellValue::Error(_))),\"circular formula became {actual:?}\");\n}\n```\nRun cargo test circular_formula_not_a_plausible_cached_number -- --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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:40Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:37:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3b2b","title":"Deleting a virtual category from the category panel disables records mode","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/panel.rs:597-609; ui/effect.rs:295-302; core/src/workbook.rs:71\n\nThe panel deliberately shows _Index/_Dim/_Measure (improvise-vjms), but its delete command treats them as ordinary removable categories. Execute DeleteCategoryAtCursor at initial cursor 0 (_Index), then ToggleRecordsMode. Status says Records mode but layout.is_records_mode() is false: the new view has no _Index to set on Row. The invariant that virtual categories always exist is broken through a shipped user action. Protect required categories at a shared authoritative boundary, with both TUI and wire tests; merely hiding the delete key leaves the wire mutation unguarded. Duplicate checks: virtual, reserved, category; read improvise-vjms, which decides visibility, not removability.\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 category_panel_cannot_delete_required_virtual() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::DeleteCategoryAtCursor},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n app.view_state.cat_panel_cursor=0;\n let fx=DeleteCategoryAtCursor.execute(\u0026app.cmd_context(KeyCode::Char('d'),KeyModifiers::NONE));app.apply_effects(fx);\n let fx=improvise::command::cmd::grid::ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode(),\"deleting _Index prevents records mode; status {}\",app.view_state.status_msg);\n}\n```\nRun cargo test category_panel_cannot_delete_required_virtual -- --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:50Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:35:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s08t","title":"Unknown names inside aggregate formulas silently select unrelated data","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-core/src/model/types.rs:66-83\n\nRevenue=10 and Total=SUM(Typo), where Typo does not exist, yields Number(10) instead of a reference error. When find_item_category fails, eval_agg simply leaves partial unconstrained and aggregates every numeric measure matching the context. A typo produces a plausible wrong total. This is distinct from computed measures being bypassed: missing identifiers must fail before the raw-value fast path. Cover unknown refs across aggregate functions and cached/direct evaluation. Duplicate checks: reference, SUM, aggregate, formula, 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,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 unknown_aggregate_reference_is_not_all_measures() {\n use improvise_io::{formula::parse_formula,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Total = SUM(Typo)\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Total\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))),\"actual {:?}\",w.model.evaluate(\u0026key));\n}\n```\nRun cargo test unknown_aggregate_reference_is_not_all_measures -- --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-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-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} {"_type":"issue","id":"improvise-q914","title":"improvise cmd advertises JSON it does not accept, and swallows every effect error at exit 0","description":"Found by the UX audit. Both halves DEMONSTRATED; orchestrator re-verified both.\n\n`improvise cmd` and `improvise script` are the scripting interface, and they are unusable as one for two independent reasons.\n\n## 1 — they advertise a language they do not accept\n cmd Run a JSON command headless (repeatable)\n Arguments: [JSON]... JSON command strings\n script Arguments: \u003cPATH\u003e Script file (one JSON command per line, # comments)\n\n $ improvise cmd '{\"cmd\":\"add-cat\",\"args\":[\"Region\"]}' -f m.improv\n Parse error: Unknown command: {\"cmd\":\"add-cat\",\"args\":[\"Region\"]}\n\nThe language is the `:` command line (add-cat Region, `.`-separated, \"quoted strings\"). src/main.rs is the ONLY place with the wrong words; README and AGENTS.md have it right. Verified: `improvise cmd --help` says JSON three times.\n\n## 2 — every error that goes through the status bar is swallowed, exit 0\nrun_headless_commands (src/main.rs:331) applies effects and never reads app.view_state.status_msg. Only PARSE errors reach stderr. Everything an Effect refuses is silent, exit 0, AND THE FILE IS SAVED ANYWAY:\n\n $ improvise cmd 'formula Product \"Bad = (((\"' -f n.improv ; echo $?\n 0 # no formula added, nothing said\n $ improvise cmd 'add-item Nonexistent Thing' -f n.improv ; echo $?\n 0 # workbook.add_item's Err went to status_msg\n $ improvise cmd 'show-item Region Nowhere' -f n.improv ; echo $?\n 0\n\nVerified independently: add-item against a nonexistent category exits 0 with no output.\n\nSilent no-ops with a success exit code are the worst possible contract for a scripting interface. Note this is also what makes `fit-measure` unusable headlessly — its whole deliverable, including the SEED THAT REPRODUCES THE FIT, goes to set_status.\n\n## Acceptance Criteria\n- Three clap doc strings: [JSON] -\u003e [COMMAND], \"JSON command\" -\u003e \"command line, as typed after `:`\".\n- After each batch in run_headless_commands: if status_msg is non-empty, eprintln! it and clear it.\n- Exit non-zero when an effect set abort_effects.\n- A test per arm — a refused effect must exit non-zero and say why.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:03Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-p8ls","title":"Nothing on screen tells a TUI user how to get help or how to quit","description":"Found by the UX audit. DEMONSTRATED via the help printout.\n\nhelp::advertised(AppMode::Normal) yields:\n\n NORMAL hjkl\u003c-^-\u003e:nav ai:edit R:records P:prune F:formulas C:categories\n V:views T:tiles [:prev-page ]:next-page \u003e:drill ::cmd\n\nNO `?`. NO F1. NO :q. NO Ctrl-S.\n\nThe welcome popup that DOES name ? and :q is drawn only when App::is_empty_model() — so `improvise mymodel.improv` shows it to nobody. A user who opens a document has no on-screen route to help and no on-screen route out.\n\nThis is the \"how do I quit vim\" problem with the escape hatch removed, and it is the highest-frequency finding in the audit: it affects every session that opens a file.\n\n## Acceptance Criteria\n- Add (\"enter-mode\", arg(\"help\"), \"help\") to the AppMode::Normal arm of advertised(). Costs ~8 columns and is DERIVED, so it cannot go stale.\n- :q is an ex-command rather than a binding, so it cannot come from advertised(); hint_tail(AppMode::Normal) can carry it — e.g. \"— :q to quit ? for help\".\n- The browser has the same gap and the same fix; see the #backend-notice finding, which wants the same sentence in the space it frees.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:02Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z05h","title":"Every documented spelling of :formula fails, and the refusal names a command the user did not type","description":"Found by the UX audit. DEMONSTRATED; orchestrator re-verified.\n\nThe parser splits on whitespace (command/parse.rs::tokenize), so a formula containing spaces must be quoted. NOTHING IN THE PRODUCT SAYS SO, and all three places that teach :formula teach the unquoted form.\n\n Help page 1, Quick start step 4 : :formula Product Total = Widget + Gadget\n Help page 5, Model building : :formula \u003ccat\u003e \u003cName=expr\u003e\n draw.rs::draw_welcome : :formula \u003ccat\u003e \u003cexpr\u003e e.g. Profit = Revenue - Cost\n\n $ improvise cmd 'formula Product Total = Widget + Gadget' -f m.improv\n Parse error: add-formula requires 1-2 argument(s), got 6 [exit 1]\n $ improvise cmd 'formula Product \"Total = Widget + Gadget\"' -f m.improv\n works\n\n## Three UX bugs stacked\n1. The documented syntax is wrong, on the product's own quick-start page, for the headline feature.\n2. The refusal NAMES A COMMAND THE USER DID NOT TYPE — they typed `formula`, the message says `add-formula`. The alias resolves in CmdRegistry::resolve before the arity check in effect_cmds.rs:204.\n3. The refusal states the ARITY, not the SHAPE, so it is unactionable: \"requires 1-2 argument(s), got 6\" does not suggest quoting.\n\n## Why the help escaped the guard that would have caught it\nhelp.rs derives HelpLine::Keys rows from the keymap and DROPS a row whose command resolves to nothing, guarded by every_help_page_key_row_names_keys_that_exist. The ex-commands are HelpLine::Literal rows — the part that escaped the derivation. That is the whole unguarded surface and this is what was in it.\n\n## The non-broken path, worth keeping in mind\nThe formula panel (F, then n) takes free text `Name = expression` and works on both surfaces. The help is pointing new users at the one door that is jammed.\n\n## Acceptance Criteria\n- Quote the examples in all three places: :formula Product \"Total = Widget + Gadget\"\n- The welcome popup's `\u003cexpr\u003e` becomes `\u003cName=expr\u003e`; `\u003cexpr\u003e` alone is not accepted.\n- The refusal names the command as typed and states the SHAPE: formula: expected `formula [\u003ccategory\u003e] \"\u003cName\u003e = \u003cexpr\u003e\"` — got 6 words; quote the formula.\n- Check the four asciinema casts in docs/: if any shows the unquoted spelling it inherits this and needs re-recording.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5hfh","title":"Duplicate CSV headers silently overwrite earlier columns","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:50-69\n\nCSV A,A followed by 1,2 succeeds with a single A=2 field and no warnings. Both header positions map to the same JSON object key. Short rows can even overwrite an earlier real value with Null during padding. Reject duplicate names after trimming, or disambiguate explicitly with a warning. Duplicate checks: duplicate, CSV; improvise-k8i is specifically row-width mismatches, not duplicate header names.\n\nDemonstrated: temporary external Cargo test duplicate_csv_headers_not_silent failed against the current source. Reproduce in a standalone Rust 2024 package depending on improvise-io by path:\n\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\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#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n\n```\nRun cargo test --manifest-path \u003ctemporary-package\u003e/Cargo.toml duplicate_csv_headers_not_silent -- --nocapture.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:44Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:27:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-izx2","title":"Loading a saved workbook resurrects its deleted Default view","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/parse.rs:399-425\n\nCreate Custom, delete Default, save and reopen. Expected only Custom; actual views are Default and Custom. build_workbook starts with Workbook::new, which inserts Default, then only adds/updates file views. Preserve the exact persisted set while retaining a sensible fallback for files with no view sections. Duplicate checks: Default, persist; improvise-azhb is about choosing the document default, not resurrecting a deleted view.\n\nDemonstrated: temporary external Cargo test deleted_default_stays_deleted failed against the current source. Reproduce in a standalone Rust 2024 package depending on improvise-io by path:\n\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\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#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n\n```\nRun cargo test --manifest-path \u003ctemporary-package\u003e/Cargo.toml deleted_default_stays_deleted -- --nocapture.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:27:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jc95","title":"CSV nonfinite and overflowing numeric tokens are silently replaced with zero","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:133-136\n\nA CSV Value column containing NaN imports as JSON number 0 with no warning. inf, -inf and 1e999 take the same parse::\u003cf64\u003e / from_f64(...).unwrap_or(Number::from(0)) branch. A missing/nonrepresentable measurement becomes an invented real zero. Refuse with a located error or preserve/report the token; never fabricate 0. Duplicate checks: CSV, NaN, non-finite; existing gp-tree numeric tickets affect a separate pipeline.\n\nDemonstrated: temporary external Cargo test csv_nonfinite_is_not_zero failed against the current source. Reproduce in a standalone Rust 2024 package depending on improvise-io by path:\n\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\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#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n\n```\nRun cargo test --manifest-path \u003ctemporary-package\u003e/Cargo.toml csv_nonfinite_is_not_zero -- --nocapture.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:27:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3yv3","title":"Saving reorders category items when grouped and ungrouped items are interleaved","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs:156-185\n\nAdd grouped item first under G, then ungrouped second. Before save the insertion order is [first, second]; parse_md(format_md(wb)) returns [second, first]. The writer collects every ungrouped item into bare and emits that entire list before grouped. Grid axis ordering consumes item insertion order, so reopening rearranges rows/columns. Preserve interleaving when writing, with a populated roundtrip regression. Duplicate checks: group, order, reorder, roundtrip; existing prune/group-boundary issues concern rendering, not serialization.\n\nDemonstrated: temporary external Cargo test grouped_item_order_survives_save failed against the current source. Reproduce in a standalone Rust 2024 package depending on improvise-io by path:\n\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\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#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n\n```\nRun cargo test --manifest-path \u003ctemporary-package\u003e/Cargo.toml grouped_item_order_survives_save -- --nocapture.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:27:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5hfh","title":"Duplicate CSV headers silently overwrite earlier columns","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:50-69\n\nCSV A,A followed by 1,2 succeeds with a single A=2 field and no warnings. Both header positions map to the same JSON object key. Short rows can even overwrite an earlier real value with Null during padding. Reject duplicate names after trimming, or disambiguate explicitly with a warning. Duplicate checks: duplicate, CSV; improvise-k8i is specifically row-width mismatches, not duplicate header names.\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 duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n```\nRun cargo test duplicate_csv_headers_not_silent. 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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:44Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-izx2","title":"Loading a saved workbook resurrects its deleted Default view","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/parse.rs:399-425\n\nCreate Custom, delete Default, save and reopen. Expected only Custom; actual views are Default and Custom. build_workbook starts with Workbook::new, which inserts Default, then only adds/updates file views. Preserve the exact persisted set while retaining a sensible fallback for files with no view sections. Duplicate checks: Default, persist; improvise-azhb is about choosing the document default, not resurrecting a deleted view.\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 deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n```\nRun cargo test deleted_default_stays_deleted. 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":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:32:03Z","closed_at":"2026-09-21T20:32:03Z","close_reason":"Duplicate of concurrently filed improvise-m4rv; retain that richer report. Independent failing regression preserved here and cross-linked there.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jc95","title":"CSV nonfinite and overflowing numeric tokens are silently replaced with zero","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/import/csv_parser.rs:133-136\n\nA CSV Value column containing NaN imports as JSON number 0 with no warning. inf, -inf and 1e999 take the same parse::\u003cf64\u003e / from_f64(...).unwrap_or(Number::from(0)) branch. A missing/nonrepresentable measurement becomes an invented real zero. Refuse with a located error or preserve/report the token; never fabricate 0. Duplicate checks: CSV, NaN, non-finite; existing gp-tree numeric tickets affect a separate pipeline.\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 csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n```\nRun cargo test csv_nonfinite_is_not_zero. 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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3yv3","title":"Saving reorders category items when grouped and ungrouped items are interleaved","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs:156-185\n\nAdd grouped item first under G, then ungrouped second. Before save the insertion order is [first, second]; parse_md(format_md(wb)) returns [second, first]. The writer collects every ungrouped item into bare and emits that entire list before grouped. Grid axis ordering consumes item insertion order, so reopening rearranges rows/columns. Preserve interleaving when writing, with a populated roundtrip regression. Duplicate checks: group, order, reorder, roundtrip; existing prune/group-boundary issues concern rendering, not serialization.\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\n```\nRun cargo test grouped_item_order_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":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:31:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qu4a","title":"View::prune_empty is never persisted, so hide-empty is lost on every save","description":"Found by the io bug hunt. DEMONSTRATED.\n\nView::prune_empty is never written to disk. format.rs:108 writes format:, hidden: and collapsed: for a view and nothing else; improv.pest:101's view_entry has no prune alternative. The string \"prune\" does not occur in format.rs, parse.rs, improv.pest or roundtrip_tests.rs.\n\n## Steps to Reproduce\nPress P in Normal mode (keymap.rs -\u003e toggle-prune-empty), :w, reopen. Every pruned row and column is back.\n\nThe toggle marks the document dirty, so the user is explicitly invited to save the setting that is about to be discarded.\n\nProbe: a view with prune_empty = true plus axes, page selection, format, hidden and collapsed. Everything else survived; only prune_empty was lost:\n\n ## View: Default\n _Index: none / _Dim: none / _Measure: page / A: row / B: page, y\n format: ,.2f\n hidden: A/x\n collapsed: A/G \u003c- no prune line anywhere\n\nInvisible to fmt(parse(x)) == fmt(parse(fmt(parse(x)))) because both sides drop it — doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share).\n\n## The adjacent irony\nexport.rs:33 was fixed (improvise-iot6) precisely so CSV export HONOURS prune_empty — a setting the document cannot remember.\n\n## Acceptance Criteria\n- A prune line in view_entry (grammar first — improv.pest is the source of truth), one w! in format_md, one arm in walk_view_section/build_view. Emit only when true, as format: does.\n- A round-trip test asserting the FLAG, not just that the output is stable.\n- Decide what an older file without the line means: false is the compatible reading and is what happens today.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:25:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-exdg","title":"A records-mode coordinate cell projects id: None, which the contract says means no cell","description":"Found by the protocol bug hunt. DEMONSTRATED.\n\nproject.rs:331 — `RecordCol::Category(_) =\u003e return None` — consumed by CellDisplay::id, whose contract (render.rs:76 and gotcha 42) says id: None means \"NO PROJECTED CELL\". render.rs::editable, edit_or_drill and record_cell_at_cursor all read it that way.\n\ncell_id uses it for a THIRD thing: a cell that IS visible, DOES hold text, and whose write belongs to the host.\n\n (0,0) text=\"North\" id=None \u003c- the Region coordinate column\n (0,1) text=\"\" id=Some(Create(Category))\n (0,2) text=\"1200\" id=Some(Record(RecordCell{..}))\n (0,3) text=\"\" id=Some(Create(Measure))\n\nUser-visible: the browser paints \"North\" as an ordinary cell and every key that could act on it is a black hole — `i` resolves Upstream with no message.\n\n## Why this is an ADR 002 violation rather than a nit\nADR 002's own argument is that THE KIND OF IDENTITY IS THE PERMISSION. Here the ABSENCE of an identity is being used to encode a permission (\"this one is the host's\"), which is exactly the conflation the ADR was written to prevent. Gotcha 42 states the trap in as many words: None means \"no projected cell\", NOT \"aggregate\" — and conflating them either hides a cell or offers a write the type system exists to make unconstructible.\n\n## Acceptance Criteria\n- A CellId variant naming a coordinate cell (e.g. Coordinate { record, category }), so render.rs can mark it not-editable DELIBERATELY and resolve.rs can decline it out loud.\n- render.rs::editable must then be exhaustive over the new variant rather than falling through.\n- Until that lands, the edit_or_drill decline from improvise-bd9o covers the symptom but not the cause.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:48Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2wu3","title":"A commit from the inline editor addresses the current cache while the screen is frozen","description":"Found by the browser bug hunt. ASSERTED. This one is a potential LOST/MISDIRECTED WRITE and deserves settling before it is dismissed.\n\nweb/app.js:146 — `if (editing) return;` in repaint. So while the DOM editor is open the screen is frozen. But onFrame() KEEPS FOLDING: client.on_frame(text) runs before repaint() and is not guarded. The cache moves while the screen does not.\n\nedit(td, row, col, seed) captures VIEWPORT COORDINATES at paint time (:336), and commit calls client.typed_cell(row, col, input.value) (:360), which resolves (row, col) against the CURRENT GridCache — not the one the user was looking at.\n\n## Steps to Reproduce\nTwo clients on one ws-served document. Browser user clicks the cell showing North / Revenue and starts typing. Another client (or the TUI host) hides an item above it, or toggles a group — the rows renumber. The browser folds the new RowFrame and Cells silently; the screen still shows the old table. The user presses Enter.\n\nrecord_cell_at(row, col) now returns the record that has MOVED INTO that position. basis.claim is satisfied because the client folded the fresh Projection::Basis in the same suppressed batch. ADR 001's staleness check PASSES — it is defeated precisely because folding continued. The write lands on a row the user never looked at, and the frame is reported accepted.\n\n## The shape, which is why this is not \"the basis check is wrong\"\nThe basis is current FOR THE CLIENT. What is stale is the SCREEN, and nothing measures that. The (row, col) captured at paint time is a claim about a frame, and the frame is not part of what is claimed.\n\n## Acceptance Criteria\n- A test in crates/improvise-wasm-client/tests/ — no harness needed. Connect A and B to one Host, have A open an editor at a position, apply a HideItem from B, deliver the resulting frames to A's Client WITHOUT repainting, then call typed_cell_frame(row, col, \"42\") and assert the RecordCell it names is the one the user clicked.\n- Fix: capture the RecordCell at edit() time rather than the coordinates, and commit against that surrogate. Client::record_cell_at already returns it; typed_cell_frame would gain a by-surrogate sibling. That makes the position-vs-identity confusion unrepresentable rather than validated, which is this repo's usual answer.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -216,9 +234,10 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-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-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\nDemonstrated: temporary external Cargo test aggregation_survives_save failed against the current source. Reproduce in a standalone Rust 2024 package depending on improvise-io by path:\n\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 grouped_item_order_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_category(\"C\").unwrap();\n w.add_item_in_group(\"C\",\"first\",\"G\").unwrap(); w.add_item(\"C\",\"second\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap();\n assert_eq!(r.model.category(\"C\").unwrap().ordered_item_names(),w.model.category(\"C\").unwrap().ordered_item_names());\n}\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#[test] fn deleted_default_stays_deleted() {\n let mut w=Workbook::new(\"test\"); w.create_view(\"Custom\"); w.delete_view(\"Default\").unwrap();\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert_eq!(r.views.keys().collect::\u003cVec\u003c_\u003e\u003e(),w.views.keys().collect::\u003cVec\u003c_\u003e\u003e());\n}\n#[test] fn csv_nonfinite_is_not_zero() {\n for token in [\"NaN\",\"inf\",\"-inf\",\"1e999\"] {\n let (r,_)=parse_csv_bytes_with_warnings(format!(\"Value\\n{token}\\n\").as_bytes()).unwrap();\n assert_ne!(r[0][\"Value\"].as_f64(),Some(0.0),\"{token} became zero\");\n }\n}\n#[test] fn duplicate_csv_headers_not_silent() {\n let result=parse_csv_bytes_with_warnings(b\"A,A\\n1,2\\n\");\n assert!(result.is_err() || !result.unwrap().1.is_empty(),\"duplicate headers silently overwrite a field\");\n}\n\n```\nRun cargo test --manifest-path \u003ctemporary-package\u003e/Cargo.toml aggregation_survives_save -- --nocapture.\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:27:45Z","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} {"_type":"issue","id":"improvise-el9h","title":"The server's mirror settles its mode once and never again, so it diverges from the client it mirrors","description":"Found by the command-layer bug hunt. ASSERTED, with the settling test specified.\n\nClientSession::fold (project.rs:711) settles self.view.mode against self.grid.records — the grid LAST PROJECTED to that client. project_diff (project.rs:758) updates self.grid AFTER the fold and never re-settles.\n\nThe client side does not have this shape: ClientState::fold calls settle_mode() on EVERY fold including Command::Project, so when the new view's grid projection arrives the client re-settles and is correct.\n\nSo for [SwitchView(records-shaped view), ChangeMode(Normal)] — precisely the case PR #98 exists to fix — THE MIRROR KEEPS Normal WHILE THE CLIENT ENDS ON RecordsNormal. That commit's own message says the mirror was given the settle \"so the two cannot diverge in a field nobody currently reads\"; the mechanism as written does not achieve it.\n\nSecond, smaller instance of the same shape: apply_switch (project.rs:930), adopt_deleted_views and adopt_cursor_epoch all write the mirror with bare reduce_view, bypassing fold and therefore the settle — three doors past the one documented as \"the same function the client runs\".\n\n## Why P3\nClientSession::view() has no production caller (grepped across improvise-command, improvise-ws-server, improvise-worker-server), and reseed/connect rebuild the view from fresh_view(app) rather than from the folded mirror, so no Snapshot currently carries the wrong value. It is a correctness hole waiting for its first reader — which is exactly the shape improvise-e5pf was before someone read it.\n\n## Acceptance Criteria\n- The settling test, which is specified and cheap: drive a Host with a pivot-shaped default view and a second records-shaped view, Sessions::connect, send ClientFrame::Commands([SwitchView(\"records view\"), ChangeMode(Normal)]), and compare session.view().mode against a ClientState fed the resulting ServerFrames in arrival order. ClientSession::view is pub so this is reachable from a test in project.rs.\n- Then the one-line fix: re-settle at the end of project_diff after self.grid = desired. Or make fold the only writer of the mirror's ViewState and settle there plus after each projection — which also closes the three bypass doors.\n- Do NOT fix before the test exists: the current behaviour is unobservable, so a fix without a test is unfalsifiable.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:25:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j79v","title":"Four click surfaces still use click, so a gesture from an open editor is swallowed","description":"Found by the browser bug hunt. ASSERTED (read, not run) — what would settle it is stated below.\n\nimprovise-0x9u fixed the grid's cell listener: web/app.js:321 is on MOUSEDOWN with event.preventDefault(), and the comment at :318 gives the reason — from an editing state the focused \u003cinput\u003e's blur repaints first and the element is detached before click can fire.\n\nTHE OTHER FOUR CLICK SURFACES KEPT `click`:\n web/app.js:396 paintPanel (view and formula panel rows)\n web/app.js:436 paintTiles (the tile bar)\n web/app.js:453 categoryList (category panel rows)\n\n## Steps to Reproduce\nOpen the demo. Click an editable cell — the inline \u003cinput\u003e opens and takes focus. Now click a tile in the tile bar (or a category/view/formula panel row).\n\nMousedown's default action moves focus off the \u003cinput\u003e -\u003e blur -\u003e dismiss(false) -\u003e close() -\u003e painted = -1 -\u003e repaint() -\u003e replaceChildren(). The element the user pressed is now detached, mouseup lands elsewhere, and NO click event reaches the listener.\n\nThe gesture is swallowed: the first click only closes the editor. The user clicks twice for one action, or concludes the tile bar is dead.\n\n## Acceptance Criteria\n- The four listeners become mousedown + preventDefault, matching the td arm. A shared helper would be better; the four are identical in shape.\n- A case in harness/tests/shell.spec.js (the right home per gotcha 48): open the inline editor on #grid td.editable, click #tiles .tile, assert the tile's axis changed after ONE click.\n- Prove the mechanism is the one named: revert the td listener to click and confirm the existing commit test fails.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependency_count":0,"dependent_count":0,"comment_count":0}