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