From f272a9d459172d2c4a5583f44ae1b89856c7233b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 23:35:08 -0700 Subject: [PATCH 1/4] chore: update roadmap --- roadmap.org | 3457 +++++++++------------------------------------------ 1 file changed, 555 insertions(+), 2902 deletions(-) diff --git a/roadmap.org b/roadmap.org index 4a7524b..fd8a937 100644 --- a/roadmap.org +++ b/roadmap.org @@ -5,7 +5,7 @@ #+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug #+PROPERTY: COOKIE_DATA todo recursive -Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-trees: each root is a goal that nothing else depends on; its children are the deps blocking it. Diamonds in the DAG are duplicated so each tree stands alone. +Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-trees: each root is a goal that nothing else depends on; its children are the deps blocking it. Diamonds in the DAG are duplicated so each tree stands alone. * DOING 'o' (add-record-row) broken in fresh data models (standalone) :standalone:P2:bug:@cursor_f4c497bb: :PROPERTIES: @@ -37,7 +37,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :KIND: standalone :END: -* TODO Epic: Browser frontend via synchronized Redux-style stores (epic) [0/68] :epic:P2:feature: +* TODO Epic: Browser frontend via synchronized Redux-style stores (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-6jk :TYPE: feature @@ -80,7 +80,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO Browser frontend MVP: end-to-end working demo (epic) [0/23] :epic:P2:feature: +** TODO Browser frontend MVP: end-to-end working demo (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-1ey :TYPE: feature @@ -102,7 +102,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing. -*** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [0/10] :epic:P2:feature: +*** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-q08 :TYPE: feature @@ -124,7 +124,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer. -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -135,6 +135,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -146,30 +147,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -193,7 +170,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -215,7 +192,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -226,6 +203,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -237,30 +215,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -306,7 +260,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -***** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +***** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -351,7 +305,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +*** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -373,7 +327,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -395,7 +349,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -439,7 +393,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -461,7 +415,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -470,86 +424,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -560,6 +470,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -571,30 +482,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -618,7 +505,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -629,6 +516,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: *** Details **** Description @@ -640,30 +528,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -*** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -**** Details -***** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -***** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -***** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -***** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - *** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -687,7 +551,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -709,7 +573,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -720,6 +584,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: **** Details ***** Description @@ -731,30 +596,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - **** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -800,7 +641,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +*** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -845,7 +686,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -867,7 +708,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -*** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +*** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -876,86 +717,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: **** Details ***** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ***** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ***** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ***** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -**** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +**** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ***** Details ****** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ****** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ****** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ****** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -****** Details -******* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -966,6 +763,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: **** Details ***** Description @@ -977,30 +775,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - **** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -1024,7 +798,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -1069,7 +843,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [0/10] :epic:P2:feature: +** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-q08 :TYPE: feature @@ -1091,7 +865,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer. -*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -1102,6 +876,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: **** Details ***** Description @@ -1113,30 +888,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - **** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -1160,7 +911,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -1182,7 +933,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -1193,6 +944,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -1204,30 +956,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -1273,7 +1001,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -**** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +**** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -1318,7 +1046,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -1340,7 +1068,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -1362,7 +1090,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -1406,7 +1134,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +*** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -1428,7 +1156,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -1437,86 +1165,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ***** Details ****** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ****** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ****** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ****** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +***** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ****** Details ******* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -1527,6 +1211,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -1538,30 +1223,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -1617,7 +1278,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Description Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates. -* TODO Epic: Native TUI as in-process client of the unified server architecture (epic) [0/42] :epic:P2:feature: +* TODO Epic: Native TUI as in-process client of the unified server architecture (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-ltq :TYPE: feature @@ -1637,7 +1298,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging. -** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -1681,7 +1342,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Split native binary into in-process client + server, delete &App adapter (epic) [0/19] :epic:P2:feature: +** TODO Split native binary into in-process client + server, delete &App adapter (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-e0u :TYPE: feature @@ -1703,7 +1364,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ. -*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -1769,7 +1430,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [0/3] :epic:P2:feature: +*** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-n10 :TYPE: feature @@ -1791,7 +1452,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -1813,7 +1474,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -1857,7 +1518,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [0/3] :epic:P2:feature: +*** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-pca :TYPE: feature @@ -1879,7 +1540,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -1901,7 +1562,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -1945,7 +1606,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate import wizard widget and state to consume RenderCache (epic) [0/3] :epic:P3:feature: +*** TODO Migrate import wizard widget and state to consume RenderCache (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-764 :TYPE: feature @@ -1967,7 +1628,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -1989,7 +1650,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2033,7 +1694,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [0/3] :epic:P3:task: +*** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-jb3 :TYPE: task @@ -2055,7 +1716,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2077,7 +1738,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2121,7 +1782,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2143,7 +1804,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2209,7 +1870,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [0/3] :epic:P2:feature: +** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-n10 :TYPE: feature @@ -2231,7 +1892,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2253,7 +1914,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2297,7 +1958,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [0/3] :epic:P2:feature: +** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-pca :TYPE: feature @@ -2319,7 +1980,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2341,7 +2002,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2385,7 +2046,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Migrate import wizard widget and state to consume RenderCache (epic) [0/3] :epic:P3:feature: +** TODO Migrate import wizard widget and state to consume RenderCache (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-764 :TYPE: feature @@ -2407,7 +2068,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2429,7 +2090,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2473,7 +2134,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [0/3] :epic:P3:task: +** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-jb3 :TYPE: task @@ -2495,7 +2156,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -2517,7 +2178,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -2561,7 +2222,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -* TODO Epic: Standalone static-web deployment via wasm + VFS storage (epic) [0/237] :epic:P2:feature: +* TODO Epic: Standalone static-web deployment via wasm + VFS storage (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-tm6 :TYPE: feature @@ -2581,7 +2242,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on. -** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -2591,7 +2252,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: *** Details **** Description @@ -2603,51 +2265,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -*** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -**** Details -***** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -***** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -***** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -2669,7 +2287,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** 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. -*** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +*** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -2679,7 +2297,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: **** Details ***** Description @@ -2691,51 +2310,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -**** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -***** Details -****** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -****** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -****** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -2746,6 +2321,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: **** Details ***** Description @@ -2757,30 +2333,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - **** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -2804,7 +2356,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -2826,7 +2378,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -2837,6 +2389,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -2848,30 +2401,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -2917,7 +2446,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -**** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +**** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -2962,7 +2491,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +*** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -2984,7 +2513,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -**** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +**** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -2994,7 +2523,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ***** Details ****** Description @@ -3006,51 +2536,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -****** Details -******* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -*** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +*** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -3072,7 +2558,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -3081,86 +2567,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ***** Details ****** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ****** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ****** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ****** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +***** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ****** Details ******* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Standalone web MVP: end-to-end offline demo (epic) [0/112] :epic:P2:feature: +** TODO Standalone web MVP: end-to-end offline demo (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-bck :TYPE: feature @@ -3182,7 +2624,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Terminal node of standalone epic (improvise-tm6). Depends on improvise-djm (main-thread), the worker-server issue, improvise-d31 (static shell + deploy), and all upstream prerequisites transitively. -*** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +*** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -3204,7 +2646,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** 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. -**** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +**** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -3214,7 +2656,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ***** Details ****** Description @@ -3226,51 +2669,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -****** Details -******* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -3281,6 +2680,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -3292,30 +2692,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -3339,7 +2715,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -3361,7 +2737,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -3372,6 +2748,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -3383,30 +2760,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -3452,7 +2805,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -***** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +***** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -3497,7 +2850,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +**** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -3519,7 +2872,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -***** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +***** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -3529,7 +2882,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ****** Details ******* Description @@ -3541,51 +2895,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -3607,7 +2917,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -3616,86 +2926,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -*** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :epic:P2:feature: +*** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-djm :TYPE: feature @@ -3717,7 +2983,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. -**** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +**** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -3739,7 +3005,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** 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. -***** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +***** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -3749,7 +3015,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ****** Details ******* Description @@ -3761,51 +3028,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -3816,6 +3039,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -3827,30 +3051,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -3874,7 +3074,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -***** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +***** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -3896,7 +3096,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -3907,6 +3107,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -3918,30 +3119,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -3987,7 +3164,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -****** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +****** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -4032,7 +3209,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -***** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +***** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -4054,7 +3231,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -****** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +****** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -4064,7 +3241,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ******* Details ******** Description @@ -4076,51 +3254,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -4142,7 +3276,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -4151,86 +3285,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******* Details ******** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******* TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******** Details ********* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -4252,7 +3342,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -4261,86 +3351,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -4351,6 +3397,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -4362,30 +3409,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -4409,7 +3432,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +**** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -4431,7 +3454,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -***** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +***** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -4453,7 +3476,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -****** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +****** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -4497,7 +3520,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -4519,7 +3542,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -4528,86 +3551,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******* Details ******** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******* TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******** Details ********* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -4618,6 +3597,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -4629,30 +3609,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -4676,7 +3632,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [0/44] :epic:P3:task: +*** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-d31 :TYPE: task @@ -4698,7 +3654,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-djm (main-thread entry point) and the worker-server issue. Mostly configuration and glue — heavy lifting lives in the upstream wasm crates. -**** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :epic:P2:feature: +**** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-djm :TYPE: feature @@ -4720,7 +3676,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. -***** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +***** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -4742,7 +3698,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* 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. -****** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +****** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -4752,7 +3708,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ******* Details ******** Description @@ -4764,51 +3721,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -4819,6 +3732,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -4830,30 +3744,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -4877,7 +3767,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -****** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +****** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -4899,7 +3789,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -******* TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +******* TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -4910,6 +3800,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******** Details ********* Description @@ -4921,30 +3812,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -4990,7 +3857,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -******* TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +******* TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -5035,7 +3902,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -****** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +****** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -5057,7 +3924,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -******* TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +******* TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -5067,7 +3934,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ******** Details ********* Description @@ -5079,51 +3947,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -****** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +****** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -5145,7 +3969,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -******* TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +******* TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -5154,86 +3978,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******** Details ********* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ********* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ********* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ********* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ********* Details ********** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -********* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********** Details -*********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -*********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -*********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -*********** Details -************ Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -************ Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -************ Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -************ Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -5255,7 +4035,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -5264,86 +4044,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******* Details ******** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******* TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******** Details ********* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -5354,6 +4090,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -5365,30 +4102,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -5412,7 +4125,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -***** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +***** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -5434,7 +4147,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -****** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +****** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -5456,7 +4169,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -******* TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +******* TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -5500,7 +4213,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -****** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +****** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -5522,7 +4235,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -******* TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +******* TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -5531,86 +4244,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******** Details ********* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ********* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ********* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ********* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ********* Details ********** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -********* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********** Details -*********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -*********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -*********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -*********** Details -************ Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -************ Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -************ Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -************ Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -******* TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +******* TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -5621,6 +4290,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******** Details ********* Description @@ -5632,30 +4302,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -5679,7 +4325,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :epic:P2:feature: +** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-djm :TYPE: feature @@ -5701,7 +4347,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. -*** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +*** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -5723,7 +4369,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** 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. -**** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +**** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -5733,7 +4379,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ***** Details ****** Description @@ -5745,51 +4392,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -****** Details -******* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -5800,6 +4403,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -5811,30 +4415,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -5858,7 +4438,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +**** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -5880,7 +4460,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -5891,6 +4471,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -5902,30 +4483,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -5971,7 +4528,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -***** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +***** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -6016,7 +4573,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +**** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -6038,7 +4595,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -***** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +***** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -6048,7 +4605,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ****** Details ******* Description @@ -6060,51 +4618,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -6126,7 +4640,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -6135,86 +4649,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -*** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +*** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -6236,7 +4706,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +**** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -6245,86 +4715,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ***** Details ****** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ****** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ****** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ****** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +***** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ****** Details ******* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -6335,6 +4761,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -6346,30 +4773,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -6393,7 +4796,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +*** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -6415,7 +4818,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -6437,7 +4840,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -6481,7 +4884,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -6503,7 +4906,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -6512,86 +4915,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -6602,6 +4961,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -6613,30 +4973,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -6660,7 +4996,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -6682,7 +5018,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -*** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +*** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -6692,7 +5028,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: **** Details ***** Description @@ -6704,51 +5041,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -**** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -***** Details -****** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -****** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -****** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -6770,7 +5063,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -*** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +*** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -6779,86 +5072,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: **** Details ***** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ***** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ***** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ***** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -**** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +**** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ***** Details ****** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ****** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ****** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ****** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -****** Details -******* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [0/44] :epic:P3:task: +** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-d31 :TYPE: task @@ -6880,7 +5129,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-djm (main-thread entry point) and the worker-server issue. Mostly configuration and glue — heavy lifting lives in the upstream wasm crates. -*** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :epic:P2:feature: +*** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-djm :TYPE: feature @@ -6902,7 +5151,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. -**** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature: +**** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-9x6 :TYPE: feature @@ -6924,7 +5173,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** 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. -***** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +***** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -6934,7 +5183,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ****** Details ******* Description @@ -6946,51 +5196,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******* Details -******** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -******** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -******** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -7001,6 +5207,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -7012,30 +5219,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -7059,7 +5242,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -***** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +***** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -7081,7 +5264,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -7092,6 +5275,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -7103,30 +5287,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -7172,7 +5332,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -****** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +****** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -7217,7 +5377,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -***** TODO OPFS-backed Storage implementation for wasm target (epic) [0/3] :epic:P2:feature: +***** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-i34 :TYPE: feature @@ -7239,7 +5399,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this. -****** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +****** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -7249,7 +5409,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: ******* Details ******** Description @@ -7261,51 +5422,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task: +***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-ywd :TYPE: task @@ -7327,7 +5444,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel. -****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -7336,86 +5453,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******* Details ******** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******* TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******** Details ********* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -7437,7 +5510,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -7446,86 +5519,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ****** Details ******* Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******* Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******* Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******* Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +****** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******* Details ******** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ******** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ******** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ******** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -******** Details -********* Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********* Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********* Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********* Details -********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -7536,6 +5565,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ****** Details ******* Description @@ -7547,30 +5577,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******* Details -******** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -7594,7 +5600,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -**** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [0/11] :epic:P3:feature: +**** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-cr3 :TYPE: feature @@ -7616,7 +5622,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself. -***** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +***** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -7638,7 +5644,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -****** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +****** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -7682,7 +5688,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature: +***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw :TYPE: feature @@ -7704,7 +5710,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes. -****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -7713,86 +5719,42 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: ******* Details ******** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. ******** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. ******** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. ******** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +******* TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature + :ID: improvise-dwe + :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 + :KIND: standalone :END: ******** Details ********* Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ********* Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ********* Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ********* Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -********* Details -********** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -********** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -********** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -********** Details -*********** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -*********** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -*********** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -*********** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -7803,6 +5765,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ******* Details ******** Description @@ -7814,30 +5777,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -******** Details -********* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -********* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -********* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -********* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -7861,7 +5800,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ********* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -* TODO Epic: Split improvise into enforced-boundary workspace crates (epic) [0/10] :epic:P2:feature: +* TODO Epic: Split improvise into enforced-boundary workspace crates (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-xgl :TYPE: feature @@ -7872,7 +5811,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 06:33:09 :UPDATED: 2026-04-14 06:33:09 :KIND: epic - :DONE_DEPS: improvise-ewi + :DONE_DEPS: improvise-36h, improvise-45v, improvise-8zh, improvise-ewi :END: ** Details *** Description @@ -7882,31 +5821,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands. -** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -*** Details -**** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -**** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -**** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -**** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature: +** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -7915,196 +5830,66 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 - :UPDATED: 2026-04-14 07:54:16 + :UPDATED: 2026-04-16 06:30:35 :KIND: epic :END: *** Details **** Description Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App. **** Design - AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate). + Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes &mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(&mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState. **** Acceptance Criteria - (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation. + (1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean. **** Notes - IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time. + With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case. -*** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: +*** TODO Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-45v - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic - :END: -**** Details -***** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. -***** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. -***** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. -***** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. - -**** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -***** Details -****** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -****** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -****** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-45v - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:36:11 - :UPDATED: 2026-04-14 06:36:11 - :KIND: epic - :END: -*** Details -**** Description - Refactor Effect from a trait (Box with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn. -**** Design - Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant. -**** Acceptance Criteria - (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec> is replaced with Vec in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change. -**** Notes - Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable. - -*** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh + :ID: improvise-dwe :TYPE: task :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -**** Details -***** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -***** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -***** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -*** Details -**** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -**** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -**** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -*** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 + :CREATED: 2026-04-16 06:28:47 + :UPDATED: 2026-04-16 06:28:47 :KIND: standalone - :DONE_DEPS: improvise-ewi :END: **** Details ***** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. + Prerequisite to improvise-3mm (extract improvise-command). Refactor App into two parts without moving files across crates: (a) AppState, the pure semantic state that effects mutate (workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path); (b) App, a thin wrapper in ui/app.rs that owns { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }. Change Effect::apply's signature from &mut App to &mut AppState. For the small number of effects that today read App-level state at apply time (EnterEditAtCursor reads display_value from layout, etc.), pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and pass it through the effect struct's own fields (self). Apply bodies become pure AppState writes. ***** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. + AppState holds everything currently mutated by Effect::apply, minus rendering caches. App keeps term_width, term_height, layout, last_autosave. apply_effects loop: for e in effects { e.apply(&mut self.state); } then self.rebuild_layout(). cmd_context() is built from both App (for visible_rows/cols via layout + term dims) and AppState (for model/view/mode/buffers). For effects that need layout-derived values at apply time: compute in execute (ctx has layout), bake into self. Example: EnterEditAtCursor { target_mode, initial_value: String } — initial_value is computed pre-effect from ctx.layout and baked in; apply does state.buffers.insert("edit", self.initial_value.clone()); state.mode = self.target_mode.clone(); ***** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). + (1) AppState struct exists; App wraps it. (2) Effect::apply takes &mut AppState, not &mut App. (3) CmdContext.workbook still resolves to &AppState.workbook. (4) rebuild_layout / term-dim tracking remain on App. (5) All existing tests pass. (6) cargo clippy --workspace --tests clean. (7) No behavior change. ***** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. + Do this in-place BEFORE the crate-split (improvise-3mm). Keeps the diff isolated from packaging churn. Audit every impl Effect for Foo — most already only touch fields that belong in AppState; the ones that don't need pre-compute refactors in their associated Cmd. -* TODO Phase 3: Distribution (epic) [0/3] :epic:P3:feature: +* TODO Make Sequence bindings transactional at command level (standalone) :standalone:P3:feature: + :PROPERTIES: + :ID: improvise-0hb + :TYPE: feature + :PRIORITY: P3 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: fido + :CREATED: 2026-04-16 05:45:49 + :UPDATED: 2026-04-16 05:45:49 + :KIND: standalone + :END: +** Details +*** Description + Currently Keymap::dispatch (src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations. + + For example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot. + + True command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass. + + Acceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations. +*** Notes + Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju). + +* TODO Phase 3: Distribution (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-0s6 :TYPE: feature @@ -8121,7 +5906,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Description Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon). -** TODO 3.3 Tag v0.1.0 release (epic) [0/1] :epic:P3:task: +** TODO 3.3 Tag v0.1.0 release (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-3tj :TYPE: task @@ -8172,7 +5957,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Description After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works. -* TODO SQLite persistence format (alternative save/restore) (epic) [0/3] :epic:P3:feature: +* TODO SQLite persistence format (alternative save/restore) (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-9ix :TYPE: feature @@ -8194,7 +5979,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3). -** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -8204,7 +5989,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: *** Details **** Description @@ -8216,51 +6002,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -*** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -**** Details -***** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -***** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -***** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -* TODO (Stretch) Command log + native undo/replay (epic) [0/1] :epic:P3:feature: +* TODO (Stretch) Command log + native undo/replay (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-m91 :TYPE: feature @@ -8304,7 +6046,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -* TODO (Stretch) Hybrid mode: native TUI with remote browser observer (epic) [0/31] :epic:P3:feature: +* TODO (Stretch) Hybrid mode: native TUI with remote browser observer (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-uq7 :TYPE: feature @@ -8326,7 +6068,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code. -** TODO Split native binary into in-process client + server, delete &App adapter (epic) [0/19] :epic:P2:feature: +** TODO Split native binary into in-process client + server, delete &App adapter (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-e0u :TYPE: feature @@ -8348,7 +6090,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ. -*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +*** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -8414,7 +6156,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [0/3] :epic:P2:feature: +*** TODO Migrate GridWidget to consume RenderCache + ViewState (not &App) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-n10 :TYPE: feature @@ -8436,7 +6178,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -8458,7 +6200,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -8502,7 +6244,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [0/3] :epic:P2:feature: +*** TODO Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-pca :TYPE: feature @@ -8524,7 +6266,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -8546,7 +6288,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -8590,7 +6332,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate import wizard widget and state to consume RenderCache (epic) [0/3] :epic:P3:feature: +*** TODO Migrate import wizard widget and state to consume RenderCache (epic) [/] :epic:P3:feature: :PROPERTIES: :ID: improvise-764 :TYPE: feature @@ -8612,7 +6354,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -8634,7 +6376,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -8678,7 +6420,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -*** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [0/3] :epic:P3:task: +*** TODO Migrate help and which_key widgets to consume RenderCache + ViewState (epic) [/] :epic:P3:task: :PROPERTIES: :ID: improvise-jb3 :TYPE: task @@ -8700,7 +6442,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration. -**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [0/2] :epic:P2:feature: +**** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp :TYPE: feature @@ -8722,7 +6464,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'. -***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task: +***** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-35e :TYPE: task @@ -8766,7 +6508,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [0/10] :epic:P2:feature: +** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-q08 :TYPE: feature @@ -8788,7 +6530,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer. -*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -8799,6 +6541,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: **** Details ***** Description @@ -8810,30 +6553,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - **** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -8857,7 +6576,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [0/6] :epic:P2:feature: +*** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqq :TYPE: feature @@ -8879,7 +6598,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ***** Notes Depends on improvise-mae (effect classification) and improvise-protocol (shared types). Does not yet wire up the actual websocket transport — that's a separate issue. This is the logical projection layer. -**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature: +**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi :TYPE: feature @@ -8890,6 +6609,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED: 2026-04-14 07:23:10 :UPDATED: 2026-04-14 07:23:10 :KIND: epic + :DONE_DEPS: improvise-36h :END: ***** Details ****** Description @@ -8901,30 +6621,6 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui). -***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -****** Details -******* Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -******* Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -******* Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -******* Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - ***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: :PROPERTIES: :ID: improvise-vb4 @@ -8970,7 +6666,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ****** Notes Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist. -**** TODO Tag existing effects as model / view / projection-emitting (epic) [0/1] :epic:P2:task: +**** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: :ID: improvise-mae :TYPE: task @@ -9015,7 +6711,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre ******* Notes Can start immediately — no dependencies. Does not require the crate-split epic. Purely a refactor of src/ui/app.rs + touchpoints in effects and commands. -* TODO Parquet columnar export (read-only) (epic) [0/3] :epic:P4:feature: +* TODO Parquet columnar export (read-only) (epic) [/] :epic:P4:feature: :PROPERTIES: :ID: improvise-a5q :TYPE: feature @@ -9037,7 +6733,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Notes Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog. -** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature: +** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-6mq :TYPE: feature @@ -9047,7 +6743,8 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:33:47 :UPDATED: 2026-04-14 07:33:47 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-8zh :END: *** Details **** Description @@ -9059,51 +6756,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre **** Notes Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing. -*** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task: - :PROPERTIES: - :ID: improvise-8zh - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:23 - :UPDATED: 2026-04-14 06:35:23 - :KIND: epic - :END: -**** Details -***** Description - Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub. -***** Acceptance Criteria - (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges. -***** Notes - Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path. - -**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone) :standalone:P2:feature:@spot: - :PROPERTIES: - :ID: improvise-36h - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: spot - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:35:05 - :UPDATED: 2026-04-15 10:10:14 - :KIND: standalone - :DONE_DEPS: improvise-ewi - :END: -***** Details -****** Description - Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields. -****** Design - Introduce pub struct Workbook { pub model: Model, pub views: IndexMap, pub active_view: String, pub measure_agg: HashMap }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view. -****** Acceptance Criteria - (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass). -****** Notes - This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving. - -* TODO Phase 4: Landing page (optional) (epic) [0/3] :epic:P4:feature: +* TODO Phase 4: Landing page (optional) (epic) [/] :epic:P4:feature: :PROPERTIES: :ID: improvise-kh8 :TYPE: feature @@ -9119,7 +6772,7 @@ Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-tre *** Description Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch. -** TODO 4.2 Enable GitHub Pages (epic) [0/1] :epic:P4:task: +** TODO 4.2 Enable GitHub Pages (epic) [/] :epic:P4:task: :PROPERTIES: :ID: improvise-3gy :TYPE: task From 489e2805e86ca9fed0e704664652822f3a1e1ecf Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 23:42:44 -0700 Subject: [PATCH 2/4] feat(ui): implement AbortChain and CleanEmptyRecords effects Implement AbortChain and CleanEmptyRecords effects to allow short-circuiting effect batches and purging cells with empty coordinates. Update the App struct to support aborting effects during the application of an effect batch. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf) --- src/ui/app.rs | 95 ++++++++++++++++++++++++++++++- src/ui/effect.rs | 145 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 225 insertions(+), 15 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index a21593d..ec53bcd 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -229,6 +229,12 @@ pub struct App { /// Current grid layout, derived from model + view + drill_state. /// Rebuilt via `rebuild_layout()` after state changes. pub layout: GridLayout, + /// When set to true by an effect during `apply_effects`, the remaining + /// effects in the batch are skipped. The flag is reset at the start of + /// every `apply_effects` call. Use via the `AbortChain` effect — this is + /// the mechanism by which e.g. "advance at bottom-right" short-circuits + /// the trailing `EnterEditAtCursor` in a `CommitAndAdvance` chain. + pub abort_effects: bool, keymap_set: KeymapSet, } @@ -272,6 +278,7 @@ impl App { buffers: HashMap::new(), transient_keymap: None, layout, + abort_effects: false, keymap_set: KeymapSet::default_keymaps(), } } @@ -338,7 +345,8 @@ impl App { visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); - let col_widths = compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals); + let col_widths = + compute_col_widths(&self.workbook.model, layout, fmt_comma, fmt_decimals); let row_header_width = compute_row_header_width(layout); compute_visible_cols( &col_widths, @@ -353,8 +361,16 @@ impl App { } pub fn apply_effects(&mut self, effects: Vec>) { + self.abort_effects = false; for effect in effects { effect.apply(self); + if self.abort_effects { + // AbortChain (or another abort-setting effect) requested + // that the rest of this batch be skipped. Reset the flag so + // the next dispatch starts clean. + self.abort_effects = false; + break; + } } self.rebuild_layout(); } @@ -909,6 +925,73 @@ mod tests { app } + /// improvise-3zq (bug #2): `AddRecordRow` creates a cell with an empty + /// `CellKey` when no Page-axis categories supply coords — that cell + /// serialises as ` = 0` in .improv and re-appears on every records + /// toggle. Leaving records mode must clean up any such meaningless + /// records (inverse of the `SortData` that runs on entry). + #[test] + fn leaving_records_mode_cleans_empty_key_cells() { + use crate::model::cell::{CellKey, CellValue}; + let mut app = records_model_with_two_rows(); + // Simulate Tab-at-bottom-right having produced an empty-key cell. + app.workbook + .model + .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); + assert!( + app.workbook + .model + .data + .iter_cells() + .any(|(k, _)| k.0.is_empty()), + "setup: empty-key cell should be present" + ); + // Leave records mode via R. + app.handle_key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)) + .unwrap(); + assert!( + !app.layout.is_records_mode(), + "setup: should have left records mode" + ); + assert!( + !app.workbook + .model + .data + .iter_cells() + .any(|(k, _)| k.0.is_empty()), + "empty-key records should be cleaned when leaving records mode" + ); + } + + /// improvise-3zq (bug #1): Enter on the bottom-right cell of records + /// view should commit and leave edit mode. Previously `CommitAndAdvance` + /// pushed an `EnterEditAtCursor` effect unconditionally, so the cursor + /// stayed put and we re-entered editing on the same cell. + #[test] + fn enter_at_bottom_right_of_records_view_exits_editing() { + let mut app = records_model_with_two_rows(); + let last_row = app.layout.row_count() - 1; + let last_col = app.layout.col_count() - 1; + app.workbook.active_view_mut().selected = (last_row, last_col); + + app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)) + .unwrap(); + assert!(app.mode.is_editing(), "setup: should be editing"); + + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert!( + !app.mode.is_editing(), + "Enter at bottom-right should exit editing, got {:?}", + app.mode + ); + assert!( + matches!(app.mode, AppMode::RecordsNormal), + "should return to RecordsNormal, got {:?}", + app.mode + ); + } + /// improvise-hmu: TAB on the bottom-right cell of records view should /// insert a new record below and move to the first cell of the new row /// in edit mode. @@ -963,7 +1046,8 @@ mod tests { ("Month".to_string(), "Jan".to_string()), ("Region".to_string(), "East".to_string()), ]); - wb.model.set_cell(record_key.clone(), CellValue::Number(1.0)); + wb.model + .set_cell(record_key.clone(), CellValue::Number(1.0)); let mut app = App::new(wb, None); app.handle_key(KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE)) @@ -1034,7 +1118,12 @@ mod tests { .unwrap(); assert!( - !app.workbook.model.category("Region").unwrap().items.contains_key(""), + !app.workbook + .model + .category("Region") + .unwrap() + .items + .contains_key(""), "records-mode edits should not create empty category items" ); } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 16ae172..bdf7285 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -548,7 +548,7 @@ pub struct Save; impl Effect for Save { fn apply(&self, app: &mut App) { if let Some(ref path) = app.file_path { - match crate::persistence::save(&app.workbook,path) { + match crate::persistence::save(&app.workbook, path) { Ok(()) => { app.dirty = false; app.status_msg = format!("Saved to {}", path.display()); @@ -567,7 +567,7 @@ impl Effect for Save { pub struct SaveAs(pub PathBuf); impl Effect for SaveAs { fn apply(&self, app: &mut App) { - match crate::persistence::save(&app.workbook,&self.0) { + match crate::persistence::save(&app.workbook, &self.0) { Ok(()) => { app.file_path = Some(self.0.clone()); app.dirty = false; @@ -927,6 +927,44 @@ impl Effect for SetPanelCursor { } } +// ── Chain control ──────────────────────────────────────────────────────────── + +/// Signals `App::apply_effects` to skip the remaining effects in the batch. +/// The flag is reset at the start of every `apply_effects` call, so each +/// dispatch starts clean. Use this when a sequence's premise no longer +/// holds (e.g. "advance to next cell" at bottom-right) and later effects +/// (e.g. "re-enter editing there") should be short-circuited. +#[derive(Debug)] +pub struct AbortChain; +impl Effect for AbortChain { + fn apply(&self, app: &mut App) { + app.abort_effects = true; + } +} + +// ── Records hygiene ────────────────────────────────────────────────────────── + +/// Remove cells whose `CellKey` has no coordinates — these are meaningless +/// records that can only be produced by `AddRecordRow` when no page +/// filters are set. Pushed by `ToggleRecordsMode` when leaving records +/// mode, as the inverse of the `SortData` that runs on entry. +#[derive(Debug)] +pub struct CleanEmptyRecords; +impl Effect for CleanEmptyRecords { + fn apply(&self, app: &mut App) { + let empties: Vec = app + .workbook + .model + .data + .iter_cells() + .filter_map(|(k, _)| if k.0.is_empty() { Some(k) } else { None }) + .collect(); + for key in empties { + app.workbook.model.clear_cell(&key); + } + } +} + // ── Convenience constructors ───────────────────────────────────────────────── pub fn mark_dirty() -> Box { @@ -987,8 +1025,8 @@ pub fn help_page_set(page: usize) -> Box { #[cfg(test)] mod tests { use super::*; - use crate::workbook::Workbook; use crate::model::cell::{CellKey, CellValue}; + use crate::workbook::Workbook; fn test_app() -> App { let mut wb = Workbook::new("Test"); @@ -1048,7 +1086,10 @@ mod tests { ("Month".into(), "Jan".into()), ]); SetCell(key.clone(), CellValue::Number(42.0)).apply(&mut app); - assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(42.0))); + assert_eq!( + app.workbook.model.get_cell(&key), + Some(&CellValue::Number(42.0)) + ); ClearCell(key.clone()).apply(&mut app); assert_eq!(app.workbook.model.get_cell(&key), None); @@ -1073,7 +1114,8 @@ mod tests { let mut app = test_app(); // "Margin" does not exist as an item in "Type" before adding the formula assert!( - !app.workbook.model + !app.workbook + .model .category("Type") .unwrap() .ordered_item_names() @@ -1118,7 +1160,8 @@ mod tests { ); // Should NOT be in the category's own items assert!( - !app.workbook.model + !app.workbook + .model .category("_Measure") .unwrap() .ordered_item_names() @@ -1293,6 +1336,69 @@ mod tests { assert_eq!(app.mode, AppMode::Help); } + /// `AbortChain` must cause subsequent effects in the same + /// `apply_effects` batch to be skipped, and the flag must reset so the + /// next dispatch starts clean. + #[test] + fn abort_chain_short_circuits_apply_effects() { + let mut app = test_app(); + app.status_msg = String::new(); + let effects: Vec> = vec![ + Box::new(SetStatus("before".into())), + Box::new(AbortChain), + Box::new(SetStatus("after".into())), + ]; + app.apply_effects(effects); + assert_eq!( + app.status_msg, "before", + "effects after AbortChain must not apply" + ); + assert!( + !app.abort_effects, + "abort flag should reset at end of apply_effects" + ); + // A subsequent batch must not be affected by the prior abort. + app.apply_effects(vec![Box::new(SetStatus("next-batch".into()))]); + assert_eq!(app.status_msg, "next-batch"); + } + + /// `CleanEmptyRecords` removes cells whose `CellKey` has no + /// coordinates, and leaves all other cells untouched. + #[test] + fn clean_empty_records_removes_only_empty_key_cells() { + let mut app = test_app(); + // An empty-key cell (the bug: produced by AddRecordRow when no page + // filters are set). + app.workbook + .model + .set_cell(CellKey::new(vec![]), CellValue::Number(0.0)); + // Plus a well-formed cell that must survive. + let valid = CellKey::new(vec![ + ("Type".to_string(), "Food".to_string()), + ("Month".to_string(), "Jan".to_string()), + ]); + app.workbook + .model + .set_cell(valid.clone(), CellValue::Number(42.0)); + assert_eq!(app.workbook.model.data.iter_cells().count(), 2); + + CleanEmptyRecords.apply(&mut app); + + assert!( + !app.workbook + .model + .data + .iter_cells() + .any(|(k, _)| k.0.is_empty()), + "empty-key cell should be gone" + ); + assert_eq!( + app.workbook.model.get_cell(&valid), + Some(&CellValue::Number(42.0)), + "valid cell must survive" + ); + } + /// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever /// `app.mode` happens to be when applied. Previous implementation /// branched on `app.mode.is_records()` — the parameterized version @@ -1492,7 +1598,9 @@ mod tests { ("Month".into(), "Jan".into()), ]); // Set original cell - app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook + .model + .set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1508,7 +1616,10 @@ mod tests { ApplyAndClearDrill.apply(&mut app); assert!(app.drill_state.is_none()); assert!(app.dirty); - assert_eq!(app.workbook.model.get_cell(&key), Some(&CellValue::Number(99.0))); + assert_eq!( + app.workbook.model.get_cell(&key), + Some(&CellValue::Number(99.0)) + ); } #[test] @@ -1518,7 +1629,9 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook + .model + .set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1540,7 +1653,10 @@ mod tests { ("Type".into(), "Drink".into()), ("Month".into(), "Jan".into()), ]); - assert_eq!(app.workbook.model.get_cell(&new_key), Some(&CellValue::Number(42.0))); + assert_eq!( + app.workbook.model.get_cell(&new_key), + Some(&CellValue::Number(42.0)) + ); // "Drink" should have been added as an item let items: Vec<&str> = app .workbook @@ -1560,7 +1676,9 @@ mod tests { ("Type".into(), "Food".into()), ("Month".into(), "Jan".into()), ]); - app.workbook.model.set_cell(key.clone(), CellValue::Number(42.0)); + app.workbook + .model + .set_cell(key.clone(), CellValue::Number(42.0)); let records = vec![(key.clone(), CellValue::Number(42.0))]; StartDrill(records).apply(&mut app); @@ -1640,7 +1758,10 @@ mod tests { item: "Food".to_string(), } .apply(&mut app); - assert_eq!(app.workbook.active_view().page_selection("Type"), Some("Food")); + assert_eq!( + app.workbook.active_view().page_selection("Type"), + Some("Food") + ); } // ── Hide/show items ───────────────────────────────────────────────── From a900f147b5204d7e52f922002b5af6e529620d26 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 23:42:44 -0700 Subject: [PATCH 3/4] feat(cmd): use new effects to improve command behavior Update various commands to utilize the new AbortChain and CleanEmptyRecords effects. - CommitAndAdvance now pushes a mode change effect when aborting. - ToggleRecordsMode now cleans up empty records upon exiting. - EnterAdvance now emits AbortChain when at the bottom-right corner. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf) --- src/command/cmd/commit.rs | 17 +++++++++++++++++ src/command/cmd/grid.rs | 19 +++++++++++++++---- src/command/cmd/navigation.rs | 28 +++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs index 69ad10a..82a88db 100644 --- a/src/command/cmd/commit.rs +++ b/src/command/cmd/commit.rs @@ -261,6 +261,16 @@ pub enum AdvanceDir { Right, } +/// Return the normal-mode counterpart of an editing mode. Used by +/// `CommitAndAdvance` to compute the mode to land in if the advance +/// aborts (commit + exit editing at boundary). +fn exit_mode_for(edit_mode: &AppMode) -> AppMode { + match edit_mode { + AppMode::RecordsEditing { .. } => AppMode::RecordsNormal, + _ => AppMode::Normal, + } +} + /// Commit a cell edit, advance the cursor, and re-enter edit mode. /// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right). /// @@ -286,6 +296,13 @@ impl Cmd for CommitAndAdvance { fn execute(&self, ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); commit_cell_value(ctx, &self.key, &self.value, &mut effects); + // Pre-emptively drop to the normal counterpart of edit_mode. If the + // advance succeeds, the trailing `EnterEditAtCursor` below will lift + // us back into editing on the new cell. If the advance aborts + // (e.g. already at bottom-right on Enter), `EnterEditAtCursor` is + // skipped and we land in normal mode — which is the desired + // "Enter at bottom-right commits and exits" behavior. + effects.push(effect::change_mode(exit_mode_for(&self.edit_mode))); match self.advance { AdvanceDir::Down => { let adv = EnterAdvance { diff --git a/src/command/cmd/grid.rs b/src/command/cmd/grid.rs index d0e2b8e..4a67530 100644 --- a/src/command/cmd/grid.rs +++ b/src/command/cmd/grid.rs @@ -132,7 +132,10 @@ mod tests { let mut m = Workbook::new("Test"); m.add_category("Region").unwrap(); m.model.category_mut("Region").unwrap().add_item("East"); - m.model.category_mut("_Measure").unwrap().add_item("Revenue"); + m.model + .category_mut("_Measure") + .unwrap() + .add_item("Revenue"); m.model.category_mut("_Measure").unwrap().add_item("Cost"); m.model.set_cell( CellKey::new(vec![ @@ -148,7 +151,8 @@ mod tests { ]), CellValue::Number(600.0), ); - m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); + m.model + .add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); let layout = make_layout(&m); let reg = make_registry(); @@ -408,8 +412,15 @@ impl Cmd for ToggleRecordsMode { let is_records = ctx.layout.is_records_mode(); if is_records { - // Navigate back to the previous view (restores original axes) - return vec![Box::new(effect::ViewBack), effect::set_status("Pivot mode")]; + // Leaving records mode: clean up any records with empty CellKeys + // (produced by AddRecordRow when no page filters are set) before + // restoring the previous view. This is the inverse of `SortData` + // that runs on entry. + return vec![ + Box::new(effect::CleanEmptyRecords), + Box::new(effect::ViewBack), + effect::set_status("Pivot mode"), + ]; } let mut effects: Vec> = Vec::new(); diff --git a/src/command/cmd/navigation.rs b/src/command/cmd/navigation.rs index d0187f7..a522c9f 100644 --- a/src/command/cmd/navigation.rs +++ b/src/command/cmd/navigation.rs @@ -154,7 +154,10 @@ impl Cmd for EnterAdvance { } else if c < col_max { (0, c + 1) } else { - (r, c) // already at bottom-right; stay + // Already at bottom-right — the advance premise no longer holds. + // Abort the rest of the batch so the caller's trailing effects + // (e.g. `CommitAndAdvance`'s `EnterEditAtCursor`) are skipped. + return vec![Box::new(effect::AbortChain)]; }; viewport_effects( nr, @@ -331,6 +334,29 @@ mod tests { ); } + /// At bottom-right `EnterAdvance` has no place to go, so it emits a + /// single `AbortChain` effect. Trailing effects in a `CommitAndAdvance` + /// batch (e.g. `EnterEditAtCursor`) are then skipped, which is how + /// "Enter at bottom-right commits and exits editing" is realised. + #[test] + fn enter_advance_at_bottom_right_emits_abort_chain() { + let m = two_cat_model(); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let mut cursor = CursorState::from_ctx(&ctx); + cursor.row = cursor.row_count.saturating_sub(1); + cursor.col = cursor.col_count.saturating_sub(1); + let cmd = EnterAdvance { cursor }; + let effects = cmd.execute(&ctx); + assert_eq!(effects.len(), 1, "should emit exactly AbortChain"); + let dbg = format!("{:?}", effects[0]); + assert!( + dbg.contains("AbortChain"), + "Expected AbortChain, got: {dbg}" + ); + } + #[test] fn law_move_to_start_idempotent() { let m = two_cat_model(); From 4e37e12f9a1667416ebf4b2df4b9b363379b9c7a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 15 Apr 2026 23:42:44 -0700 Subject: [PATCH 4/4] style: reformat code and cleanup whitespace Reformat code for improved readability and remove unnecessary whitespace. Co-Authored-By: fiddlerwoaroof/git-smart-commit (gemma-4-26B-A4B-it-UD-Q5_K_XL.gguf) --- crates/improvise-core/src/workbook.rs | 1 - src/draw.rs | 7 +++++-- src/import/wizard.rs | 8 ++++++-- src/persistence/mod.rs | 14 +++----------- src/ui/grid.rs | 8 ++++++-- src/ui/tile_bar.rs | 7 +------ 6 files changed, 21 insertions(+), 24 deletions(-) diff --git a/crates/improvise-core/src/workbook.rs b/crates/improvise-core/src/workbook.rs index 4376f15..06e22ff 100644 --- a/crates/improvise-core/src/workbook.rs +++ b/crates/improvise-core/src/workbook.rs @@ -138,7 +138,6 @@ impl Workbook { view.col_offset = 0; } } - } #[cfg(test)] diff --git a/src/draw.rs b/src/draw.rs index a12dc03..326ce25 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -246,8 +246,11 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { } if app.category_panel_open { let a = Rect::new(side.x, y, side.width, ph); - let content = - CategoryContent::new(&app.workbook.model, app.workbook.active_view(), &app.expanded_cats); + let content = CategoryContent::new( + &app.workbook.model, + app.workbook.active_view(), + &app.expanded_cats, + ); f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a); y += ph; } diff --git a/src/import/wizard.rs b/src/import/wizard.rs index 9fdee5b..f6fc8f1 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -222,7 +222,8 @@ impl ImportPipeline { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { let mut cell_coords = coords.clone(); cell_coords.push(("_Measure".to_string(), measure.field.clone())); - wb.model.set_cell(CellKey::new(cell_coords), CellValue::Number(val)); + wb.model + .set_cell(CellKey::new(cell_coords), CellValue::Number(val)); } } } @@ -1112,6 +1113,9 @@ mod tests { ("Date_Month".to_string(), "2026-03".to_string()), ("_Measure".to_string(), "Amount".to_string()), ]); - assert_eq!(wb.model.get_cell(&key).and_then(|v| v.as_f64()), Some(100.0)); + assert_eq!( + wb.model.get_cell(&key).and_then(|v| v.as_f64()), + Some(100.0) + ); } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 9d618d0..17551d0 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -578,11 +578,7 @@ fn coord_str(key: &CellKey) -> String { .join(", ") } -pub fn export_csv( - workbook: &Workbook, - view_name: &str, - path: &Path, -) -> Result<()> { +pub fn export_csv(workbook: &Workbook, view_name: &str, path: &Path) -> Result<()> { let view = workbook .views .get(view_name) @@ -1429,10 +1425,7 @@ Type=Food = 42 fn category_name_with_comma_space_in_data() { let mut m = Workbook::new("Test"); m.add_category("Income, Gross").unwrap(); - m.model - .category_mut("Income, Gross") - .unwrap() - .add_item("A"); + m.model.category_mut("Income, Gross").unwrap().add_item("A"); m.add_category("Month").unwrap(); m.model.category_mut("Month").unwrap().add_item("Jan"); m.model.set_cell( @@ -1602,8 +1595,7 @@ mod parser_prop_tests { for (i, value) in values.into_iter().enumerate() { let a = &items1[i % items1.len()]; let b = &items2[i % items2.len()]; - m.model - .set_cell(coord(&[("CatA", a), ("CatB", b)]), value); + m.model.set_cell(coord(&[("CatA", a), ("CatB", b)]), value); } m diff --git a/src/ui/grid.rs b/src/ui/grid.rs index a657f98..a61e263 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -915,7 +915,10 @@ mod tests { fn formula_cell_renders_computed_value() { let mut m = Workbook::new("Test"); m.add_category("Region").unwrap(); // → Column - m.model.category_mut("_Measure").unwrap().add_item("Revenue"); + m.model + .category_mut("_Measure") + .unwrap() + .add_item("Revenue"); m.model.category_mut("_Measure").unwrap().add_item("Cost"); // Profit is a formula target — dynamically included in _Measure m.model.category_mut("Region").unwrap().add_item("East"); @@ -927,7 +930,8 @@ mod tests { coord(&[("_Measure", "Cost"), ("Region", "East")]), CellValue::Number(600.0), ); - m.model.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); + m.model + .add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); m.active_view_mut() .set_axis("_Index", crate::view::Axis::None); m.active_view_mut() diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 34c7695..4129e3d 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -18,12 +18,7 @@ pub struct TileBar<'a> { } impl<'a> TileBar<'a> { - pub fn new( - model: &'a Model, - view: &'a View, - mode: &'a AppMode, - tile_cat_idx: usize, - ) -> Self { + pub fn new(model: &'a Model, view: &'a View, mode: &'a AppMode, tile_cat_idx: usize) -> Self { Self { model, view,