From 1181ffd0abf9ff6eef4d804a6de7af1d7e2ddae9 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 26 Apr 2026 11:10:32 -0700 Subject: [PATCH] chore: update roadmap --- roadmap.org | 4109 +++++---------------------------------------------- 1 file changed, 388 insertions(+), 3721 deletions(-) diff --git a/roadmap.org b/roadmap.org index fd8a937..b65ed31 100644 --- a/roadmap.org +++ b/roadmap.org @@ -5,24 +5,119 @@ #+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug #+PROPERTY: COOKIE_DATA todo recursive -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. +Generated from ~bd list --json~. 50 open issues organised as 27 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: +* TODO Malformed numbers in .improv file silently become 0.0 (standalone) :standalone:P1:bug: :PROPERTIES: - :ID: improvise-s0h + :ID: improvise-4yc :TYPE: bug - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: cursor-f4c497bb + :PRIORITY: P1 + :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-09 22:18:58 - :UPDATED: 2026-04-14 08:07:41 + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:17 + :UPDATED: 2026-04-16 18:18:17 :KIND: standalone :END: ** Details *** Description - Pressing 'o' in records mode to add a new record row doesn't work correctly with fresh data models. The keybinding exists (add-record-row + enter-edit-at-cursor sequence) but the behavior is broken. Needs investigation to reproduce and fix. + crates/improvise-io/src/persistence/mod.rs:434 uses value_pair.as_str().parse().unwrap_or(0.0). Malformed number literals (e.g. '3.14.15', '1e999', '--5') become CellValue::Number(0.0) with no error. This is the only production unwrap/unwrap_or in the parse path and silently corrupts data. Fix: return a parse error with line context, or at minimum log a warning and use CellValue::Error. + +* TODO Pest parse errors lack file:line context for users (standalone) :standalone:P1:bug: + :PROPERTIES: + :ID: improvise-6kj + :TYPE: bug + :PRIORITY: P1 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:13 + :UPDATED: 2026-04-16 18:18:13 + :KIND: standalone + :END: +** Details +*** Description + persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected "=", found ","' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error. + +* TODO CyclePanelFocus does not actually cycle between panels (standalone) :standalone:P1:bug: + :PROPERTIES: + :ID: improvise-dqn + :TYPE: bug + :PRIORITY: P1 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:07 + :UPDATED: 2026-04-16 18:18:07 + :KIND: standalone + :END: +** Details +*** Description + The CyclePanelFocus command at src/command/cmd/panel.rs:299-313 is documented as 'Tab through open panels' but the if/else if chain always resolves to the first open panel (formula > category > view). Pressing the bound key repeatedly never rotates. Test at panel.rs:84 only asserts a panel mode is entered, not rotation. Fix: either implement real rotation using ctx.mode to determine 'next' open panel, or rename to FocusFirstOpenPanel. Also replace the three bool fields with an enum of open panel states. + +* TODO CSV import silently drops columns when row widths mismatch (standalone) :standalone:P1:bug: + :PROPERTIES: + :ID: improvise-k8i + :TYPE: bug + :PRIORITY: P1 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:15 + :UPDATED: 2026-04-16 18:18:15 + :KIND: standalone + :END: +** Details +*** Description + crates/improvise-io/src/import/csv_parser.rs:14-20 uses ReaderBuilder with .flexible(true). Rows with fewer columns than the header are truncated with no warning. User data is silently lost on import. Fix: either enforce .flexible(false) for strict RFC 4180 parsing, or detect mismatched row widths and surface a warning to the import wizard. + +* TODO Inconsistent float-equality tolerance in formula eval (standalone) :standalone:P2:bug: + :PROPERTIES: + :ID: improvise-0bf + :TYPE: bug + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:35 + :UPDATED: 2026-04-16 18:18:35 + :KIND: standalone + :END: +** Details +*** Description + crates/improvise-core/src/model/types.rs uses different float-equality strategies: division checks 'rv == 0.0' exactly (:674), while = and != comparisons use '1e-10' epsilon (:555, :749). A formula like IF(x = 0, ..., y/x) with x = 1e-11 treats x as zero in the condition but divides without error. Either unify on a single FLOAT_EQ_EPSILON constant or document the design choice explicitly in comments. + +* TODO Migrate name-string comparisons to CategoryKind enum checks (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-2lh + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:01:53 + :UPDATED: 2026-04-16 19:01:53 + :KIND: standalone + :END: +** Details +*** Description + 8 sites compare category names as strings against virtual-category prefixes ('_Measure', '_Index', '_Dim') instead of checking the typed CategoryKind enum. Adding a new CategoryKind variant won't produce a compile error at any of these sites.\n\nSites:\n- crates/improvise-core/src/model/types.rs:215, 226, 429, 636 (target_category == '_Measure', cat_name == '_Measure')\n- crates/improvise-io/src/persistence/mod.rs:213, 226 (formula target comparison)\n- crates/improvise-core/src/view/layout.rs:108 (c == '_Index' && c == '_Dim')\n- src/command/cmd/grid.rs:306 (target_category == '_Measure')\n\nFix: add methods on CategoryKind: is_virtual(), is_measure(), is_persisted(), is_index_or_dim(). Look up the Category by name once, dispatch on its .kind.\n\nCorrectness win: the compiler flags every site when a new variant is added. Also removes the hardcoded-name dependency — if the underscore prefix convention ever changes, one place to edit. + +* TODO Extract panel mode keymap template (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-4pg + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:24 + :UPDATED: 2026-04-16 18:53:24 + :KIND: standalone + :END: +** Details +*** Description + command/keymap.rs:554-713 has three nearly-identical panel keymaps (FormulaPanel, CategoryPanel, ViewPanel) sharing Esc/Tab/j/k/F/C/V/: bindings. Only panel name varies.\n\nAbstraction: fn panel_keymap(panel: Panel) -> Keymap\n\nCorrectness win: adding a 4th panel becomes a one-line call; prevents per-panel binding drift. Also helps keep CyclePanelFocus keybinding consistent once improvise-dqn is fixed.\n\n~80 LOC collapsed. * TODO Virtual views _Records and _Drill should not be persisted to .improv files (standalone) :standalone:P2:task: :PROPERTIES: @@ -67,7 +162,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: *** Details @@ -78,7 +173,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre **** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. **** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO Browser frontend MVP: end-to-end working demo (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -157,7 +252,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -168,7 +263,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -225,7 +320,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -236,7 +331,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -292,7 +387,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -303,207 +398,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -**** Details -***** Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -***** Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -***** Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -***** Details -****** Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -****** Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -****** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -****** Details -******* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******* Details -******** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -538,7 +433,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: **** Details @@ -549,7 +444,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ***** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ***** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -606,7 +501,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ***** Details @@ -617,7 +512,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ****** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. *** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -673,7 +568,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ***** Details @@ -684,7 +579,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ****** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -708,7 +603,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +*** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -718,7 +613,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: **** Details ***** Description @@ -730,28 +626,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ***** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -***** Details -****** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi @@ -785,7 +659,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ***** Details @@ -796,7 +670,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ****** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO Tag existing effects as model / view / projection-emitting (epic) [/] :epic:P2:task: :PROPERTIES: @@ -830,7 +704,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: **** Details @@ -841,7 +715,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ***** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ***** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -898,7 +772,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ***** Details @@ -909,7 +783,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ****** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. *** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -966,7 +840,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -977,7 +851,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -1033,7 +907,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -1044,207 +918,55 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. -** TODO DOM renderer for browser grid (reads ViewState + render cache) (epic) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -*** Details -**** Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -**** Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -**** Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -**** 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 O(n^2) stem deduplication in recompute_formulas (standalone) :standalone:P2:bug: + :PROPERTIES: + :ID: improvise-6os + :TYPE: bug + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:24 + :UPDATED: 2026-04-16 18:18:24 + :KIND: standalone + :END: +** Details +*** Description + crates/improvise-core/src/model/types.rs:346-359 accumulates unique formula stems using 'if !stems.contains(&stripped) { stems.push(stripped); }'. This is O(n^2) over every cell for every formula category. recompute_formulas runs at startup and after every data mutation. Large workbooks will show visible slowdown. Fix: collect into HashSet, convert to Vec once for iteration order. -*** TODO Establish ViewModel layer: derived data between RenderCache and widgets (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -**** Details -***** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -***** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -***** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -***** 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 Merge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode} (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-6sq + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:39 + :UPDATED: 2026-04-16 18:53:39 + :KIND: standalone + :END: +** Details +*** Description + command/cmd/mode.rs:254-307 has two commands (EditOrDrill, EnterEditAtCursorCmd) that each thread an edit_mode parameter. The drill-vs-edit decision is mode-independent; merging eliminates the fork.\n\nAbstraction: EnterEdit { mode: AppMode } — single command that checks aggregation state, pre-fills the buffer if editing, then enters the supplied mode.\n\nMatches the parameterization precedent from commit 30383f2 ('parameterize mode-related commands and effects'). ~50 LOC collapsed. -**** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -***** Details -****** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -****** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -****** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -****** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -****** Details -******* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -**** Details -***** Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -***** Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -***** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -****** Details -******* Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -***** Details -****** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -****** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -****** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -****** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -****** Details -******* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******* 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 Introduce register_cmd! macro to prevent name drift (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-9cn + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:22 + :UPDATED: 2026-04-16 18:53:22 + :KIND: standalone + :END: +** Details +*** Description + src/command/cmd/registry.rs has 20+ register_pure(&SomeCmd(vec![]), SomeCmd::parse) sites where the registered name string must match SomeCmd::name(). Nothing enforces this match — a typo produces a silent dispatch failure.\n\nAbstraction: register_cmd!(r, SomeCmd) macro that derives the name from the Cmd impl at compile time.\n\nCorrectness win: compile-time guarantee that the registered name matches Cmd::name(). Subsumes improvise-61f (the runtime invariant test); keeping 61f as a belt-and-braces check is fine but may be unnecessary once this lands.\n\n~40 LOC collapsed. * TODO Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column) (standalone) :standalone:P2:feature: :PROPERTIES: @@ -1262,7 +984,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre *** Description Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv. -* TODO Add Axis::Filter for drill-fixed coordinates (standalone) :standalone:P2:feature: +* TODO Add Axis::Filter for drill-fixed coordinates (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-dz8 :TYPE: feature @@ -1272,12 +994,76 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 07:30:04 :UPDATED: 2026-04-14 07:30:04 - :KIND: standalone + :KIND: epic :END: ** Details *** 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 Consolidate Axis display into a method on Axis (standalone) :standalone:P3:task: + :PROPERTIES: + :ID: improvise-rml + :TYPE: task + :PRIORITY: P3 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:02:21 + :UPDATED: 2026-04-16 19:02:21 + :KIND: standalone + :END: +*** Details +**** Description + Axis display logic is duplicated:\n- src/ui/tile_bar.rs:30-36 — axis_display() match (4 arms)\n- src/ui/category_panel.rs:14-19 — exact copy of the same match\n- src/ui/view_panel.rs:40-44 — inline match\n- src/command/cmd/tile.rs:112, 131, 143 — axis cycling and conversion matches\n\nAdd methods on Axis (crates/improvise-core/src/view/axis.rs):\n- display_short() -> (&'static str, Color)\n- display_long() -> &'static str\n- next() -> Self (cycle Row -> Col -> Page -> None -> Row)\n- is_data_axis() -> bool\n\nCorrectness win: eliminates the tile_bar/category_panel copy-paste — currently a change to axis colors or short labels must be made in both places.\n\n~25 LOC. + +* TODO Consolidate minibuffer AppMode constructors (epic) [/] :epic:P2:task: + :PROPERTIES: + :ID: improvise-k8h + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:25 + :UPDATED: 2026-04-16 18:53:25 + :KIND: epic + :END: +** Details +*** Description + ui/app.rs:54-185 has 7 AppMode minibuffer variants (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode) that each hand-build MinibufferConfig { buffer_key, prompt, color, .. }. The buffer_key string must match what AppendChar/PopChar/commit commands look up in ctx.buffers — nothing enforces this.\n\nAbstraction: impl AppMode { fn editing() -> Self; fn command_mode() -> Self; ... } with buffer_key drawn from a shared constant or enum.\n\nNote: full enum refactor to AppMode::Minibuffer { kind, config } is not worth the churn — most of the 35 match sites use { .. } and don't care about the payload; the constructor consolidation captures the correctness win (single buffer_key source) without the enum refactor.\n\n~40 LOC collapsed. + +** TODO Extract AppMode dispatch methods (mode_label, mode_style, mode_key) (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-2hi + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:02:09 + :UPDATED: 2026-04-16 19:02:09 + :KIND: standalone + :END: +*** Details +**** Description + AppMode is matched in ~52 sites across 8 files to derive UI labels, styles, and mode keys. Each match arm is a small piece of per-variant logic that should live on the variant itself.\n\nHot spots:\n- src/draw.rs:119-149 — mode_name() and mode_style() match blocks (16+ arms)\n- src/command/keymap.rs:69-89 — ModeKey::from_app_mode match (16 arms)\n- src/ui/{formula,category,view}_panel.rs — matches!(mode, AppMode::X) checks\n- src/ui/tile_bar.rs, src/ui/effect.rs:898-900, src/command/cmd/registry.rs:376\n- src/ui/app.rs: 13 scattered matches!() checks\n\nFix: add methods on AppMode — mode_label() -> &'static str, mode_style() -> Style, mode_key() -> Option, panel_mode() -> Option, is_text_entry_mode() -> bool (partially present via minibuffer()). Also extend the existing minibuffer() with buffer_key() convenience.\n\nCorrectness win: adding a new AppMode variant requires updating the enum's methods (one file), not chasing 8 files with match blocks. Exhaustive match inside the method body still forces the compiler to flag every new variant.\n\n~80 LOC collapsed and 7 files depend on fewer concerns of AppMode. + +* TODO Duplicated aggregation dispatch block (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-kdp + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:29 + :UPDATED: 2026-04-16 18:18:29 + :KIND: standalone + :END: +** Details +*** Description + crates/improvise-core/src/model/types.rs:305-312 and :598-604 contain identical Sum/Avg/Min/Max/Count match blocks against AggFunc. If business logic changes (NaN handling, default agg), updates must be made in both sites. Fix: extract fn apply_agg(values: &[f64], func: &AggFunc) -> f64 as a pure helper. + * TODO Epic: Native TUI as in-process client of the unified server architecture (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-ltq @@ -1606,182 +1392,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-764 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:47 - :UPDATED: 2026-04-14 07:53:21 - :KIND: epic - :END: -**** Details -***** Description - Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle. -***** Design - WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports. -***** Acceptance Criteria - (1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P3:task: - :PROPERTIES: - :ID: improvise-jb3 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:23 - :UPDATED: 2026-04-14 07:53:20 - :KIND: epic - :END: -**** Details -***** Description - Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap). -***** Design - HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits. -***** Acceptance Criteria - (1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-edp @@ -2046,181 +1656,21 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-764 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:47 - :UPDATED: 2026-04-14 07:53:21 - :KIND: epic - :END: -*** Details -**** Description - Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle. -**** Design - WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports. -**** Acceptance Criteria - (1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step. -**** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -**** Details -***** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -***** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -***** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -***** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -***** Details -****** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -****** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -****** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -****** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -****** Details -******* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******* 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) [/] :epic:P3:task: - :PROPERTIES: - :ID: improvise-jb3 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:23 - :UPDATED: 2026-04-14 07:53:20 - :KIND: epic - :END: -*** Details -**** Description - Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap). -**** Design - HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits. -**** Acceptance Criteria - (1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged. -**** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -**** Details -***** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -***** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -***** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -***** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -***** Details -****** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -****** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -****** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -****** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -****** Details -******* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******* 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 Duplicated find_item_category helper in eval paths (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-nrs + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:18:26 + :UPDATED: 2026-04-16 18:18:26 + :KIND: standalone + :END: +** Details +*** Description + crates/improvise-core/src/model/types.rs has identical nested find_item_category helpers at lines 420 and 627 (inside eval_formula_with_cache and eval_formula_depth). Both search all categories for an item, then fall back to formula targets. Fix: extract as Model::find_item_category(&self, item: &str) -> Option<&str> so bug fixes apply in one place. * TODO Epic: Standalone static-web deployment via wasm + VFS storage (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -2343,7 +1793,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ***** Details @@ -2354,7 +1804,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ****** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. *** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -2411,7 +1861,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -2422,7 +1872,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -2478,7 +1928,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -2489,7 +1939,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. *** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -2558,7 +2008,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +**** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -2568,7 +2018,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ***** Details ****** Description @@ -2580,28 +2031,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -****** Details -******* Description - 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 - 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) 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 - 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 Standalone web MVP: end-to-end offline demo (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-bck @@ -2702,7 +2131,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -2713,7 +2142,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -2770,7 +2199,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -2781,7 +2210,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -2837,7 +2266,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -2848,7 +2277,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -2917,7 +2346,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -2927,7 +2356,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ****** Details ******* Description @@ -2939,28 +2369,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-djm @@ -3061,7 +2469,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -3072,7 +2480,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ***** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -3129,7 +2537,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******** Details @@ -3140,7 +2548,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ********* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ********* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -3196,7 +2604,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******** Details @@ -3207,7 +2615,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ********* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ********* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ***** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -3276,7 +2684,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +****** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -3286,7 +2694,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ******* Details ******** Description @@ -3298,28 +2707,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******** Details -********* Description - 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 - 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) 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 - 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 improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw @@ -3342,7 +2729,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -3352,7 +2739,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ****** Details ******* Description @@ -3364,28 +2752,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi @@ -3419,7 +2785,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -3430,900 +2796,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -***** Details -****** Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -****** Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -****** Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -****** Details -******* Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -******* Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -******* Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -******* 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -******* Details -******** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -******* TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******** Details -********* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -********* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -********* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -********* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -****** Details -******* Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -******* Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -******* Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******** Details -********* Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******* Details -******** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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) [/] :epic:P3:task: - :PROPERTIES: - :ID: improvise-d31 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:36 - :UPDATED: 2026-04-14 07:39:31 - :KIND: epic - :END: -**** Details -***** Description - HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install. -***** Design - docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity. -***** Acceptance Criteria - (1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid. -***** Notes - 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-djm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:23 - :UPDATED: 2026-04-14 07:38:45 - :KIND: epic - :END: -***** Details -****** Description - Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport. -****** Design - Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios. -****** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running. -****** Notes - Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. - -***** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-9x6 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:39:29 - :UPDATED: 2026-04-14 07:39:29 - :KIND: epic - :END: -****** Details -******* Description - New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ. -******* Design - crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired. -******* Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic. -******* Notes - Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence. - -****** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -******* Details -******** Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -******** Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -******** Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -******** 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******* Details -******** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:12 - :UPDATED: 2026-04-14 07:23:12 - :KIND: epic - :END: -******* Details -******** Description - After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work. -******** Design - Server holds HashMap where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range. -******** Acceptance Criteria - (1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec<(SessionId, Command)> to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******** Details -********* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -********* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -********* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -********* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -********* Details -********** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********** 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 Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******** Details -********* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -********* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -********* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -********* 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-mae - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:22:02 - :UPDATED: 2026-04-14 07:22:02 - :KIND: epic - :END: -******** Details -********* Description - Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer. -********* Design - Add a method to Effect like fn kind(&self) -> EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }. -********* Acceptance Criteria - (1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind. -********* Notes - Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting. - -******** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -********* Details -********** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-i34 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:08 - :UPDATED: 2026-04-14 07:34:08 - :KIND: epic - :END: -******* Details -******** Description - Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment. -******** Design - Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait. -******** Acceptance Criteria - (1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI). -******** 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 (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -******** Details -********* Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -********* Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -********* Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -********* 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 Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-ywd - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:49 - :UPDATED: 2026-04-14 07:33:49 - :KIND: epic - :END: -******* Details -******** Description - Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate. -******** Design - Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs. -******** Acceptance Criteria - (1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -********* Details -********** Description - 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 - 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) 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 - 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 improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -****** Details -******* Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -******* Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -******* Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******** Details -********* Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******* Details -******** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -****** Details -******* Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -******* Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -******* Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -******* Details -******** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -******** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -******** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -******** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -******** Details -********* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -********* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -********* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -********* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -******** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -********* Details -********** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -********** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -********** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -********** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -******* Details -******** Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -******** Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -******** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -********* Details -********** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******** Details -********* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -********* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -********* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -********* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -********* Details -********** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -4425,7 +2898,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -4436,7 +2909,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO Server-side projection emission layer + per-client viewport tracking (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -4493,7 +2966,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -4504,7 +2977,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ***** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: :PROPERTIES: @@ -4560,7 +3033,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ******* Details @@ -4571,7 +3044,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******** Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. **** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -4640,7 +3113,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +***** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -4650,7 +3123,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ****** Details ******* Description @@ -4662,28 +3136,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-gsw @@ -4706,7 +3158,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +**** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -4716,7 +3168,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: ***** Details ****** Description @@ -4728,28 +3181,6 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ****** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -****** Details -******* Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: :PROPERTIES: :ID: improvise-cqi @@ -4783,7 +3214,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :OWNER: el-github@elangley.org :CREATED_BY: spot :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 + :UPDATED: 2026-04-16 23:02:18 :KIND: standalone :END: ****** Details @@ -4794,207 +3225,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ******* Acceptance Criteria (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. ******* 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -**** Details -***** Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -***** Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -***** Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -***** Details -****** Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -****** Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -****** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -****** Details -******* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******* Details -******** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******** 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. + Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply. ** TODO OPFS-backed Storage implementation for wasm target (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -5063,7 +3294,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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) [/] :epic:P2:feature: +*** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -5073,7 +3304,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: **** Details ***** Description @@ -5085,720 +3317,53 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre ***** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -***** Details -****** Description - 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 - 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) 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 - 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 Route mutations through Workbook facade instead of app.workbook.model chains (standalone) :standalone:P2:task: + :PROPERTIES: + :ID: improvise-tvt + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:02:18 + :UPDATED: 2026-04-16 19:02:18 + :KIND: standalone + :END: +** Details +*** Description + 40+ sites follow the pattern 'if let Some(cat) = app.workbook.model.category_mut(&name) { cat.add_item(&item); }' (src/ui/effect.rs:38, 54, 99 and many test setups in src/command/cmd/mod.rs, src/ui/grid.rs).\n\nSimilarly .views.get().unwrap().axis_of() appears in 11 sites (workbook.rs:167-177, model/types.rs:1868-1890, persistence:943-946,1165) even though Workbook::active_view() / active_view_mut() already exist and are used correctly in some places.\n\nFix: add facade methods on Workbook: add_item(cat, item), remove_item(cat, item), view_axis(view_name, cat), sort_cells(), cell_count(). Migrate the existing chains to use them.\n\nCorrectness win: (a) Workbook becomes a real facade with the option to enforce invariants (e.g., notify views when items change); (b) Tests and effects stop reaching through three layers; (c) future refactors to Model/DataStore internals don't cascade into 40+ call sites.\n\n~60 LOC in production + simplification of many tests. -** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [/] :epic:P3:task: +* TODO Extract text-entry keymap template for minibuffer modes (epic) [/] :epic:P2:task: + :PROPERTIES: + :ID: improvise-w3q + :TYPE: task + :PRIORITY: P2 + :STATUS: open + :OWNER: el-github@elangley.org + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:14 + :UPDATED: 2026-04-16 18:53:14 + :KIND: epic + :END: +** Details +*** Description + command/keymap.rs:770-966 hand-binds Enter/Esc/Tab/Backspace/AnyChar for 8 modes (Editing, RecordsEditing, FormulaEdit, CategoryAdd, ItemAdd, ExportPrompt, CommandMode, SearchMode). Only buffer name, commit command, and exit mode vary between them.\n\nAbstraction: fn text_entry_keymap(buffer: &str, commit: &str, exit: AppMode, parent: Option>) -> Keymap\n\nCorrectness win: impossible to omit clear-buffer in one mode's Esc sequence (the exact class of bug fixed in a recent refactor). New text modes inherit semantics for free. Pairs naturally with generic CommitBuffer(kind) — once the commit primitive is unified, the template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly.\n\n~150 LOC collapsed. + +** TODO Unify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind) (standalone) :standalone:P2:task: :PROPERTIES: - :ID: improvise-d31 + :ID: improvise-6t3 :TYPE: task - :PRIORITY: P3 + :PRIORITY: P2 :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:36 - :UPDATED: 2026-04-14 07:39:31 - :KIND: epic + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:53:33 + :UPDATED: 2026-04-16 18:53:33 + :KIND: standalone :END: *** Details **** Description - HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install. -**** Design - docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity. -**** Acceptance Criteria - (1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid. -**** Notes - 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-djm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:23 - :UPDATED: 2026-04-14 07:38:45 - :KIND: epic - :END: -**** Details -***** Description - Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport. -***** Design - Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios. -***** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running. -***** Notes - Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport. - -**** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-9x6 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:39:29 - :UPDATED: 2026-04-14 07:39:29 - :KIND: epic - :END: -***** Details -****** Description - New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ. -****** Design - crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired. -****** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic. -****** Notes - Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence. - -***** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -****** Details -******* Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -******* Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -******* Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -******* 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -****** Details -******* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******* Details -******** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:12 - :UPDATED: 2026-04-14 07:23:12 - :KIND: epic - :END: -****** Details -******* Description - After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work. -******* Design - Server holds HashMap where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range. -******* Acceptance Criteria - (1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec<(SessionId, Command)> to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******* Details -******** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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 Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-mae - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:22:02 - :UPDATED: 2026-04-14 07:22:02 - :KIND: epic - :END: -******* Details -******** Description - Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer. -******** Design - Add a method to Effect like fn kind(&self) -> EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }. -******** Acceptance Criteria - (1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind. -******** Notes - Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting. - -******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-i34 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:34:08 - :UPDATED: 2026-04-14 07:34:08 - :KIND: epic - :END: -****** Details -******* Description - Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment. -******* Design - Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait. -******* Acceptance Criteria - (1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI). -******* 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 (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -******* Details -******** Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -******** Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -******** Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -******** 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 Wasm compatibility audit and fixes across core/formula/command/io (epic) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-ywd - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:49 - :UPDATED: 2026-04-14 07:33:49 - :KIND: epic - :END: -****** Details -******* Description - Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate. -******* Design - Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs. -******* Acceptance Criteria - (1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******** Details -********* Description - 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 - 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) 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 - 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 improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -***** Details -****** Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -****** Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -****** Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******* Details -******** Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -****** Details -******* Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******* Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******* Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******* 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******* Details -******** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******** 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-cr3 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:41 - :UPDATED: 2026-04-14 07:53:24 - :KIND: epic - :END: -***** Details -****** Description - JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event. -****** Design - Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single
for the grid body, for column labels, with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected
. Mode indicator is a
above the table. Minibuffer is a
shown conditionally when mode is Editing/FormulaEdit/etc. -****** Acceptance Criteria - (1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -****** Details -******* Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -******* Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -******* Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -******* 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -******* Details -******** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -******* TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******** Details -********* Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -********* Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -********* Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -********* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-gsw - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:24:37 - :UPDATED: 2026-04-14 07:39:34 - :KIND: epic - :END: -****** Details -******* Description - Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream. -******* Design - crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -> Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -> Option (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -> JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5). -******* Acceptance Criteria - (1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket. -******* 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-3mm - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 06:53:59 - :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 - 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/ + 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 - 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -******** Details -********* Description - 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 - 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) 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 - 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -******* Details -******** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -******** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -******** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -******** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -******** Details -********* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -********* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -********* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -********* 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. + command/cmd/commit.rs:349-439 has three commands with identical shape: read buffer named X, if empty abort, else emit AddY effect, mark dirty, optionally exit mode. Only the buffer name and the target effect type differ.\n\nAbstraction: CommitBuffer { kind: CommitKind } where CommitKind is Formula | Category | Item.\n\nCorrectness win: minibuffer commit semantics live in one place. New minibuffer modes (rename-view, rename-item) get correct semantics for free. Pairs with improvise-{text-entry keymap template} — once the commit is generic, the keymap template composes Sequence [commit-buffer X, clear-buffer X, enter-mode Normal] uniformly across all text-entry modes.\n\n~40 LOC collapsed. * TODO Epic: Split improvise into enforced-boundary workspace crates (epic) [/] :epic:P2:feature: :PROPERTIES: @@ -5821,7 +3386,7 @@ Generated from ~bd list --json~. 44 open issues organised as 15 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. -** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [/] :epic:P2:feature: +** TODO Step 5: Extract improvise-command crate below improvise-tui (standalone) :standalone:P2:feature: :PROPERTIES: :ID: improvise-3mm :TYPE: feature @@ -5831,7 +3396,8 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre :CREATED_BY: spot :CREATED: 2026-04-14 06:53:59 :UPDATED: 2026-04-16 06:30:35 - :KIND: epic + :KIND: standalone + :DONE_DEPS: improvise-dwe :END: *** Details **** Description @@ -5843,981 +3409,82 @@ Generated from ~bd list --json~. 44 open issues organised as 15 inverted dep-tre **** Notes 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 Split App into AppState + App wrapper (in-place, no crate change) (standalone) :standalone:P2:task: - :PROPERTIES: - :ID: improvise-dwe - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-16 06:28:47 - :UPDATED: 2026-04-16 06:28:47 - :KIND: standalone - :END: -**** Details -***** Description - 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 - 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) 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 - 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 Make Sequence bindings transactional at command level (standalone) :standalone:P3:feature: +* TODO Document RecordsEditing variant in repo-map.md (standalone) :standalone:P3:task: :PROPERTIES: - :ID: improvise-0hb - :TYPE: feature + :ID: improvise-1ud + :TYPE: task :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 + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:54:16 + :UPDATED: 2026-04-16 18:54:16 :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. + context/repo-map.md currently lists 15 AppMode variants but does not include RecordsEditing, which is referenced at src/draw.rs:142 and src/ui/app.rs:442. Add the variant to the AppMode list in the 'Key Types' section so coding agents see it.\n\nTrivial doc fix. - 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: +* TODO Extract axis label helper in view/layout.rs (standalone) :standalone:P3:task: :PROPERTIES: - :ID: improvise-0s6 - :TYPE: feature + :ID: improvise-1wu + :TYPE: task :PRIORITY: P3 :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:05:51 - :UPDATED: 2026-04-09 06:38:00 - :KIND: epic - :DONE_DEPS: improvise-11a + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:54:13 + :UPDATED: 2026-04-16 18:54:13 + :KIND: standalone :END: ** Details *** 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). + crates/improvise-core/src/view/layout.rs:323-336 (row_label) and :338-351 (col_label) are character-identical except for which items list they read. Small syntactic duplication but trivial to consolidate.\n\nFix: fn axis_label(items: &[AxisEntry], idx: usize) -> String, call from both. ~14 LOC. -** TODO 3.3 Tag v0.1.0 release (epic) [/] :epic:P3:task: - :PROPERTIES: - :ID: improvise-3tj - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:08:02 - :UPDATED: 2026-04-09 06:38:01 - :KIND: epic - :DONE_DEPS: improvise-11a - :END: -*** Details -**** Description - Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release. - -*** TODO 3.2 Publish to crates.io (standalone) :standalone:P3:task: - :PROPERTIES: - :ID: improvise-l36 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:07:58 - :UPDATED: 2026-04-09 06:38:01 - :KIND: standalone - :DONE_DEPS: improvise-11a, improvise-2fr - :END: -**** Details -***** 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 3.2 Publish to crates.io (standalone) :standalone:P3:task: - :PROPERTIES: - :ID: improvise-l36 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:07:58 - :UPDATED: 2026-04-09 06:38:01 - :KIND: standalone - :DONE_DEPS: improvise-11a, improvise-2fr - :END: -*** Details -**** 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) [/] :epic:P3:feature: +* TODO Introduce simple_effect! macro for single-field setter effects (standalone) :standalone:P3:task: :PROPERTIES: - :ID: improvise-9ix - :TYPE: feature + :ID: improvise-8fy + :TYPE: task :PRIORITY: P3 :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:39:41 - :UPDATED: 2026-04-14 07:39:41 - :KIND: epic + :CREATED_BY: Ed L + :CREATED: 2026-04-16 18:54:14 + :UPDATED: 2026-04-16 18:54:14 + :KIND: standalone :END: ** Details *** Description - Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it. -*** Design - Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity. -*** Acceptance Criteria - (1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option. -*** 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). + src/ui/effect.rs has ~15 simple setter effects (SetSelected, SetRowOffset, SetColOffset, SetYanked, SetSearchQuery, SetSearchMode, SetStatus, etc.) that each implement the same 4-line apply: pub struct SetX(pub T); impl Effect { fn apply(&self, app: &mut App) { app.field = self.0.clone(); } }.\n\nAbstraction: simple_effect!(SetRowOffset, usize, row_offset) macro generates struct + Effect impl.\n\nCorrectness win: provides a single hook for cross-cutting concerns. If layout-affecting setters need to mark dirty or rebuild layout, one macro body covers all of them. Currently each setter independently decides whether to mark_dirty(), and silent omissions are plausible. ~50 LOC. -** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -*** Details -**** Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -**** Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -**** Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -**** 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 (Stretch) Command log + native undo/replay (epic) [/] :epic:P3:feature: +* TODO Add Model/Category getter methods to absorb .unwrap().items/groups chains (standalone) :standalone:P3:task: :PROPERTIES: - :ID: improvise-m91 - :TYPE: feature + :ID: improvise-ete + :TYPE: task :PRIORITY: P3 :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:02 - :UPDATED: 2026-04-14 07:53:02 - :KIND: epic + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:02:34 + :UPDATED: 2026-04-16 19:02:34 + :KIND: standalone :END: ** Details *** Description - Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay. -*** Design - Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay. -*** Acceptance Criteria - (1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state. -*** Notes - Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart. + Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\n\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\n- persistence/mod.rs:831: &m2.model.category('Month').unwrap().groups — 1 site\n- src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\n\nAdd:\n- Model::item_names(cat: &str) -> Option>\n- Model::group_names(cat: &str) -> Option>\n- Category::item_count() -> usize\n\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\n\n~15 LOC plus cleaner call sites. -** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -*** Details -**** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -**** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -**** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -**** 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) [/] :epic:P3:feature: +* TODO Migrate existing callers of workbook.views.get() to Workbook::active_view (standalone) :standalone:P3:task: :PROPERTIES: - :ID: improvise-uq7 - :TYPE: feature + :ID: improvise-ont + :TYPE: task :PRIORITY: P3 :STATUS: open :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:52:57 - :UPDATED: 2026-04-14 07:52:57 - :KIND: epic + :CREATED_BY: Ed L + :CREATED: 2026-04-16 19:02:37 + :UPDATED: 2026-04-16 19:02:37 + :KIND: standalone :END: ** Details *** Description - Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full. -*** Design - Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server. -*** Acceptance Criteria - (1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running. -*** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-e0u - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:54 - :UPDATED: 2026-04-14 07:50:54 - :KIND: epic - :END: -*** Details -**** Description - The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter. -**** Design - Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets. -**** Acceptance Criteria - (1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read &App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types). -**** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -**** Details -***** Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -***** Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -***** Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -***** Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -**** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -***** Details -****** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -****** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -****** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -****** 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 Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -**** Details -***** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -***** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -***** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-n10 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:49:03 - :UPDATED: 2026-04-14 07:53:17 - :KIND: epic - :END: -**** Details -***** Description - Rewrite ui/grid.rs so that GridWidget::render takes &GridCache + &ViewState instead of reaching into &App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them. -***** Design - pub struct GridCache { pub col_widths: Vec, pub row_labels: Vec, pub col_labels: Vec, pub cells: Vec>, pub cursor: (usize, usize), pub highlights: Vec, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: &mut Frame, area: Rect, cache: &GridCache, view: &ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol. -***** Acceptance Criteria - (1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside). -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-pca - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:49:06 - :UPDATED: 2026-04-14 07:53:18 - :KIND: epic - :END: -**** Details -***** Description - Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of &App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure. -***** Design - Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes &SubCache and &ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: &CategoryTreeCache, view: &ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those). -***** Acceptance Criteria - (1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P3:feature: - :PROPERTIES: - :ID: improvise-764 - :TYPE: feature - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:47 - :UPDATED: 2026-04-14 07:53:21 - :KIND: epic - :END: -**** Details -***** Description - Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle. -***** Design - WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports. -***** Acceptance Criteria - (1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P3:task: - :PROPERTIES: - :ID: improvise-jb3 - :TYPE: task - :PRIORITY: P3 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:50:23 - :UPDATED: 2026-04-14 07:53:20 - :KIND: epic - :END: -**** Details -***** Description - Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap). -***** Design - HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits. -***** Acceptance Criteria - (1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-edp - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:53:04 - :UPDATED: 2026-04-14 07:53:04 - :KIND: epic - :END: -***** Details -****** Description - Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly. -****** Design - For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: &SurfaceCache, view: &ViewState, render_env: &RenderEnv) -> SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read &SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation. -****** Acceptance Criteria - (1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-35e - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:38 - :UPDATED: 2026-04-14 07:48:38 - :KIND: epic - :END: -****** Details -******* Description - Build a function that constructs a complete RenderCache from an &App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '&App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading &App. When every widget has been migrated and the binary split is complete, this adapter is deleted. -******* Design - pub fn RenderCache::from_app(app: &App, subs: SubscriptionSet) -> RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option, category_tree: Option, formulas: Option, views: Option, tile_bar: Option, wizard: Option, help: Option, ... }. Each field is Some if subscribed and populated from App data. -******* Acceptance Criteria - (1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly. -******* Notes - Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read. - -****** TODO Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -******* Details -******** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -******** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -******** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -******** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-q08 - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:14 - :UPDATED: 2026-04-14 07:23:14 - :KIND: epic - :END: -*** Details -**** Description - New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet). -**** Design - crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters. -**** Acceptance Criteria - (1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back. -**** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -**** Details -***** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -***** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -***** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -***** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -***** Details -****** Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -****** Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -****** Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -****** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:12 - :UPDATED: 2026-04-14 07:23:12 - :KIND: epic - :END: -**** Details -***** Description - After each command is applied on the server, walk the resulting effect list, identify model-touching effects, compute which connected clients have the affected cells in their viewport, and enqueue projection commands (CellsUpdated, ColumnLabelsChanged, etc.) for dispatch to those clients. Server must track per-client viewport state (visible row/col range) and mirror each client's ViewState for this to work. -***** Design - Server holds HashMap where ClientSession { view: ViewState, visible_region: (row_range, col_range) }. When a client dispatches a command, update its session's view state via the shared reduce_view. When the command affects Model, walk the effect list: for each ModelTouching effect, compute which cells changed, intersect with each client session's visible_region, emit CellsUpdated commands into that session's outbound queue. ScrollTo commands update visible_region and trigger a fresh CellsUpdated for the newly-visible region. Viewport intersection logic: a cell is visible if its row/col position (computed from layout) lies within the client's visible range. -***** Acceptance Criteria - (1) Server struct ClientSession tracks view state and visible region per connected client. (2) After effect application, a projection step computes Vec<(SessionId, Command)> to dispatch. (3) ScrollTo updates visible region and triggers a fresh render-cache fill. (4) Unit tests: dispatching a SetCell produces CellsUpdated only for clients whose viewport includes the cell. (5) Unit tests: ScrollTo produces a CellsUpdated covering the new visible region. -***** 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) [/] :epic:P2:feature: - :PROPERTIES: - :ID: improvise-cqi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:23:10 - :UPDATED: 2026-04-14 07:23:10 - :KIND: epic - :DONE_DEPS: improvise-36h - :END: -***** Details -****** Description - Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic. -****** Design - pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec<(CellKey, CellDisplay)> }, ColumnLabelsChanged { labels: Vec }, ColumnWidthsChanged { widths: Vec }, RowLabelsChanged { labels: Vec }, ViewportInvalidated, ... }. pub fn reduce_view(vs: &mut ViewState, cache: &mut RenderCache, cmd: &Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation. -****** Acceptance Criteria - (1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown. -****** 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 Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -****** Details -******* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******* 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 Define unified reduce_full(&mut App, &Command) -> Vec (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-gxi - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:48:36 - :UPDATED: 2026-04-14 07:48:36 - :KIND: standalone - :END: -***** Details -****** Description - Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function. -****** Design - Signature: pub fn reduce_full(app: &mut App, cmd: &Command) -> Vec. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports. -****** Acceptance Criteria - (1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path. -****** 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) [/] :epic:P2:task: - :PROPERTIES: - :ID: improvise-mae - :TYPE: task - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:22:02 - :UPDATED: 2026-04-14 07:22:02 - :KIND: epic - :END: -***** Details -****** Description - Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer. -****** Design - Add a method to Effect like fn kind(&self) -> EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }. -****** Acceptance Criteria - (1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind. -****** Notes - Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting. - -***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone) :standalone:P2:feature:@Edward_Langley: - :PROPERTIES: - :ID: improvise-vb4 - :TYPE: feature - :PRIORITY: P2 - :STATUS: in_progress - :ASSIGNEE: Edward Langley - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:21:59 - :UPDATED: 2026-04-15 10:24:54 - :KIND: standalone - :END: -****** Details -******* Description - Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on. -******* Design - pub struct ModelState { model: Model, file_path: Option, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model. -******* Acceptance Criteria - (1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take &mut the correct slice where obvious, or &mut App where cross-cutting. (5) Audit report in issue notes: every field classified. -******* 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) [/] :epic:P4:feature: - :PROPERTIES: - :ID: improvise-a5q - :TYPE: feature - :PRIORITY: P4 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:39:46 - :UPDATED: 2026-04-14 07:39:46 - :KIND: epic - :END: -** Details -*** Description - One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on. -*** Design - Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand. -*** Acceptance Criteria - (1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS). -*** Notes - Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog. - -** TODO Introduce Storage/VFS abstraction in persistence layer (standalone) :standalone:P2:feature: - :PROPERTIES: - :ID: improvise-6mq - :TYPE: feature - :PRIORITY: P2 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: spot - :CREATED: 2026-04-14 07:33:47 - :UPDATED: 2026-04-14 07:33:47 - :KIND: standalone - :DONE_DEPS: improvise-8zh - :END: -*** Details -**** Description - Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend. -**** Design - Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take &dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path. -**** Acceptance Criteria - (1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk. -**** 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 Phase 4: Landing page (optional) (epic) [/] :epic:P4:feature: - :PROPERTIES: - :ID: improvise-kh8 - :TYPE: feature - :PRIORITY: P4 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:05:53 - :UPDATED: 2026-04-09 06:38:02 - :KIND: epic - :END: -** Details -*** 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) [/] :epic:P4:task: - :PROPERTIES: - :ID: improvise-3gy - :TYPE: task - :PRIORITY: P4 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:08:10 - :UPDATED: 2026-04-09 06:38:03 - :KIND: epic - :END: -*** Details -**** Description - Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL. - -*** TODO 4.1 Create docs/index.html landing page (standalone) :standalone:P4:task: - :PROPERTIES: - :ID: improvise-e61 - :TYPE: task - :PRIORITY: P4 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:08:07 - :UPDATED: 2026-04-09 06:38:02 - :KIND: standalone - :DONE_DEPS: improvise-d4w - :END: -**** Details -***** Description - Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true. - -** TODO 4.1 Create docs/index.html landing page (standalone) :standalone:P4:task: - :PROPERTIES: - :ID: improvise-e61 - :TYPE: task - :PRIORITY: P4 - :STATUS: open - :OWNER: el-github@elangley.org - :CREATED_BY: Edward Langley - :CREATED: 2026-04-09 04:08:07 - :UPDATED: 2026-04-09 06:38:02 - :KIND: standalone - :DONE_DEPS: improvise-d4w - :END: -*** Details -**** Description - Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true. + Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(&workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\n\n11 lookup sites identified:\n- crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\n- crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\n- crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\n\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -> Option to absorb the common chain.\n\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.