diff --git a/.gitignore b/.gitignore
index c80d176..1651546 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,5 @@ bench/*.txt
 proptest-regressions/
 .cursor
 .claude/worktrees/
+*~
+/roadmap.html
diff --git a/TAGS b/TAGS
index b29badb..576a6d8 100644
--- a/TAGS
+++ b/TAGS
@@ -748,7 +748,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {ImportWizardWidget21,444
     fn render(self, area: Rect, buf: &mut Buffer) {render22,489
 fn truncate(s: &str, max: usize) -> String {truncate339,13390
 
-src/ui/effect.rs,14177
+src/ui/effect.rs,14356
 pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty";RECORD_COORDS_CANNOT_BE_EMPTY9,160
 pub trait Effect: Debug {Effect13,358
     fn apply(&self, app: &mut App);apply14,384
@@ -786,223 +786,225 @@ pub struct RemoveFormula {RemoveFormula113,3206
     pub target_category: String,target_category115,3257
 impl Effect for RemoveFormula {RemoveFormula117,3292
     fn apply(&self, app: &mut App) {apply118,3324
-pub struct EnterEditAtCursor;EnterEditAtCursor128,3619
-impl Effect for EnterEditAtCursor {EnterEditAtCursor129,3649
-    fn apply(&self, app: &mut App) {apply130,3685
-pub struct TogglePruneEmpty;TogglePruneEmpty148,4184
-impl Effect for TogglePruneEmpty {TogglePruneEmpty149,4213
-    fn apply(&self, app: &mut App) {apply150,4248
-pub struct ToggleCatExpand(pub String);ToggleCatExpand157,4399
-impl Effect for ToggleCatExpand {ToggleCatExpand158,4439
-    fn apply(&self, app: &mut App) {apply159,4473
-pub struct RemoveItem {RemoveItem167,4648
-    pub category: String,category168,4672
-    pub item: String,item169,4698
-impl Effect for RemoveItem {RemoveItem171,4722
-    fn apply(&self, app: &mut App) {apply172,4751
-pub struct RemoveCategory(pub String);RemoveCategory178,4882
-impl Effect for RemoveCategory {RemoveCategory179,4921
-    fn apply(&self, app: &mut App) {apply180,4954
-pub struct CreateView(pub String);CreateView188,5268
-impl Effect for CreateView {CreateView189,5303
-    fn apply(&self, app: &mut App) {apply190,5332
-pub struct DeleteView(pub String);DeleteView196,5438
-impl Effect for DeleteView {DeleteView197,5473
-    fn apply(&self, app: &mut App) {apply198,5502
-pub struct SwitchView(pub String);SwitchView204,5616
-impl Effect for SwitchView {SwitchView205,5651
-    fn apply(&self, app: &mut App) {apply206,5680
-pub struct ViewBack;ViewBack221,6153
-impl Effect for ViewBack {ViewBack222,6174
-    fn apply(&self, app: &mut App) {apply223,6201
-pub struct ViewForward;ViewForward238,6714
-impl Effect for ViewForward {ViewForward239,6738
-    fn apply(&self, app: &mut App) {apply240,6768
-pub struct SetAxis {SetAxis254,7201
-    pub category: String,category255,7222
-    pub axis: Axis,axis256,7248
-impl Effect for SetAxis {SetAxis258,7270
-    fn apply(&self, app: &mut App) {apply259,7296
-pub struct SetPageSelection {SetPageSelection267,7461
-    pub category: String,category268,7491
-    pub item: String,item269,7517
-impl Effect for SetPageSelection {SetPageSelection271,7541
-    fn apply(&self, app: &mut App) {apply272,7576
-pub struct ToggleGroup {ToggleGroup280,7752
-    pub category: String,category281,7777
-    pub group: String,group282,7803
-impl Effect for ToggleGroup {ToggleGroup284,7828
-    fn apply(&self, app: &mut App) {apply285,7858
-pub struct HideItem {HideItem293,8038
-    pub category: String,category294,8060
-    pub item: String,item295,8086
-impl Effect for HideItem {HideItem297,8110
-    fn apply(&self, app: &mut App) {apply298,8137
-pub struct ShowItem {ShowItem306,8304
-    pub category: String,category307,8326
-    pub item: String,item308,8352
-impl Effect for ShowItem {ShowItem310,8376
-    fn apply(&self, app: &mut App) {apply311,8403
-pub struct TransposeAxes;TransposeAxes319,8570
-impl Effect for TransposeAxes {TransposeAxes320,8596
-    fn apply(&self, app: &mut App) {apply321,8628
-pub struct CycleAxis(pub String);CycleAxis327,8748
-impl Effect for CycleAxis {CycleAxis328,8782
-    fn apply(&self, app: &mut App) {apply329,8810
-pub struct SetNumberFormat(pub String);SetNumberFormat335,8933
-impl Effect for SetNumberFormat {SetNumberFormat336,8973
-    fn apply(&self, app: &mut App) {apply337,9007
-pub struct SetSelected(pub usize, pub usize);SetSelected345,9353
-impl Effect for SetSelected {SetSelected346,9399
-    fn apply(&self, app: &mut App) {apply347,9429
-pub struct SetRowOffset(pub usize);SetRowOffset353,9560
-impl Effect for SetRowOffset {SetRowOffset354,9596
-    fn apply(&self, app: &mut App) {apply355,9627
-pub struct SetColOffset(pub usize);SetColOffset361,9750
-impl Effect for SetColOffset {SetColOffset362,9786
-    fn apply(&self, app: &mut App) {apply363,9817
-pub struct ChangeMode(pub AppMode);ChangeMode371,10154
-impl Effect for ChangeMode {ChangeMode372,10190
-    fn apply(&self, app: &mut App) {apply373,10219
-    fn changes_mode(&self) -> bool {changes_mode376,10297
-pub struct SetStatus(pub String);SetStatus382,10373
-impl Effect for SetStatus {SetStatus383,10407
-    fn apply(&self, app: &mut App) {apply384,10435
-pub struct MarkDirty;MarkDirty390,10539
-impl Effect for MarkDirty {MarkDirty391,10561
-    fn apply(&self, app: &mut App) {apply392,10589
-pub struct SetYanked(pub Option<CellValue>);SetYanked398,10678
-impl Effect for SetYanked {SetYanked399,10723
-    fn apply(&self, app: &mut App) {apply400,10751
-pub struct SetSearchQuery(pub String);SetSearchQuery406,10851
-impl Effect for SetSearchQuery {SetSearchQuery407,10890
-    fn apply(&self, app: &mut App) {apply408,10923
-pub struct SetSearchMode(pub bool);SetSearchMode414,11029
-impl Effect for SetSearchMode {SetSearchMode415,11065
-    fn apply(&self, app: &mut App) {apply416,11097
-pub struct SetBuffer {SetBuffer423,11229
-    pub name: String,name424,11252
-    pub value: String,value425,11274
-impl Effect for SetBuffer {SetBuffer427,11299
-    fn apply(&self, app: &mut App) {apply428,11327
-pub struct SetTileCatIdx(pub usize);SetTileCatIdx439,11655
-impl Effect for SetTileCatIdx {SetTileCatIdx440,11692
-    fn apply(&self, app: &mut App) {apply441,11724
-pub struct StartDrill(pub Vec<(CellKey, CellValue)>);StartDrill449,11923
-impl Effect for StartDrill {StartDrill450,11977
-    fn apply(&self, app: &mut App) {apply451,12006
-pub struct ApplyAndClearDrill;ApplyAndClearDrill461,12321
-impl Effect for ApplyAndClearDrill {ApplyAndClearDrill462,12352
-    fn apply(&self, app: &mut App) {apply463,12389
-pub struct SetDrillPendingEdit {SetDrillPendingEdit523,14762
-    pub record_idx: usize,record_idx524,14795
-    pub col_name: String,col_name525,14822
-    pub new_value: String,new_value526,14848
-impl Effect for SetDrillPendingEdit {SetDrillPendingEdit528,14877
-    fn apply(&self, app: &mut App) {apply529,14915
-pub struct Save;Save542,15401
-impl Effect for Save {Save543,15418
-    fn apply(&self, app: &mut App) {apply544,15441
-pub struct SaveAs(pub PathBuf);SaveAs562,16006
-impl Effect for SaveAs {SaveAs563,16038
-    fn apply(&self, app: &mut App) {apply564,16063
-pub struct WizardKey {WizardKey582,16679
-    pub key_code: crossterm::event::KeyCode,key_code583,16702
-impl Effect for WizardKey {WizardKey585,16749
-    fn apply(&self, app: &mut App) {apply586,16777
-pub struct StartImportWizard(pub String);StartImportWizard712,22429
-impl Effect for StartImportWizard {StartImportWizard713,22471
-    fn apply(&self, app: &mut App) {apply714,22507
-pub struct ExportCsv(pub PathBuf);ExportCsv733,23142
-impl Effect for ExportCsv {ExportCsv734,23177
-    fn apply(&self, app: &mut App) {apply735,23205
-pub struct LoadModel(pub PathBuf);LoadModel750,23693
-impl Effect for LoadModel {LoadModel751,23728
-    fn apply(&self, app: &mut App) {apply752,23756
-pub struct ImportJsonHeadless {ImportJsonHeadless768,24268
-    pub path: PathBuf,path769,24300
-    pub model_name: Option<String>,model_name770,24323
-    pub array_path: Option<String>,array_path771,24359
-impl Effect for ImportJsonHeadless {ImportJsonHeadless773,24397
-    fn apply(&self, app: &mut App) {apply774,24434
-pub struct SetPanelOpen {SetPanelOpen878,28005
-    pub panel: Panel,panel879,28031
-    pub open: bool,open880,28053
-pub enum Panel {Panel884,28106
-    Formula,Formula885,28123
-    Category,Category886,28136
-    View,View887,28150
-impl Panel {Panel890,28163
-    pub fn mode(self) -> AppMode {mode891,28176
-impl Effect for SetPanelOpen {SetPanelOpen900,28406
-    fn apply(&self, app: &mut App) {apply901,28437
-pub struct SetPanelCursor {SetPanelCursor911,28731
-    pub panel: Panel,panel912,28759
-    pub cursor: usize,cursor913,28781
-impl Effect for SetPanelCursor {SetPanelCursor915,28806
-    fn apply(&self, app: &mut App) {apply916,28839
-pub fn mark_dirty() -> Box<dyn Effect> {mark_dirty927,29301
-pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {set_status931,29369
-pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {change_mode935,29471
-pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {set_selected939,29560
-pub struct HelpPageNext;HelpPageNext946,29870
-impl Effect for HelpPageNext {HelpPageNext947,29895
-    fn apply(&self, app: &mut App) {apply948,29926
-pub struct HelpPagePrev;HelpPagePrev955,30125
-impl Effect for HelpPagePrev {HelpPagePrev956,30150
-    fn apply(&self, app: &mut App) {apply957,30181
-pub struct HelpPageSet(pub usize);HelpPageSet963,30301
-impl Effect for HelpPageSet {HelpPageSet964,30336
-    fn apply(&self, app: &mut App) {apply965,30366
-pub fn help_page_next() -> Box<dyn Effect> {help_page_next970,30444
-pub fn help_page_prev() -> Box<dyn Effect> {help_page_prev974,30519
-pub fn help_page_set(page: usize) -> Box<dyn Effect> {help_page_set978,30594
-mod tests {tests983,30697
-    fn test_app() -> App {test_app988,30813
-    fn add_category_effect() {add_category_effect1002,31444
-    fn add_item_to_existing_category() {add_item_to_existing_category1009,31653
-    fn add_item_to_nonexistent_category_sets_status() {add_item_to_nonexistent_category_sets_status1028,32148
-    fn set_cell_and_clear_cell() {set_cell_and_clear_cell1039,32457
-    fn add_formula_valid() {add_formula_valid1053,32950
-    fn add_formula_adds_target_item_to_category() {add_formula_adds_target_item_to_category1067,33491
-    fn add_formula_to_measure_shows_in_effective_items() {add_formula_to_measure_shows_in_effective_items1100,34562
-    fn add_formula_invalid_sets_error_status() {add_formula_invalid_sets_error_status1126,35512
-    fn remove_formula_effect() {remove_formula_effect1137,35829
-    fn switch_view_pushes_to_back_stack() {switch_view_pushes_to_back_stack1156,36530
-    fn switch_view_to_same_does_not_push_stack() {switch_view_to_same_does_not_push_stack1170,37054
-    fn view_back_and_forward() {view_back_and_forward1177,37266
-    fn view_back_with_empty_stack_is_noop() {view_back_with_empty_stack_is_noop1199,38100
-    fn create_and_delete_view() {create_and_delete_view1207,38342
-    fn set_axis_effect() {set_axis_effect1217,38667
-    fn transpose_axes_effect() {transpose_axes_effect1228,38949
-    fn set_selected_effect() {set_selected_effect1266,40173
-    fn set_row_and_col_offset() {set_row_and_col_offset1273,40365
-    fn change_mode_effect() {change_mode_effect1284,40835
-    fn set_buffer_empty_clears() {set_buffer_empty_clears1294,41199
-    fn set_status_effect() {set_status_effect1307,41585
-    fn mark_dirty_effect() {mark_dirty_effect1314,41768
-    fn set_yanked_effect() {set_yanked_effect1322,41942
-    fn set_search_query_and_mode() {set_search_query_and_mode1329,42153
-    fn set_buffer_normal_key() {set_buffer_normal_key1342,42664
-    fn set_buffer_search_writes_to_search_query() {set_buffer_search_writes_to_search_query1353,42947
-    fn set_panel_open_and_cursor() {set_panel_open_and_cursor1367,43487
-    fn set_tile_cat_idx_effect() {set_tile_cat_idx_effect1399,44215
-    fn help_page_navigation() {help_page_navigation1408,44561
-    fn help_page_prev_clamps_at_zero() {help_page_prev_clamps_at_zero1422,44990
-    fn start_drill_and_apply_clear_drill_with_no_edits() {start_drill_and_apply_clear_drill_with_no_edits1431,45343
-    fn apply_and_clear_drill_with_value_edit() {apply_and_clear_drill_with_value_edit1448,45952
-    fn apply_and_clear_drill_with_coord_rename() {apply_and_clear_drill_with_coord_rename1475,46849
-    fn apply_and_clear_drill_empty_value_clears_cell() {apply_and_clear_drill_empty_value_clears_cell1517,48255
-    fn toggle_prune_empty_effect() {toggle_prune_empty_effect1543,49214
-    fn toggle_cat_expand_effect() {toggle_cat_expand_effect1553,49585
-    fn remove_item_and_category() {remove_item_and_category1563,49957
-    fn set_number_format_effect() {set_number_format_effect1587,50747
-    fn set_page_selection_effect() {set_page_selection_effect1596,51148
-    fn hide_and_show_item_effects() {hide_and_show_item_effects1609,51645
-    fn toggle_group_effect() {toggle_group_effect1629,52335
-    fn cycle_axis_effect() {cycle_axis_effect1656,53171
-    fn save_without_file_path_shows_status() {save_without_file_path_shows_status1667,53637
-    fn panel_mode_mapping() {panel_mode_mapping1676,54000
+pub struct EnterEditAtCursor {EnterEditAtCursor133,3880
+    pub target_mode: AppMode,target_mode134,3911
+impl Effect for EnterEditAtCursor {EnterEditAtCursor136,3943
+    fn apply(&self, app: &mut App) {apply137,3979
+pub struct TogglePruneEmpty;TogglePruneEmpty153,4532
+impl Effect for TogglePruneEmpty {TogglePruneEmpty154,4561
+    fn apply(&self, app: &mut App) {apply155,4596
+pub struct ToggleCatExpand(pub String);ToggleCatExpand162,4747
+impl Effect for ToggleCatExpand {ToggleCatExpand163,4787
+    fn apply(&self, app: &mut App) {apply164,4821
+pub struct RemoveItem {RemoveItem172,4996
+    pub category: String,category173,5020
+    pub item: String,item174,5046
+impl Effect for RemoveItem {RemoveItem176,5070
+    fn apply(&self, app: &mut App) {apply177,5099
+pub struct RemoveCategory(pub String);RemoveCategory183,5230
+impl Effect for RemoveCategory {RemoveCategory184,5269
+    fn apply(&self, app: &mut App) {apply185,5302
+pub struct CreateView(pub String);CreateView193,5616
+impl Effect for CreateView {CreateView194,5651
+    fn apply(&self, app: &mut App) {apply195,5680
+pub struct DeleteView(pub String);DeleteView201,5786
+impl Effect for DeleteView {DeleteView202,5821
+    fn apply(&self, app: &mut App) {apply203,5850
+pub struct SwitchView(pub String);SwitchView209,5964
+impl Effect for SwitchView {SwitchView210,5999
+    fn apply(&self, app: &mut App) {apply211,6028
+pub struct ViewBack;ViewBack226,6501
+impl Effect for ViewBack {ViewBack227,6522
+    fn apply(&self, app: &mut App) {apply228,6549
+pub struct ViewForward;ViewForward243,7062
+impl Effect for ViewForward {ViewForward244,7086
+    fn apply(&self, app: &mut App) {apply245,7116
+pub struct SetAxis {SetAxis259,7549
+    pub category: String,category260,7570
+    pub axis: Axis,axis261,7596
+impl Effect for SetAxis {SetAxis263,7618
+    fn apply(&self, app: &mut App) {apply264,7644
+pub struct SetPageSelection {SetPageSelection272,7809
+    pub category: String,category273,7839
+    pub item: String,item274,7865
+impl Effect for SetPageSelection {SetPageSelection276,7889
+    fn apply(&self, app: &mut App) {apply277,7924
+pub struct ToggleGroup {ToggleGroup285,8100
+    pub category: String,category286,8125
+    pub group: String,group287,8151
+impl Effect for ToggleGroup {ToggleGroup289,8176
+    fn apply(&self, app: &mut App) {apply290,8206
+pub struct HideItem {HideItem298,8386
+    pub category: String,category299,8408
+    pub item: String,item300,8434
+impl Effect for HideItem {HideItem302,8458
+    fn apply(&self, app: &mut App) {apply303,8485
+pub struct ShowItem {ShowItem311,8652
+    pub category: String,category312,8674
+    pub item: String,item313,8700
+impl Effect for ShowItem {ShowItem315,8724
+    fn apply(&self, app: &mut App) {apply316,8751
+pub struct TransposeAxes;TransposeAxes324,8918
+impl Effect for TransposeAxes {TransposeAxes325,8944
+    fn apply(&self, app: &mut App) {apply326,8976
+pub struct CycleAxis(pub String);CycleAxis332,9096
+impl Effect for CycleAxis {CycleAxis333,9130
+    fn apply(&self, app: &mut App) {apply334,9158
+pub struct SetNumberFormat(pub String);SetNumberFormat340,9281
+impl Effect for SetNumberFormat {SetNumberFormat341,9321
+    fn apply(&self, app: &mut App) {apply342,9355
+pub struct SetSelected(pub usize, pub usize);SetSelected350,9701
+impl Effect for SetSelected {SetSelected351,9747
+    fn apply(&self, app: &mut App) {apply352,9777
+pub struct SetRowOffset(pub usize);SetRowOffset358,9908
+impl Effect for SetRowOffset {SetRowOffset359,9944
+    fn apply(&self, app: &mut App) {apply360,9975
+pub struct SetColOffset(pub usize);SetColOffset366,10098
+impl Effect for SetColOffset {SetColOffset367,10134
+    fn apply(&self, app: &mut App) {apply368,10165
+pub struct ChangeMode(pub AppMode);ChangeMode376,10502
+impl Effect for ChangeMode {ChangeMode377,10538
+    fn apply(&self, app: &mut App) {apply378,10567
+    fn changes_mode(&self) -> bool {changes_mode381,10645
+pub struct SetStatus(pub String);SetStatus387,10721
+impl Effect for SetStatus {SetStatus388,10755
+    fn apply(&self, app: &mut App) {apply389,10783
+pub struct MarkDirty;MarkDirty395,10887
+impl Effect for MarkDirty {MarkDirty396,10909
+    fn apply(&self, app: &mut App) {apply397,10937
+pub struct SetYanked(pub Option<CellValue>);SetYanked403,11026
+impl Effect for SetYanked {SetYanked404,11071
+    fn apply(&self, app: &mut App) {apply405,11099
+pub struct SetSearchQuery(pub String);SetSearchQuery411,11199
+impl Effect for SetSearchQuery {SetSearchQuery412,11238
+    fn apply(&self, app: &mut App) {apply413,11271
+pub struct SetSearchMode(pub bool);SetSearchMode419,11377
+impl Effect for SetSearchMode {SetSearchMode420,11413
+    fn apply(&self, app: &mut App) {apply421,11445
+pub struct SetBuffer {SetBuffer428,11577
+    pub name: String,name429,11600
+    pub value: String,value430,11622
+impl Effect for SetBuffer {SetBuffer432,11647
+    fn apply(&self, app: &mut App) {apply433,11675
+pub struct SetTileCatIdx(pub usize);SetTileCatIdx444,12003
+impl Effect for SetTileCatIdx {SetTileCatIdx445,12040
+    fn apply(&self, app: &mut App) {apply446,12072
+pub struct StartDrill(pub Vec<(CellKey, CellValue)>);StartDrill454,12271
+impl Effect for StartDrill {StartDrill455,12325
+    fn apply(&self, app: &mut App) {apply456,12354
+pub struct ApplyAndClearDrill;ApplyAndClearDrill466,12669
+impl Effect for ApplyAndClearDrill {ApplyAndClearDrill467,12700
+    fn apply(&self, app: &mut App) {apply468,12737
+pub struct SetDrillPendingEdit {SetDrillPendingEdit528,15110
+    pub record_idx: usize,record_idx529,15143
+    pub col_name: String,col_name530,15170
+    pub new_value: String,new_value531,15196
+impl Effect for SetDrillPendingEdit {SetDrillPendingEdit533,15225
+    fn apply(&self, app: &mut App) {apply534,15263
+pub struct Save;Save547,15749
+impl Effect for Save {Save548,15766
+    fn apply(&self, app: &mut App) {apply549,15789
+pub struct SaveAs(pub PathBuf);SaveAs567,16354
+impl Effect for SaveAs {SaveAs568,16386
+    fn apply(&self, app: &mut App) {apply569,16411
+pub struct WizardKey {WizardKey587,17027
+    pub key_code: crossterm::event::KeyCode,key_code588,17050
+impl Effect for WizardKey {WizardKey590,17097
+    fn apply(&self, app: &mut App) {apply591,17125
+pub struct StartImportWizard(pub String);StartImportWizard717,22777
+impl Effect for StartImportWizard {StartImportWizard718,22819
+    fn apply(&self, app: &mut App) {apply719,22855
+pub struct ExportCsv(pub PathBuf);ExportCsv738,23490
+impl Effect for ExportCsv {ExportCsv739,23525
+    fn apply(&self, app: &mut App) {apply740,23553
+pub struct LoadModel(pub PathBuf);LoadModel755,24041
+impl Effect for LoadModel {LoadModel756,24076
+    fn apply(&self, app: &mut App) {apply757,24104
+pub struct ImportJsonHeadless {ImportJsonHeadless773,24616
+    pub path: PathBuf,path774,24648
+    pub model_name: Option<String>,model_name775,24671
+    pub array_path: Option<String>,array_path776,24707
+impl Effect for ImportJsonHeadless {ImportJsonHeadless778,24745
+    fn apply(&self, app: &mut App) {apply779,24782
+pub struct SetPanelOpen {SetPanelOpen883,28353
+    pub panel: Panel,panel884,28379
+    pub open: bool,open885,28401
+pub enum Panel {Panel889,28454
+    Formula,Formula890,28471
+    Category,Category891,28484
+    View,View892,28498
+impl Panel {Panel895,28511
+    pub fn mode(self) -> AppMode {mode896,28524
+impl Effect for SetPanelOpen {SetPanelOpen905,28754
+    fn apply(&self, app: &mut App) {apply906,28785
+pub struct SetPanelCursor {SetPanelCursor916,29079
+    pub panel: Panel,panel917,29107
+    pub cursor: usize,cursor918,29129
+impl Effect for SetPanelCursor {SetPanelCursor920,29154
+    fn apply(&self, app: &mut App) {apply921,29187
+pub fn mark_dirty() -> Box<dyn Effect> {mark_dirty932,29649
+pub fn set_status(msg: impl Into<String>) -> Box<dyn Effect> {set_status936,29717
+pub fn change_mode(mode: AppMode) -> Box<dyn Effect> {change_mode940,29819
+pub fn set_selected(row: usize, col: usize) -> Box<dyn Effect> {set_selected944,29908
+pub struct HelpPageNext;HelpPageNext951,30218
+impl Effect for HelpPageNext {HelpPageNext952,30243
+    fn apply(&self, app: &mut App) {apply953,30274
+pub struct HelpPagePrev;HelpPagePrev960,30473
+impl Effect for HelpPagePrev {HelpPagePrev961,30498
+    fn apply(&self, app: &mut App) {apply962,30529
+pub struct HelpPageSet(pub usize);HelpPageSet968,30649
+impl Effect for HelpPageSet {HelpPageSet969,30684
+    fn apply(&self, app: &mut App) {apply970,30714
+pub fn help_page_next() -> Box<dyn Effect> {help_page_next975,30792
+pub fn help_page_prev() -> Box<dyn Effect> {help_page_prev979,30867
+pub fn help_page_set(page: usize) -> Box<dyn Effect> {help_page_set983,30942
+mod tests {tests988,31045
+    fn test_app() -> App {test_app993,31161
+    fn add_category_effect() {add_category_effect1007,31792
+    fn add_item_to_existing_category() {add_item_to_existing_category1014,32001
+    fn add_item_to_nonexistent_category_sets_status() {add_item_to_nonexistent_category_sets_status1033,32496
+    fn set_cell_and_clear_cell() {set_cell_and_clear_cell1044,32805
+    fn add_formula_valid() {add_formula_valid1058,33298
+    fn add_formula_adds_target_item_to_category() {add_formula_adds_target_item_to_category1072,33839
+    fn add_formula_to_measure_shows_in_effective_items() {add_formula_to_measure_shows_in_effective_items1105,34910
+    fn add_formula_invalid_sets_error_status() {add_formula_invalid_sets_error_status1131,35860
+    fn remove_formula_effect() {remove_formula_effect1142,36177
+    fn switch_view_pushes_to_back_stack() {switch_view_pushes_to_back_stack1161,36878
+    fn switch_view_to_same_does_not_push_stack() {switch_view_to_same_does_not_push_stack1175,37402
+    fn view_back_and_forward() {view_back_and_forward1182,37614
+    fn view_back_with_empty_stack_is_noop() {view_back_with_empty_stack_is_noop1204,38448
+    fn create_and_delete_view() {create_and_delete_view1212,38690
+    fn set_axis_effect() {set_axis_effect1222,39015
+    fn transpose_axes_effect() {transpose_axes_effect1233,39297
+    fn set_selected_effect() {set_selected_effect1271,40521
+    fn set_row_and_col_offset() {set_row_and_col_offset1278,40713
+    fn change_mode_effect() {change_mode_effect1289,41183
+    fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {enter_edit_at_cursor_uses_target_mode_not_app_mode1301,41700
+    fn set_buffer_empty_clears() {set_buffer_empty_clears1334,42863
+    fn set_status_effect() {set_status_effect1347,43249
+    fn mark_dirty_effect() {mark_dirty_effect1354,43432
+    fn set_yanked_effect() {set_yanked_effect1362,43606
+    fn set_search_query_and_mode() {set_search_query_and_mode1369,43817
+    fn set_buffer_normal_key() {set_buffer_normal_key1382,44328
+    fn set_buffer_search_writes_to_search_query() {set_buffer_search_writes_to_search_query1393,44611
+    fn set_panel_open_and_cursor() {set_panel_open_and_cursor1407,45151
+    fn set_tile_cat_idx_effect() {set_tile_cat_idx_effect1439,45879
+    fn help_page_navigation() {help_page_navigation1448,46225
+    fn help_page_prev_clamps_at_zero() {help_page_prev_clamps_at_zero1462,46654
+    fn start_drill_and_apply_clear_drill_with_no_edits() {start_drill_and_apply_clear_drill_with_no_edits1471,47007
+    fn apply_and_clear_drill_with_value_edit() {apply_and_clear_drill_with_value_edit1488,47616
+    fn apply_and_clear_drill_with_coord_rename() {apply_and_clear_drill_with_coord_rename1515,48513
+    fn apply_and_clear_drill_empty_value_clears_cell() {apply_and_clear_drill_empty_value_clears_cell1557,49919
+    fn toggle_prune_empty_effect() {toggle_prune_empty_effect1583,50878
+    fn toggle_cat_expand_effect() {toggle_cat_expand_effect1593,51249
+    fn remove_item_and_category() {remove_item_and_category1603,51621
+    fn set_number_format_effect() {set_number_format_effect1627,52411
+    fn set_page_selection_effect() {set_page_selection_effect1636,52812
+    fn hide_and_show_item_effects() {hide_and_show_item_effects1649,53309
+    fn toggle_group_effect() {toggle_group_effect1669,53999
+    fn cycle_axis_effect() {cycle_axis_effect1696,54835
+    fn save_without_file_path_shows_status() {save_without_file_path_shows_status1707,55301
+    fn panel_mode_mapping() {panel_mode_mapping1716,55664
 
 src/ui/mod.rs,400
 pub mod app;app1,0
@@ -1434,7 +1436,7 @@ mod tests {tests302,10085
     fn parse_axis_recognizes_all_variants() {parse_axis_recognizes_all_variants306,10128
     fn parse_axis_rejects_unknown() {parse_axis_rejects_unknown316,10462
 
-src/command/cmd/commit.rs,2750
+src/command/cmd/commit.rs,2939
 mod tests {tests10,266
     fn commit_formula_with_categories_adds_formula() {commit_formula_with_categories_adds_formula18,426
     fn commit_formula_without_regular_categories_targets_measure() {commit_formula_without_regular_categories_targets_measure41,1294
@@ -1442,39 +1444,41 @@ mod tests {tests10,266
     fn commit_category_add_with_empty_buffer_returns_to_panel() {commit_category_add_with_empty_buffer_returns_to_panel79,2622
     fn commit_item_add_with_name_produces_add_item() {commit_item_add_with_name_produces_add_item96,3221
     fn commit_item_add_outside_item_add_mode_returns_empty() {commit_item_add_outside_item_add_mode_returns_empty112,3853
-    fn commit_export_produces_export_and_normal_mode() {commit_export_produces_export_and_normal_mode122,4176
-fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {commit_regular_cell_value143,5139
-fn stage_drill_edit(record_idx: usize, col_name: String, value: &str) -> Box<dyn Effect> {stage_drill_edit158,5735
-fn commit_plain_records_edit(commit_plain_records_edit167,6034
-fn commit_cell_value(commit_cell_value208,7063
-pub enum AdvanceDir {AdvanceDir228,7649
-    Down,Down230,7741
-    Right,Right232,7800
-pub struct CommitAndAdvance {CommitAndAdvance238,7981
-    pub key: CellKey,key239,8011
-    pub value: String,value240,8033
-    pub advance: AdvanceDir,advance241,8056
-    pub cursor: CursorState,cursor242,8085
-impl Cmd for CommitAndAdvance {CommitAndAdvance244,8116
-    fn name(&self) -> &'static str {name245,8148
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute251,8343
-pub struct CommitFormula;CommitFormula297,10180
-impl Cmd for CommitFormula {CommitFormula298,10206
-    fn name(&self) -> &'static str {name299,10235
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute302,10303
-fn commit_add_from_buffer(commit_add_from_buffer322,11120
-pub struct CommitCategoryAdd;CommitCategoryAdd345,11827
-impl Cmd for CommitCategoryAdd {CommitCategoryAdd346,11857
-    fn name(&self) -> &'static str {name347,11890
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute350,11963
-pub struct CommitItemAdd;CommitItemAdd362,12340
-impl Cmd for CommitItemAdd {CommitItemAdd363,12366
-    fn name(&self) -> &'static str {name364,12395
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute367,12464
-pub struct CommitExport;CommitExport389,13085
-impl Cmd for CommitExport {CommitExport390,13110
-    fn name(&self) -> &'static str {name391,13138
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute394,13205
+    fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor125,4399
+    fn commit_export_produces_export_and_normal_mode() {commit_export_produces_export_and_normal_mode151,5424
+fn commit_regular_cell_value(key: &CellKey, value: &str, effects: &mut Vec<Box<dyn Effect>>) {commit_regular_cell_value172,6387
+fn stage_drill_edit(record_idx: usize, col_name: String, value: &str) -> Box<dyn Effect> {stage_drill_edit187,6983
+fn commit_plain_records_edit(commit_plain_records_edit196,7282
+fn commit_cell_value(commit_cell_value237,8311
+pub enum AdvanceDir {AdvanceDir257,8897
+    Down,Down259,8989
+    Right,Right261,9048
+pub struct CommitAndAdvance {CommitAndAdvance272,9491
+    pub key: CellKey,key273,9521
+    pub value: String,value274,9543
+    pub advance: AdvanceDir,advance275,9566
+    pub cursor: CursorState,cursor276,9595
+    pub edit_mode: AppMode,edit_mode277,9624
+impl Cmd for CommitAndAdvance {CommitAndAdvance279,9654
+    fn name(&self) -> &'static str {name280,9686
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute286,9881
+pub struct CommitFormula;CommitFormula334,11779
+impl Cmd for CommitFormula {CommitFormula335,11805
+    fn name(&self) -> &'static str {name336,11834
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute339,11902
+fn commit_add_from_buffer(commit_add_from_buffer359,12719
+pub struct CommitCategoryAdd;CommitCategoryAdd382,13426
+impl Cmd for CommitCategoryAdd {CommitCategoryAdd383,13456
+    fn name(&self) -> &'static str {name384,13489
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute387,13562
+pub struct CommitItemAdd;CommitItemAdd399,13939
+impl Cmd for CommitItemAdd {CommitItemAdd400,13965
+    fn name(&self) -> &'static str {name401,13994
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute404,14063
+pub struct CommitExport;CommitExport426,14684
+impl Cmd for CommitExport {CommitExport427,14709
+    fn name(&self) -> &'static str {name428,14737
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute431,14804
 
 src/command/cmd/cell.rs,1886
 mod tests {tests6,90
@@ -1508,9 +1512,10 @@ impl Cmd for SaveCmd {SaveCmd191,5956
     fn name(&self) -> &'static str {name192,5979
     fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute195,6037
 
-src/command/cmd/registry.rs,113
-pub fn default_registry() -> CmdRegistry {default_registry19,459
-    macro_rules! reg_jump {reg_jump105,4345
+src/command/cmd/registry.rs,193
+fn parse_mode_name(s: &str) -> Result<AppMode, String> {parse_mode_name7,208
+pub fn default_registry() -> CmdRegistry {default_registry40,1375
+    macro_rules! reg_jump {reg_jump126,5261
 
 src/command/cmd/effect_cmds.rs,518
 mod tests {tests8,162
@@ -1791,59 +1796,58 @@ impl Cmd for ExitSearchMode {ExitSearchMode195,6137
     fn name(&self) -> &'static str {name196,6167
     fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute199,6237
 
-src/command/cmd/mode.rs,3175
+src/command/cmd/mode.rs,3285
 mod tests {tests8,151
-    fn enter_edit_mode_produces_editing_mode() {enter_edit_mode_produces_editing_mode14,275
-    fn enter_tile_select_with_categories() {enter_tile_select_with_categories29,788
-    fn enter_tile_select_no_categories() {enter_tile_select_no_categories45,1285
-    fn enter_export_prompt_sets_mode() {enter_export_prompt_sets_mode56,1621
-    fn force_quit_always_produces_quit_mode() {force_quit_always_produces_quit_mode70,2055
-    fn save_and_quit_produces_save_then_quit() {save_and_quit_produces_save_then_quit83,2506
-    fn edit_or_drill_without_aggregation_enters_edit() {edit_or_drill_without_aggregation_enters_edit96,2993
-    fn enter_search_mode_sets_flag_and_clears_query() {enter_search_mode_sets_flag_and_clears_query107,3393
-pub struct EnterMode(pub AppMode);EnterMode129,4200
-impl Cmd for EnterMode {EnterMode130,4235
-    fn name(&self) -> &'static str {name131,4260
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute134,4324
-pub struct ForceQuit;ForceQuit149,4842
-impl Cmd for ForceQuit {ForceQuit150,4864
-    fn name(&self) -> &'static str {name151,4889
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute154,4953
-pub struct Quit;Quit161,5159
-impl Cmd for Quit {Quit162,5176
-    fn name(&self) -> &'static str {name163,5196
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute166,5251
-pub struct SaveAndQuit;SaveAndQuit179,5597
-impl Cmd for SaveAndQuit {SaveAndQuit180,5621
-    fn name(&self) -> &'static str {name181,5648
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute184,5704
-pub struct EnterEditMode {EnterEditMode193,6114
-    pub initial_value: String,initial_value194,6141
-impl Cmd for EnterEditMode {EnterEditMode196,6174
-    fn name(&self) -> &'static str {name197,6203
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute200,6272
-pub struct EditOrDrill;EditOrDrill220,6953
-impl Cmd for EditOrDrill {EditOrDrill221,6977
-    fn name(&self) -> &'static str {name222,7004
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute225,7071
-pub struct EnterEditAtCursorCmd;EnterEditAtCursorCmd254,8262
-impl Cmd for EnterEditAtCursorCmd {EnterEditAtCursorCmd255,8295
-    fn name(&self) -> &'static str {name256,8331
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute259,8405
-pub struct EnterExportPrompt;EnterExportPrompt266,8578
-impl Cmd for EnterExportPrompt {EnterExportPrompt267,8608
-    fn name(&self) -> &'static str {name268,8641
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute271,8714
-pub struct EnterSearchMode;EnterSearchMode278,8890
-impl Cmd for EnterSearchMode {EnterSearchMode279,8918
-    fn name(&self) -> &'static str {name280,8949
-    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute283,9009
-pub struct EnterTileSelect;EnterTileSelect293,9266
-impl Cmd for EnterTileSelect {EnterTileSelect294,9294
-    fn name(&self) -> &'static str {name295,9325
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute298,9396
+    fn enter_tile_select_with_categories() {enter_tile_select_with_categories14,275
+    fn enter_tile_select_no_categories() {enter_tile_select_no_categories30,772
+    fn enter_export_prompt_sets_mode() {enter_export_prompt_sets_mode41,1108
+    fn force_quit_always_produces_quit_mode() {force_quit_always_produces_quit_mode55,1542
+    fn save_and_quit_produces_save_then_quit() {save_and_quit_produces_save_then_quit68,1993
+    fn edit_or_drill_without_aggregation_enters_edit() {edit_or_drill_without_aggregation_enters_edit81,2480
+    fn edit_or_drill_passes_records_editing_mode_through() {edit_or_drill_passes_records_editing_mode_through101,3324
+    fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {enter_edit_at_cursor_cmd_passes_target_mode_to_effect123,4197
+    fn edit_or_drill_pre_fills_edit_buffer_with_display_value() {edit_or_drill_pre_fills_edit_buffer_with_display_value143,4926
+    fn enter_search_mode_sets_flag_and_clears_query() {enter_search_mode_sets_flag_and_clears_query161,5554
+pub struct EnterMode(pub AppMode);EnterMode183,6361
+impl Cmd for EnterMode {EnterMode184,6396
+    fn name(&self) -> &'static str {name185,6421
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute188,6485
+pub struct ForceQuit;ForceQuit203,7003
+impl Cmd for ForceQuit {ForceQuit204,7025
+    fn name(&self) -> &'static str {name205,7050
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute208,7114
+pub struct Quit;Quit215,7320
+impl Cmd for Quit {Quit216,7337
+    fn name(&self) -> &'static str {name217,7357
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute220,7412
+pub struct SaveAndQuit;SaveAndQuit233,7758
+impl Cmd for SaveAndQuit {SaveAndQuit234,7782
+    fn name(&self) -> &'static str {name235,7809
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute238,7865
+pub struct EditOrDrill {EditOrDrill254,8707
+    pub edit_mode: AppMode,edit_mode255,8732
+impl Cmd for EditOrDrill {EditOrDrill257,8762
+    fn name(&self) -> &'static str {name258,8789
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute261,8856
+pub struct EnterEditAtCursorCmd {EnterEditAtCursorCmd295,10299
+    pub target_mode: AppMode,target_mode296,10333
+impl Cmd for EnterEditAtCursorCmd {EnterEditAtCursorCmd298,10365
+    fn name(&self) -> &'static str {name299,10401
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute302,10475
+pub struct EnterExportPrompt;EnterExportPrompt311,10711
+impl Cmd for EnterExportPrompt {EnterExportPrompt312,10741
+    fn name(&self) -> &'static str {name313,10774
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute316,10847
+pub struct EnterSearchMode;EnterSearchMode323,11023
+impl Cmd for EnterSearchMode {EnterSearchMode324,11051
+    fn name(&self) -> &'static str {name325,11082
+    fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute328,11142
+pub struct EnterTileSelect;EnterTileSelect338,11399
+impl Cmd for EnterTileSelect {EnterTileSelect339,11427
+    fn name(&self) -> &'static str {name340,11458
+    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {execute343,11529
 
-src/command/keymap.rs,5088
+src/command/keymap.rs,5093
 fn format_key_label(code: &KeyCode) -> String {format_key_label13,301
 pub enum KeyPattern {KeyPattern38,1303
     Key(KeyCode, KeyModifiers),Key40,1359
@@ -1904,29 +1908,29 @@ impl KeymapSet {KeymapSet289,9804
     pub fn dispatch(dispatch306,10224
     pub fn dispatch_transient(dispatch_transient318,10642
     pub fn default_keymaps() -> Self {default_keymaps329,10948
-mod tests {tests930,33396
-    fn lookup_exact_match() {lookup_exact_match936,33587
-    fn lookup_exact_with_ctrl() {lookup_exact_with_ctrl944,33877
-    fn lookup_char_falls_back_to_none_mods() {lookup_char_falls_back_to_none_mods952,34175
-    fn lookup_non_char_does_not_fall_back_to_none_mods() {lookup_non_char_does_not_fall_back_to_none_mods962,34633
-    fn lookup_char_falls_to_any_char() {lookup_char_falls_to_any_char972,35046
-    fn lookup_non_char_skips_any_char() {lookup_non_char_skips_any_char986,35414
-    fn lookup_any_matches_everything() {lookup_any_matches_everything995,35722
-    fn lookup_exact_takes_priority_over_any_char() {lookup_exact_takes_priority_over_any_char1009,36067
-    fn lookup_any_char_takes_priority_over_any() {lookup_any_char_takes_priority_over_any1024,36509
-    fn lookup_non_char_falls_to_any_not_any_char() {lookup_non_char_falls_to_any_not_any_char1039,36921
-    fn lookup_ctrl_char_with_only_none_binding_falls_through() {lookup_ctrl_char_with_only_none_binding_falls_through1054,37330
-    fn mode_key_normal_no_search() {mode_key_normal_no_search1065,37836
-    fn mode_key_normal_with_search_overrides() {mode_key_normal_with_search_overrides1071,38005
-    fn mode_key_help() {mode_key_help1077,38189
-    fn mode_key_quit_returns_none() {mode_key_quit_returns_none1083,38342
-    fn lookup_returns_prefix_binding() {lookup_returns_prefix_binding1091,38669
-    fn lookup_returns_sequence_binding() {lookup_returns_sequence_binding1100,39000
-    fn default_keymaps_has_all_modes() {default_keymaps_has_all_modes1114,39551
-    fn normal_mode_has_basic_movement() {normal_mode_has_basic_movement1144,40436
-    fn editing_mode_has_any_char_and_esc() {editing_mode_has_any_char_and_esc1168,41220
-    fn search_mode_has_any_char_and_esc() {search_mode_has_any_char_and_esc1182,41696
-    fn import_wizard_has_any_catchall() {import_wizard_has_any_catchall1194,42090
+mod tests {tests978,34976
+    fn lookup_exact_match() {lookup_exact_match984,35167
+    fn lookup_exact_with_ctrl() {lookup_exact_with_ctrl992,35457
+    fn lookup_char_falls_back_to_none_mods() {lookup_char_falls_back_to_none_mods1000,35755
+    fn lookup_non_char_does_not_fall_back_to_none_mods() {lookup_non_char_does_not_fall_back_to_none_mods1010,36213
+    fn lookup_char_falls_to_any_char() {lookup_char_falls_to_any_char1020,36626
+    fn lookup_non_char_skips_any_char() {lookup_non_char_skips_any_char1034,36994
+    fn lookup_any_matches_everything() {lookup_any_matches_everything1043,37302
+    fn lookup_exact_takes_priority_over_any_char() {lookup_exact_takes_priority_over_any_char1057,37647
+    fn lookup_any_char_takes_priority_over_any() {lookup_any_char_takes_priority_over_any1072,38089
+    fn lookup_non_char_falls_to_any_not_any_char() {lookup_non_char_falls_to_any_not_any_char1087,38501
+    fn lookup_ctrl_char_with_only_none_binding_falls_through() {lookup_ctrl_char_with_only_none_binding_falls_through1102,38910
+    fn mode_key_normal_no_search() {mode_key_normal_no_search1113,39416
+    fn mode_key_normal_with_search_overrides() {mode_key_normal_with_search_overrides1119,39585
+    fn mode_key_help() {mode_key_help1125,39769
+    fn mode_key_quit_returns_none() {mode_key_quit_returns_none1131,39922
+    fn lookup_returns_prefix_binding() {lookup_returns_prefix_binding1139,40249
+    fn lookup_returns_sequence_binding() {lookup_returns_sequence_binding1148,40580
+    fn default_keymaps_has_all_modes() {default_keymaps_has_all_modes1162,41131
+    fn normal_mode_has_basic_movement() {normal_mode_has_basic_movement1192,42016
+    fn editing_mode_has_any_char_and_esc() {editing_mode_has_any_char_and_esc1216,42800
+    fn search_mode_has_any_char_and_esc() {search_mode_has_any_char_and_esc1230,43276
+    fn import_wizard_has_any_catchall() {import_wizard_has_any_catchall1242,43670
 
 src/command/mod.rs,80
 pub mod cmd;cmd8,323
diff --git a/roadmap.org b/roadmap.org
new file mode 100644
index 0000000..4a7524b
--- /dev/null
+++ b/roadmap.org
@@ -0,0 +1,9170 @@
+#+TITLE: Improvise Roadmap
+#+AUTHOR: Edward Langley
+#+TODO: TODO DOING WAIT | DONE
+#+STARTUP: overview
+#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug
+#+PROPERTY: COOKIE_DATA todo recursive
+
+Generated from ~bd list --json~. 45 open issues organised as 14 inverted dep-trees: each root is a goal that nothing else depends on; its children are the deps blocking it. Diamonds in the DAG are duplicated so each tree stands alone.
+
+* DOING 'o' (add-record-row) broken in fresh data models (standalone)                          :standalone:P2:bug:@cursor_f4c497bb:
+ :PROPERTIES:
+ :ID:         improvise-s0h
+ :TYPE:       bug
+ :PRIORITY:   P2
+ :STATUS:     in_progress
+ :ASSIGNEE:   cursor-f4c497bb
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-09 22:18:58
+ :UPDATED:    2026-04-14 08:07:41
+ :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.
+
+* TODO Virtual views _Records and _Drill should not be persisted to .improv files (standalone) :standalone:P2:task:
+ :PROPERTIES:
+ :ID:         improvise-60z
+ :TYPE:       task
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: Edward Langley
+ :CREATED:    2026-04-15 11:13:29
+ :UPDATED:    2026-04-15 11:13:29
+ :KIND:       standalone
+ :END:
+
+* TODO Epic: Browser frontend via synchronized Redux-style stores (epic) [0/68] :epic:P2:feature:
+ :PROPERTIES:
+ :ID:         improvise-6jk
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-14 07:21:21
+ :UPDATED:    2026-04-14 07:21:21
+ :KIND:       epic
+ :END:
+** Details
+*** Description
+     Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.
+*** Design
+     Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.
+*** Notes
+     Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.
+
+** 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<PathBuf>, 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 Browser frontend MVP: end-to-end working demo (epic) [0/23] :epic:P2:feature:
+   :PROPERTIES:
+   :ID:         improvise-1ey
+   :TYPE:       feature
+   :PRIORITY:   P2
+   :STATUS:     open
+   :OWNER:      el-github@elangley.org
+   :CREATED_BY: spot
+   :CREATED:    2026-04-14 07:24:44
+   :UPDATED:    2026-04-14 07:24:44
+   :KIND:       epic
+   :END:
+*** Details
+**** Description
+       Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.
+**** Design
+       HTML shell: minimal index.html with a <div> for the grid and a <script> tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at bank-info.improv or any existing test file.
+**** Acceptance Criteria
+       (1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.
+**** Notes
+       Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.
+
+*** TODO improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [0/10] :epic:P2:feature:
+     :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) [0/2] :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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 Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :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
+   :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)         :standalone:P2:feature:@spot:
+     :PROPERTIES:
+     :ID:         improvise-36h
+     :TYPE:       feature
+     :PRIORITY:   P2
+     :STATUS:     in_progress
+     :ASSIGNEE:   spot
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:05
+     :UPDATED:    2026-04-15 10:10:14
+     :KIND:       standalone
+     :DONE_DEPS:  improvise-ewi
+     :END:
+**** Details
+***** Description
+         Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+***** Design
+         Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+***** Acceptance Criteria
+         (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+***** Notes
+         This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)        :standalone:P2:feature:@Edward_Langley:
+     :PROPERTIES:
+     :ID:         improvise-vb4
+     :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+     :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)       :standalone:P2:feature:@Edward_Langley:
+       :PROPERTIES:
+       :ID:         improvise-vb4
+       :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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 improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+***** Design
+         AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+***** Acceptance Criteria
+         (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+***** Notes
+         IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+**** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+       :PROPERTIES:
+       :ID:         improvise-45v
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     open
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:36:11
+       :UPDATED:    2026-04-14 06:36:11
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+****** Design
+           Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+****** Acceptance Criteria
+           (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+****** Notes
+           Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+         :PROPERTIES:
+         :ID:         improvise-8zh
+         :TYPE:       task
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:23
+         :UPDATED:    2026-04-14 06:35:23
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******* Acceptance Criteria
+             (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******* Notes
+             Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+     :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
+     :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)       :standalone:P2:feature:@Edward_Langley:
+       :PROPERTIES:
+       :ID:         improvise-vb4
+       :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<PathBuf>, 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 Tag existing effects as model / view / projection-emitting (epic) [0/1] :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<PathBuf>, 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 improvise-ws-server binary (tokio + tungstenite session wrapper) (epic) [0/10] :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) [0/2] :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
+     :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)       :standalone:P2:feature:@Edward_Langley:
+       :PROPERTIES:
+       :ID:         improvise-vb4
+       :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+****** Design
+           AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+****** Acceptance Criteria
+           (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+****** Notes
+           IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+         :PROPERTIES:
+         :ID:         improvise-45v
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:36:11
+         :UPDATED:    2026-04-14 06:36:11
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******* Design
+             Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******* Acceptance Criteria
+             (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******* Notes
+             Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+       :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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 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:
+ :ID:         improvise-c5v
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: Edward Langley
+ :CREATED:    2026-04-15 10:34:46
+ :UPDATED:    2026-04-15 10:34:46
+ :KIND:       standalone
+ :END:
+** Details
+*** 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:
+ :PROPERTIES:
+ :ID:         improvise-dz8
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-14 07:30:04
+ :UPDATED:    2026-04-14 07:30:04
+ :KIND:       standalone
+ :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 Epic: Native TUI as in-process client of the unified server architecture (epic) [0/42] :epic:P2:feature:
+ :PROPERTIES:
+ :ID:         improvise-ltq
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-14 07:48:34
+ :UPDATED:    2026-04-14 07:48:34
+ :KIND:       epic
+ :END:
+** Details
+*** Description
+     Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading &App directly.
+*** Design
+     Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.
+*** Notes
+     Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.
+
+** TODO Temporary adapter: RenderCache::from_app bridge for incremental widget migration (epic) [0/1] :epic:P2:task:
+   :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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 Split native binary into in-process client + server, delete &App adapter (epic) [0/19] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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<u16>, pub row_labels: Vec<String>, pub col_labels: Vec<String>, pub cells: Vec<Vec<CellDisplay>>, pub cursor: (usize, usize), pub highlights: Vec<Highlight>, 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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<u16>, pub row_labels: Vec<String>, pub col_labels: Vec<String>, pub cells: Vec<Vec<CellDisplay>>, pub cursor: (usize, usize), pub highlights: Vec<Highlight>, 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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 Epic: Standalone static-web deployment via wasm + VFS storage (epic) [0/237] :epic:P2:feature:
+ :PROPERTIES:
+ :ID:         improvise-tm6
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-14 07:33:45
+ :UPDATED:    2026-04-14 07:33:45
+ :KIND:       epic
+ :END:
+** Details
+*** Description
+     Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).
+*** Design
+     Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.
+*** Notes
+     Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.
+
+** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic:P2:feature:
+   :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:       epic
+   :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+     :PROPERTIES:
+     :ID:         improvise-8zh
+     :TYPE:       task
+     :PRIORITY:   P2
+     :STATUS:     open
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:23
+     :UPDATED:    2026-04-14 06:35:23
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+***** Acceptance Criteria
+         (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+***** Notes
+         Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature:
+   :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 (epic) [0/2] :epic: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:       epic
+     :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+       :PROPERTIES:
+       :ID:         improvise-8zh
+       :TYPE:       task
+       :PRIORITY:   P2
+       :STATUS:     open
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:23
+       :UPDATED:    2026-04-14 06:35:23
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+****** Acceptance Criteria
+           (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+****** Notes
+           Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+     :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
+     :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)       :standalone:P2:feature:@Edward_Langley:
+       :PROPERTIES:
+       :ID:         improvise-vb4
+       :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+       :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+         :PROPERTIES:
+         :ID:         improvise-8zh
+         :TYPE:       task
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:23
+         :UPDATED:    2026-04-14 06:35:23
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******* Acceptance Criteria
+             (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******* Notes
+             Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+     :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) [0/3] :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-14 07:54:16
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+****** Design
+           AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+****** Acceptance Criteria
+           (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+****** Notes
+           IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+         :PROPERTIES:
+         :ID:         improvise-45v
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:36:11
+         :UPDATED:    2026-04-14 06:36:11
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******* Design
+             Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******* Acceptance Criteria
+             (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******* Notes
+             Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Standalone web MVP: end-to-end offline demo (epic) [0/112] :epic:P2:feature:
+   :PROPERTIES:
+   :ID:         improvise-bck
+   :TYPE:       feature
+   :PRIORITY:   P2
+   :STATUS:     open
+   :OWNER:      el-github@elangley.org
+   :CREATED_BY: spot
+   :CREATED:    2026-04-14 07:34:38
+   :UPDATED:    2026-04-14 07:39:37
+   :KIND:       epic
+   :END:
+*** Details
+**** Description
+       Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.
+**** Design
+       Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).
+**** Acceptance Criteria
+       (1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.
+**** Notes
+       Terminal node of standalone epic (improvise-tm6). Depends on improvise-djm (main-thread), the worker-server issue, improvise-d31 (static shell + deploy), and all upstream prerequisites transitively.
+
+*** TODO Worker-hosted server bundle (wasm, MessageChannel transport) (epic) [0/22] :epic:P2:feature:
+     :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 (epic) [0/2] :epic: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:       epic
+       :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+         :PROPERTIES:
+         :ID:         improvise-8zh
+         :TYPE:       task
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:23
+         :UPDATED:    2026-04-14 06:35:23
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******* Acceptance Criteria
+             (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******* Notes
+             Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+       :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+         :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+       :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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** TODO Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :epic:P2:feature:
+     :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) [0/22] :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 (epic) [0/2] :epic: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:       epic
+         :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+           :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+         :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) [0/3] :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-14 07:54:16
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******** Design
+               AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******** Acceptance Criteria
+               (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******** Notes
+               IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+             :PROPERTIES:
+             :ID:         improvise-45v
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:36:11
+             :UPDATED:    2026-04-14 06:36:11
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********* Design
+                 Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********* Acceptance Criteria
+                 (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********* Notes
+                 Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature:
+       :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<String> (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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******** Design
+               AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******** Acceptance Criteria
+               (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******** Notes
+               IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+             :PROPERTIES:
+             :ID:         improvise-45v
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:36:11
+             :UPDATED:    2026-04-14 06:36:11
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********* Design
+                 Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********* Acceptance Criteria
+                 (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********* Notes
+                 Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+           :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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) [0/44] :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) [0/43] :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) [0/22] :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 (epic) [0/2] :epic: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:       epic
+           :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+           :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+             :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)   :standalone:P2:feature:@Edward_Langley:
+               :PROPERTIES:
+               :ID:         improvise-vb4
+               :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+             :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+           :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) [0/3] :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-14 07:54:16
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+********* Design
+                 AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+********* Acceptance Criteria
+                 (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+********* Notes
+                 IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+               :PROPERTIES:
+               :ID:         improvise-45v
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:36:11
+               :UPDATED:    2026-04-14 06:36:11
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********** Design
+                   Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********** Acceptance Criteria
+                   (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********** Notes
+                   Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+********* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+                 :PROPERTIES:
+                 :ID:         improvise-8zh
+                 :TYPE:       task
+                 :PRIORITY:   P2
+                 :STATUS:     open
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:23
+                 :UPDATED:    2026-04-14 06:35:23
+                 :KIND:       epic
+                 :END:
+********** Details
+*********** Description
+                     Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+*********** Acceptance Criteria
+                     (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+*********** Notes
+                     Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)  :standalone:P2:feature:@spot:
+                   :PROPERTIES:
+                   :ID:         improvise-36h
+                   :TYPE:       feature
+                   :PRIORITY:   P2
+                   :STATUS:     in_progress
+                   :ASSIGNEE:   spot
+                   :OWNER:      el-github@elangley.org
+                   :CREATED_BY: spot
+                   :CREATED:    2026-04-14 06:35:05
+                   :UPDATED:    2026-04-15 10:10:14
+                   :KIND:       standalone
+                   :DONE_DEPS:  improvise-ewi
+                   :END:
+*********** Details
+************ Description
+                       Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+************ Design
+                       Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+************ Acceptance Criteria
+                       (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+************ Notes
+                       This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature:
+         :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<String> (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) [0/3] :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-14 07:54:16
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******** Design
+               AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******** Acceptance Criteria
+               (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******** Notes
+               IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+             :PROPERTIES:
+             :ID:         improvise-45v
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:36:11
+             :UPDATED:    2026-04-14 06:36:11
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********* Design
+                 Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********* Acceptance Criteria
+                 (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********* Notes
+                 Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+           :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+********* Design
+                 AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+********* Acceptance Criteria
+                 (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+********* Notes
+                 IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+               :PROPERTIES:
+               :ID:         improvise-45v
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:36:11
+               :UPDATED:    2026-04-14 06:36:11
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********** Design
+                   Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********** Acceptance Criteria
+                   (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********** Notes
+                   Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+********* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+                 :PROPERTIES:
+                 :ID:         improvise-8zh
+                 :TYPE:       task
+                 :PRIORITY:   P2
+                 :STATUS:     open
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:23
+                 :UPDATED:    2026-04-14 06:35:23
+                 :KIND:       epic
+                 :END:
+********** Details
+*********** Description
+                     Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+*********** Acceptance Criteria
+                     (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+*********** Notes
+                     Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)  :standalone:P2:feature:@spot:
+                   :PROPERTIES:
+                   :ID:         improvise-36h
+                   :TYPE:       feature
+                   :PRIORITY:   P2
+                   :STATUS:     in_progress
+                   :ASSIGNEE:   spot
+                   :OWNER:      el-github@elangley.org
+                   :CREATED_BY: spot
+                   :CREATED:    2026-04-14 06:35:05
+                   :UPDATED:    2026-04-15 10:10:14
+                   :KIND:       standalone
+                   :DONE_DEPS:  improvise-ewi
+                   :END:
+*********** Details
+************ Description
+                       Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+************ Design
+                       Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+************ Acceptance Criteria
+                       (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+************ Notes
+                       This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+             :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
+             :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)   :standalone:P2:feature:@Edward_Langley:
+               :PROPERTIES:
+               :ID:         improvise-vb4
+               :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<PathBuf>, 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 Standalone main-thread bundle: wasm-client + worker transport (epic) [0/43] :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) [0/22] :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 (epic) [0/2] :epic: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:       epic
+       :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+         :PROPERTIES:
+         :ID:         improvise-8zh
+         :TYPE:       task
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:23
+         :UPDATED:    2026-04-14 06:35:23
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******* Acceptance Criteria
+             (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******* Notes
+             Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+       :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+         :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+       :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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+*** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature:
+     :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<String> (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) [0/3] :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-14 07:54:16
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+****** Design
+           AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+****** Acceptance Criteria
+           (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+****** Notes
+           IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+***** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+         :PROPERTIES:
+         :ID:         improvise-45v
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:36:11
+         :UPDATED:    2026-04-14 06:36:11
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******* Design
+             Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******* Acceptance Criteria
+             (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******* Notes
+             Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+****** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+       :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+     :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+       :PROPERTIES:
+       :ID:         improvise-8zh
+       :TYPE:       task
+       :PRIORITY:   P2
+       :STATUS:     open
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:23
+       :UPDATED:    2026-04-14 06:35:23
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+****** Acceptance Criteria
+           (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+****** Notes
+           Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+   :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) [0/3] :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-14 07:54:16
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+***** Design
+         AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+***** Acceptance Criteria
+         (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+***** Notes
+         IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+**** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+       :PROPERTIES:
+       :ID:         improvise-45v
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     open
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:36:11
+       :UPDATED:    2026-04-14 06:36:11
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+****** Design
+           Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+****** Acceptance Criteria
+           (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+****** Notes
+           Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+***** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+         :PROPERTIES:
+         :ID:         improvise-8zh
+         :TYPE:       task
+         :PRIORITY:   P2
+         :STATUS:     open
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:23
+         :UPDATED:    2026-04-14 06:35:23
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******* Acceptance Criteria
+             (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******* Notes
+             Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+****** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Static HTML shell + GitHub Pages deploy workflow (epic) [0/44] :epic:P3:task:
+   :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) [0/43] :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) [0/22] :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 (epic) [0/2] :epic: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:       epic
+         :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+           :PROPERTIES:
+           :ID:         improvise-8zh
+           :TYPE:       task
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:23
+           :UPDATED:    2026-04-14 06:35:23
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+******** Acceptance Criteria
+               (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+******** Notes
+               Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3] :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 (epic) [0/2] :epic: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:       epic
+           :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Wasm compatibility audit and fixes across core/formula/command/io (epic) [0/4] :epic:P2:task:
+         :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) [0/3] :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-14 07:54:16
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******** Design
+               AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******** Acceptance Criteria
+               (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******** Notes
+               IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+             :PROPERTIES:
+             :ID:         improvise-45v
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:36:11
+             :UPDATED:    2026-04-14 06:36:11
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********* Design
+                 Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********* Acceptance Criteria
+                 (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********* Notes
+                 Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** TODO improvise-wasm-client crate (keymap + reduce_view in wasm) (epic) [0/7] :epic:P2:feature:
+       :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<String> (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) [0/3] :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-14 07:54:16
+         :KIND:       epic
+         :END:
+****** Details
+******* Description
+             Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******* Design
+             AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******* Acceptance Criteria
+             (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******* Notes
+             IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+****** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+           :PROPERTIES:
+           :ID:         improvise-45v
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     open
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:36:11
+           :UPDATED:    2026-04-14 06:36:11
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+******** Design
+               Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+******** Acceptance Criteria
+               (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+******** Notes
+               Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******* TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+             :PROPERTIES:
+             :ID:         improvise-8zh
+             :TYPE:       task
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:23
+             :UPDATED:    2026-04-14 06:35:23
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********* Acceptance Criteria
+                 (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********* Notes
+                 Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+******** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)    :standalone:P2:feature:@spot:
+               :PROPERTIES:
+               :ID:         improvise-36h
+               :TYPE:       feature
+               :PRIORITY:   P2
+               :STATUS:     in_progress
+               :ASSIGNEE:   spot
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:05
+               :UPDATED:    2026-04-15 10:10:14
+               :KIND:       standalone
+               :DONE_DEPS:  improvise-ewi
+               :END:
+********* Details
+********** Description
+                   Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********** Design
+                   Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********** Acceptance Criteria
+                   (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********** Notes
+                   This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+         :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
+         :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)      :standalone:P2:feature:@spot:
+           :PROPERTIES:
+           :ID:         improvise-36h
+           :TYPE:       feature
+           :PRIORITY:   P2
+           :STATUS:     in_progress
+           :ASSIGNEE:   spot
+           :OWNER:      el-github@elangley.org
+           :CREATED_BY: spot
+           :CREATED:    2026-04-14 06:35:05
+           :UPDATED:    2026-04-15 10:10:14
+           :KIND:       standalone
+           :DONE_DEPS:  improvise-ewi
+           :END:
+******* Details
+******** Description
+               Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******** Design
+               Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******** Acceptance Criteria
+               (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******** Notes
+               This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)     :standalone:P2:feature:@Edward_Langley:
+           :PROPERTIES:
+           :ID:         improvise-vb4
+           :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<PathBuf>, 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) [0/11] :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 <table> 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 <table> for the grid body, <thead> for column labels, <tbody> with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected <td>. Mode indicator is a <div> above the table. Minibuffer is a <div> 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/7] :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<String> (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) [0/3] :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-14 07:54:16
+           :KIND:       epic
+           :END:
+******* Details
+******** Description
+               Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+******** Design
+               AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+******** Acceptance Criteria
+               (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+******** Notes
+               IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+******* TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+             :PROPERTIES:
+             :ID:         improvise-45v
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     open
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:36:11
+             :UPDATED:    2026-04-14 06:36:11
+             :KIND:       epic
+             :END:
+******** Details
+********* Description
+                 Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+********* Design
+                 Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+********* Acceptance Criteria
+                 (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+********* Notes
+                 Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+******** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+               :PROPERTIES:
+               :ID:         improvise-8zh
+               :TYPE:       task
+               :PRIORITY:   P2
+               :STATUS:     open
+               :OWNER:      el-github@elangley.org
+               :CREATED_BY: spot
+               :CREATED:    2026-04-14 06:35:23
+               :UPDATED:    2026-04-14 06:35:23
+               :KIND:       epic
+               :END:
+********* Details
+********** Description
+                   Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+********** Acceptance Criteria
+                   (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+********** Notes
+                   Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+********* DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)   :standalone:P2:feature:@spot:
+                 :PROPERTIES:
+                 :ID:         improvise-36h
+                 :TYPE:       feature
+                 :PRIORITY:   P2
+                 :STATUS:     in_progress
+                 :ASSIGNEE:   spot
+                 :OWNER:      el-github@elangley.org
+                 :CREATED_BY: spot
+                 :CREATED:    2026-04-14 06:35:05
+                 :UPDATED:    2026-04-15 10:10:14
+                 :KIND:       standalone
+                 :DONE_DEPS:  improvise-ewi
+                 :END:
+********** Details
+*********** Description
+                     Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+*********** Design
+                     Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+*********** Acceptance Criteria
+                     (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+*********** Notes
+                     This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+****** TODO Extract improvise-protocol crate (Command, ViewState, reduce_view) (epic) [0/2] :epic:P2:feature:
+           :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
+           :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)     :standalone:P2:feature:@spot:
+             :PROPERTIES:
+             :ID:         improvise-36h
+             :TYPE:       feature
+             :PRIORITY:   P2
+             :STATUS:     in_progress
+             :ASSIGNEE:   spot
+             :OWNER:      el-github@elangley.org
+             :CREATED_BY: spot
+             :CREATED:    2026-04-14 06:35:05
+             :UPDATED:    2026-04-15 10:10:14
+             :KIND:       standalone
+             :DONE_DEPS:  improvise-ewi
+             :END:
+******** Details
+********* Description
+                 Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+********* Design
+                 Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+********* Acceptance Criteria
+                 (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+********* Notes
+                 This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+******* DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)    :standalone:P2:feature:@Edward_Langley:
+             :PROPERTIES:
+             :ID:         improvise-vb4
+             :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<PathBuf>, 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 Epic: Split improvise into enforced-boundary workspace crates (epic) [0/10] :epic:P2:feature:
+ :PROPERTIES:
+ :ID:         improvise-xgl
+ :TYPE:       feature
+ :PRIORITY:   P2
+ :STATUS:     open
+ :OWNER:      el-github@elangley.org
+ :CREATED_BY: spot
+ :CREATED:    2026-04-14 06:33:09
+ :UPDATED:    2026-04-14 06:33:09
+ :KIND:       epic
+ :DONE_DEPS:  improvise-ewi
+ :END:
+** Details
+*** Description
+     Convert the single improvise crate into a Cargo workspace of 5 crates so that module boundaries become compile-enforced rather than convention. Today nothing stops model/types.rs from reaching into ui::app; the goal is to make that a compile error. Target shape: improvise-formula (leaf) → improvise-core (model+view+format) → improvise-io (persistence+import) → improvise-command → improvise-tui (bin). See child issues for sequenced steps. The expensive work is breaking the Model↔View cycle and decoupling Effect from &mut App — those are features, not costs.
+*** Design
+     Target crate graph: improvise-formula (no deps) ← improvise-core (model+view+format) ← improvise-io (persistence+import) ← improvise-command ← improvise-tui. Two structural obstacles: (1) Model owns views: IndexMap<String,View>, creating a model↔view cycle — fix by moving views out into a Workbook wrapper. (2) Effect::apply takes &mut App, so command transitively depends on App — fix by converting Effect to an enum with apply(app, effect) in the tui crate. Prefer the enum over a trait-based EffectTarget: effects become loggable/replayable (already gestured at in design-principles §1) and it matches the existing enum-heavy style (Binding, Axis, CategoryKind, BinOp).
+*** Notes
+     Order matters: do the formula extraction first as a warm-up, then core, then io, then the Effect enum conversion in-place (no crate change), then finally the command/tui split. Steps 1-3 are mostly mechanical; step 4 is the real semantic work; step 5 should mostly just work after 4 lands.
+
+** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)          :standalone:P2:feature:@spot:
+   :PROPERTIES:
+   :ID:         improvise-36h
+   :TYPE:       feature
+   :PRIORITY:   P2
+   :STATUS:     in_progress
+   :ASSIGNEE:   spot
+   :OWNER:      el-github@elangley.org
+   :CREATED_BY: spot
+   :CREATED:    2026-04-14 06:35:05
+   :UPDATED:    2026-04-15 10:10:14
+   :KIND:       standalone
+   :DONE_DEPS:  improvise-ewi
+   :END:
+*** Details
+**** Description
+       Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+**** Design
+       Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+**** Acceptance Criteria
+       (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+**** Notes
+       This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Step 5: Extract improvise-command crate below improvise-tui (epic) [0/3] :epic:P2:feature:
+   :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-14 07:54:16
+   :KIND:       epic
+   :END:
+*** Details
+**** Description
+       Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.
+**** Design
+       AppMode and ModeKey must move down into improvise-command (both are pure enums used by commands for dispatch). One complication: AppMode::Editing { minibuf: MinibufferConfig } embeds ui-layer state in a mode enum. Decide: (a) pull MinibufferConfig down into command (if it's pure data, probably fine), or (b) split AppMode into a lean 'dispatch mode' enum in command and a richer per-mode state struct in tui. Recommendation: (a) if MinibufferConfig is pure data, (b) if it touches rendering. Also move command/keymap.rs (it imports AppMode and Effect — both will be in this crate).
+**** Acceptance Criteria
+       (1) crates/improvise-command/ contains command/ plus AppMode, ModeKey. (2) No use crate::ui::* imports in improvise-command. (3) improvise-tui depends on improvise-command and contains ui/, draw.rs, main.rs. (4) cargo check in improvise-command succeeds without compiling ratatui or crossterm. (5) All tests pass. (6) cargo test -p improvise-command runs command/effect tests in isolation.
+**** Notes
+       IMPORTANT UPDATE: App (or the equivalent server-side state struct) must live in improvise-command, NOT in improvise-tui. This is because the unified reduce_full(&mut App, &Command) function (improvise-gxi) needs to be imported by ws-server and worker-server — neither of which should pull in ratatui. If App stays in improvise-tui, reduce_full can only live in improvise-tui, which defeats the whole unified-reducer plan. Therefore: improvise-command hosts App + reduce_full + CmdRegistry + AppMode + Keymap + Cmd trait. improvise-tui becomes a thin rendering crate that depends on improvise-command and renders &App (or after migration, renders from ViewModels per improvise-edp). Complication: App currently holds wizard: Option<ImportWizard>. ImportWizard must either move to improvise-command (likely correct since it's session state) or be held behind an opaque handle. Resolve at implementation time.
+
+*** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+     :PROPERTIES:
+     :ID:         improvise-45v
+     :TYPE:       feature
+     :PRIORITY:   P2
+     :STATUS:     open
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:36:11
+     :UPDATED:    2026-04-14 06:36:11
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+***** Design
+         Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+***** Acceptance Criteria
+         (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+***** Notes
+         Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+**** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+       :PROPERTIES:
+       :ID:         improvise-8zh
+       :TYPE:       task
+       :PRIORITY:   P2
+       :STATUS:     open
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:23
+       :UPDATED:    2026-04-14 06:35:23
+       :KIND:       epic
+       :END:
+***** Details
+****** Description
+           Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+****** Acceptance Criteria
+           (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+****** Notes
+           Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+***** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Step 4: Convert Effect trait to an enum (in-place, no crate change) (epic) [0/2] :epic:P2:feature:
+   :PROPERTIES:
+   :ID:         improvise-45v
+   :TYPE:       feature
+   :PRIORITY:   P2
+   :STATUS:     open
+   :OWNER:      el-github@elangley.org
+   :CREATED_BY: spot
+   :CREATED:    2026-04-14 06:36:11
+   :UPDATED:    2026-04-14 06:36:11
+   :KIND:       epic
+   :END:
+*** Details
+**** Description
+       Refactor Effect from a trait (Box<dyn Effect> with apply(&self, &mut App)) into a data enum, with a single apply(app: &mut App, effect: &Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.
+**** Design
+       Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: &mut App, e: &Effect) function that matches exhaustively. Commands still return Vec<Effect> (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.
+**** Acceptance Criteria
+       (1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec<Box<dyn Effect>> is replaced with Vec<Effect> in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.
+**** Notes
+       Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.
+
+*** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+     :PROPERTIES:
+     :ID:         improvise-8zh
+     :TYPE:       task
+     :PRIORITY:   P2
+     :STATUS:     open
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:23
+     :UPDATED:    2026-04-14 06:35:23
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+***** Acceptance Criteria
+         (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+***** Notes
+         Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+** TODO Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+   :PROPERTIES:
+   :ID:         improvise-8zh
+   :TYPE:       task
+   :PRIORITY:   P2
+   :STATUS:     open
+   :OWNER:      el-github@elangley.org
+   :CREATED_BY: spot
+   :CREATED:    2026-04-14 06:35:23
+   :UPDATED:    2026-04-14 06:35:23
+   :KIND:       epic
+   :END:
+*** Details
+**** Description
+       Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+**** Acceptance Criteria
+       (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+**** Notes
+       Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+*** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)         :standalone:P2:feature:@spot:
+     :PROPERTIES:
+     :ID:         improvise-36h
+     :TYPE:       feature
+     :PRIORITY:   P2
+     :STATUS:     in_progress
+     :ASSIGNEE:   spot
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:05
+     :UPDATED:    2026-04-15 10:10:14
+     :KIND:       standalone
+     :DONE_DEPS:  improvise-ewi
+     :END:
+**** Details
+***** Description
+         Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+***** Design
+         Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+***** Acceptance Criteria
+         (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+***** Notes
+         This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+* TODO Phase 3: Distribution (epic) [0/3]                   :epic:P3:feature:
+ :PROPERTIES:
+ :ID:         improvise-0s6
+ :TYPE:       feature
+ :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
+ :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).
+
+** TODO 3.3 Tag v0.1.0 release (epic) [0/1]                    :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) [0/3] :epic:P3:feature:
+ :PROPERTIES:
+ :ID:         improvise-9ix
+ :TYPE:       feature
+ :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
+ :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).
+
+** TODO Introduce Storage/VFS abstraction in persistence layer (epic) [0/2] :epic: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:       epic
+   :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+     :PROPERTIES:
+     :ID:         improvise-8zh
+     :TYPE:       task
+     :PRIORITY:   P2
+     :STATUS:     open
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:23
+     :UPDATED:    2026-04-14 06:35:23
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+***** Acceptance Criteria
+         (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+***** Notes
+         Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+* TODO (Stretch) Command log + native undo/replay (epic) [0/1] :epic:P3:feature:
+ :PROPERTIES:
+ :ID:         improvise-m91
+ :TYPE:       feature
+ :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
+ :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.
+
+** TODO Define unified reduce_full(&mut App, &Command) -> Vec<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/31] :epic:P3:feature:
+ :PROPERTIES:
+ :ID:         improvise-uq7
+ :TYPE:       feature
+ :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
+ :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) [0/19] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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<u16>, pub row_labels: Vec<String>, pub col_labels: Vec<String>, pub cells: Vec<Vec<CellDisplay>>, pub cursor: (usize, usize), pub highlights: Vec<Highlight>, 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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/3] :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) [0/2] :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) [0/1] :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<GridCache>, category_tree: Option<CategoryTreeCache>, formulas: Option<FormulaListCache>, views: Option<ViewListCache>, tile_bar: Option<TileBarCache>, wizard: Option<WizardCache>, help: Option<HelpCache>, ... }. 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/10] :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) [0/2] :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
+     :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+**** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)       :standalone:P2:feature:@Edward_Langley:
+       :PROPERTIES:
+       :ID:         improvise-vb4
+       :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<PathBuf>, 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) [0/6] :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<SessionId, ClientSession> 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) [0/2] :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
+       :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<String> }, ColumnWidthsChanged { widths: Vec<u16> }, RowLabelsChanged { labels: Vec<String> }, 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 Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)       :standalone:P2:feature:@spot:
+         :PROPERTIES:
+         :ID:         improvise-36h
+         :TYPE:       feature
+         :PRIORITY:   P2
+         :STATUS:     in_progress
+         :ASSIGNEE:   spot
+         :OWNER:      el-github@elangley.org
+         :CREATED_BY: spot
+         :CREATED:    2026-04-14 06:35:05
+         :UPDATED:    2026-04-15 10:10:14
+         :KIND:       standalone
+         :DONE_DEPS:  improvise-ewi
+         :END:
+****** Details
+******* Description
+             Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+******* Design
+             Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+******* Acceptance Criteria
+             (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+******* Notes
+             This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+***** DOING Split AppState into ModelState + ViewState (standalone refactor) (standalone)      :standalone:P2:feature:@Edward_Langley:
+         :PROPERTIES:
+         :ID:         improvise-vb4
+         :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<PathBuf>, 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<Command> (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<Command>. 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<dyn Cmd> trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: &Command, ctx: &CmdContext) -> Vec<Effect>' 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) [0/1] :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<PathBuf>, 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) [0/3]     :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 (epic) [0/2] :epic: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:       epic
+   :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 Step 3: Extract improvise-io crate (persistence + import) (epic) [0/1] :epic:P2:task:
+     :PROPERTIES:
+     :ID:         improvise-8zh
+     :TYPE:       task
+     :PRIORITY:   P2
+     :STATUS:     open
+     :OWNER:      el-github@elangley.org
+     :CREATED_BY: spot
+     :CREATED:    2026-04-14 06:35:23
+     :UPDATED:    2026-04-14 06:35:23
+     :KIND:       epic
+     :END:
+**** Details
+***** Description
+         Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.
+***** Acceptance Criteria
+         (1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.
+***** Notes
+         Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.
+
+**** DOING Step 2: Break Model↔View cycle and extract improvise-core crate (standalone)        :standalone:P2:feature:@spot:
+       :PROPERTIES:
+       :ID:         improvise-36h
+       :TYPE:       feature
+       :PRIORITY:   P2
+       :STATUS:     in_progress
+       :ASSIGNEE:   spot
+       :OWNER:      el-github@elangley.org
+       :CREATED_BY: spot
+       :CREATED:    2026-04-14 06:35:05
+       :UPDATED:    2026-04-15 10:10:14
+       :KIND:       standalone
+       :DONE_DEPS:  improvise-ewi
+       :END:
+***** Details
+****** Description
+           Extract model/ + view/ + format.rs into an improvise-core crate. The blocker is the Model↔View cycle: Model currently owns views: IndexMap<String,View>, and view/layout.rs reads &Model. Fix by lifting views out of Model into a Workbook (or similar) wrapper that holds both. GridLayout::new already takes Model and View separately, so most call sites are already shaped correctly — the work is in Model's methods and fields.
+****** Design
+           Introduce pub struct Workbook { pub model: Model, pub views: IndexMap<String, View>, pub active_view: String, pub measure_agg: HashMap<String, AggFunc> }. Model keeps categories, data, formulas — pure data model. Methods currently on Model that touch views (recompute_formulas with none_cats from active view, etc.) either move to Workbook or take a &View parameter. Audit: Model::views, Model::active_view, Model::on_category_added, any method that consults the active view.
+****** Acceptance Criteria
+           (1) Model no longer contains View data. (2) improvise-core crate compiles with only improvise-formula as a local dep. (3) View depends on Model (one direction), no cycle. (4) All existing tests pass. (5) The .improv file format loads and saves identically (persistence round-trip tests still pass).
+****** Notes
+           This is the first non-mechanical step. Expect ripple effects through command/ and ui/app.rs since they currently call model.active_view(), model.set_axis(), etc. Those become workbook.* calls. Keep the changes behavior-preserving.
+
+* TODO Phase 4: Landing page (optional) (epic) [0/3]        :epic:P4:feature:
+ :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) [0/1]                   :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.
diff --git a/scripts/gen_roadmap.py b/scripts/gen_roadmap.py
new file mode 100755
index 0000000..f7cc3cd
--- /dev/null
+++ b/scripts/gen_roadmap.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Generate roadmap.org from bd.
+
+Inverted-tree layout: each tree is rooted at an issue that nothing else
+(among the open set) depends on — the "epic" or standalone goal. Its
+children are the things blocking it, recursively. Diamonds in the DAG
+are broken into a tree by duplicating shared deps under each parent so
+emacs `[/]` cookies count whole subtrees.
+
+Usage: python3 scripts/gen_roadmap.py [output-path]
+       (default output: roadmap.org at the repo root)
+"""
+import json
+import re
+import subprocess
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+
+def bd_json(*args):
+    """Run `bd <args>` and parse JSON from stdout."""
+    cmd = ['bd', *args, '--json']
+    res = subprocess.run(cmd, check=True, capture_output=True, text=True)
+    return json.loads(res.stdout)
+
+
+def main():
+    repo = Path(__file__).resolve().parent.parent
+    out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else repo / 'roadmap.org'
+
+    issues = bd_json('list')
+    by_id = {i['id']: i for i in issues}
+    # `bd list --all` includes closed issues — used only to resolve titles
+    # of already-done dep targets so we can label them in :DONE_DEPS:.
+    all_issues = bd_json('list', '--all')
+    all_by_id = {i['id']: i for i in all_issues}
+
+    # --- Build dep graph (open-issue subgraph only) ----------------------
+    # child depends_on parent; in our inverted tree, parent (the goal/epic)
+    # sits ABOVE its deps (children).
+    tree_children = defaultdict(set)
+    tree_parents = defaultdict(set)
+    for i in issues:
+        iid = i['id']
+        for d in (i.get('dependencies') or []):
+            dep = d['depends_on_id']
+            if dep in by_id and iid in by_id:
+                tree_children[iid].add(dep)
+                tree_parents[dep].add(iid)
+
+    def order_key(iid):
+        i = by_id[iid]
+        return (
+            0 if i['status'] == 'in_progress' else 1,
+            i['priority'],
+            iid,
+        )
+
+    roots = sorted(
+        [iid for iid in by_id if not tree_parents.get(iid)],
+        key=order_key,
+    )
+
+    # --- Helpers ---------------------------------------------------------
+    status_kw = {
+        'open': 'TODO',
+        'in_progress': 'DOING',
+        'closed': 'DONE',
+        'blocked': 'WAIT',
+        'deferred': 'WAIT',
+    }
+
+    def tag(s):
+        return re.sub(r'[^A-Za-z0-9_@#%]', '_', s)
+
+    def headline_tags(i, kind):
+        tags = [kind, f"P{i['priority']}", tag(i['issue_type'])]
+        if i.get('assignee'):
+            tags.append('@' + tag(i['assignee']))
+        return ':' + ':'.join(tags) + ':'
+
+    def fmt_date(s):
+        return s.replace('T', ' ').rstrip('Z') if s else ''
+
+    def kind_of(iid):
+        return 'epic' if tree_children.get(iid) else 'standalone'
+
+    out = []
+    out.append('#+TITLE: Improvise Roadmap')
+    out.append('#+AUTHOR: Edward Langley')
+    out.append('#+TODO: TODO DOING WAIT | DONE')
+    out.append('#+STARTUP: overview')
+    out.append('#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug')
+    out.append('#+PROPERTY: COOKIE_DATA todo recursive')
+    out.append('')
+    out.append(
+        f'Generated from ~bd list --json~. {len(issues)} open issues '
+        f'organised as {len(roots)} 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.'
+    )
+    out.append('')
+
+    def emit(iid, depth, path):
+        i = by_id[iid]
+        kind = kind_of(iid)
+        kw = status_kw.get(i['status'], 'TODO')
+        title = i['title'].replace('[', '(').replace(']', ')')
+        stars = '*' * depth
+        cookie = ' [/]' if tree_children.get(iid) else ''
+        head = f'{stars} {kw} {title} ({kind}){cookie}'
+        pad = max(1, 95 - len(head))
+        out.append(f'{head}{" " * pad}{headline_tags(i, kind)}')
+
+        indent = '  ' * (depth - 1) + ' '
+        out.append(f'{indent}:PROPERTIES:')
+        out.append(f'{indent}:ID:         {iid}')
+        out.append(f'{indent}:TYPE:       {i["issue_type"]}')
+        out.append(f'{indent}:PRIORITY:   P{i["priority"]}')
+        out.append(f'{indent}:STATUS:     {i["status"]}')
+        if i.get('assignee'):
+            out.append(f'{indent}:ASSIGNEE:   {i["assignee"]}')
+        if i.get('owner'):
+            out.append(f'{indent}:OWNER:      {i["owner"]}')
+        if i.get('created_by'):
+            out.append(f'{indent}:CREATED_BY: {i["created_by"]}')
+        out.append(f'{indent}:CREATED:    {fmt_date(i["created_at"])}')
+        out.append(f'{indent}:UPDATED:    {fmt_date(i["updated_at"])}')
+        if i.get('comment_count'):
+            out.append(f'{indent}:COMMENTS:   {i["comment_count"]}')
+        out.append(f'{indent}:KIND:       {kind}')
+        closed_deps = sorted(
+            d['depends_on_id']
+            for d in (i.get('dependencies') or [])
+            if (ref := all_by_id.get(d['depends_on_id'])) and ref['status'] == 'closed'
+        )
+        if closed_deps:
+            out.append(f'{indent}:DONE_DEPS:  {", ".join(closed_deps)}')
+        if iid in path:
+            out.append(f'{indent}:CYCLE:      yes — descendants pruned at this node')
+        out.append(f'{indent}:END:')
+
+        if iid in path:
+            return  # cycle guard
+
+        details_stars = '*' * (depth + 1)
+        section_stars = '*' * (depth + 2)
+        section_indent = '  ' * (depth + 1) + ' '
+        if any(i.get(k) for k in ('description', 'design', 'acceptance_criteria', 'notes')):
+            out.append(f'{details_stars} Details')
+            for label, key in [
+                ('Description', 'description'),
+                ('Design', 'design'),
+                ('Acceptance Criteria', 'acceptance_criteria'),
+                ('Notes', 'notes'),
+            ]:
+                v = i.get(key)
+                if not v:
+                    continue
+                out.append(f'{section_stars} {label}')
+                for line in v.rstrip('\n').splitlines():
+                    out.append(f'{section_indent}{line}' if line else '')
+
+        new_path = path | {iid}
+        for c in sorted(tree_children.get(iid, set()), key=order_key):
+            out.append('')
+            emit(c, depth + 1, new_path)
+
+    for r in roots:
+        emit(r, 1, frozenset())
+        out.append('')
+
+    out_path.write_text('\n'.join(out))
+    print(f'Wrote {out_path} ({len(out)} lines, {len(roots)} roots, {len(by_id)} issues)')
+
+
+if __name__ == '__main__':
+    main()
diff --git a/src/command/cmd/commit.rs b/src/command/cmd/commit.rs
index 1b166cf..69ad10a 100644
--- a/src/command/cmd/commit.rs
+++ b/src/command/cmd/commit.rs
@@ -118,6 +118,35 @@ mod tests {
         assert!(effects.is_empty());
     }
 
+    /// `CommitAndAdvance` must thread its `edit_mode` through to the
+    /// trailing `EnterEditAtCursor` effect so the post-commit re-edit lands
+    /// in the mode the keymap requested. The command never reads ctx.mode.
+    #[test]
+    fn commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor() {
+        let m = two_cat_model();
+        let layout = make_layout(&m);
+        let reg = make_registry();
+        let mut bufs = HashMap::new();
+        bufs.insert("edit".to_string(), "42".to_string());
+        let mut ctx = make_ctx(&m, &layout, &reg);
+        ctx.buffers = &bufs;
+        // ctx.mode stays Normal — the command must not look at it.
+        let key = ctx.cell_key().unwrap();
+        let cmd = CommitAndAdvance {
+            key,
+            value: "42".to_string(),
+            advance: super::AdvanceDir::Down,
+            cursor: super::CursorState::from_ctx(&ctx),
+            edit_mode: AppMode::records_editing(),
+        };
+        let effects = cmd.execute(&ctx);
+        let dbg = effects_debug(&effects);
+        assert!(
+            dbg.contains("EnterEditAtCursor") && dbg.contains("RecordsEditing"),
+            "Expected trailing EnterEditAtCursor with RecordsEditing target, got: {dbg}"
+        );
+    }
+
     #[test]
     fn commit_export_produces_export_and_normal_mode() {
         let m = two_cat_model();
@@ -234,12 +263,18 @@ pub enum AdvanceDir {
 
 /// Commit a cell edit, advance the cursor, and re-enter edit mode.
 /// Subsumes the old `CommitCellEdit` (Down) and `CommitAndAdvanceRight` (Right).
+///
+/// `edit_mode` is the editing mode to re-enter after advancing. The keymap
+/// binding supplies this — the editing-mode keymap passes `editing` and the
+/// records-editing keymap passes `records-editing`. The command itself
+/// never inspects `ctx.mode`.
 #[derive(Debug)]
 pub struct CommitAndAdvance {
     pub key: CellKey,
     pub value: String,
     pub advance: AdvanceDir,
     pub cursor: CursorState,
+    pub edit_mode: AppMode,
 }
 impl Cmd for CommitAndAdvance {
     fn name(&self) -> &'static str {
@@ -287,7 +322,9 @@ impl Cmd for CommitAndAdvance {
                 }
             }
         }
-        effects.push(Box::new(effect::EnterEditAtCursor));
+        effects.push(Box::new(effect::EnterEditAtCursor {
+            target_mode: self.edit_mode.clone(),
+        }));
         effects
     }
 }
diff --git a/src/command/cmd/mode.rs b/src/command/cmd/mode.rs
index 4c900b0..c2f7245 100644
--- a/src/command/cmd/mode.rs
+++ b/src/command/cmd/mode.rs
@@ -10,21 +10,6 @@ mod tests {
     use crate::command::cmd::test_helpers::*;
     use crate::workbook::Workbook;
 
-    #[test]
-    fn enter_edit_mode_produces_editing_mode() {
-        let m = two_cat_model();
-        let layout = make_layout(&m);
-        let reg = make_registry();
-        let ctx = make_ctx(&m, &layout, &reg);
-        let cmd = EnterEditMode {
-            initial_value: String::new(),
-        };
-        let effects = cmd.execute(&ctx);
-        assert_eq!(effects.len(), 2);
-        let dbg = format!("{:?}", effects[1]);
-        assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
-    }
-
     #[test]
     fn enter_tile_select_with_categories() {
         let m = two_cat_model();
@@ -98,11 +83,80 @@ mod tests {
         let layout = make_layout(&m);
         let reg = make_registry();
         let ctx = make_ctx(&m, &layout, &reg);
-        let effects = EditOrDrill.execute(&ctx);
+        let effects = EditOrDrill {
+            edit_mode: AppMode::editing(),
+        }
+        .execute(&ctx);
+        assert_eq!(effects.len(), 2);
         let dbg = effects_debug(&effects);
         assert!(dbg.contains("Editing"), "Expected Editing mode, got: {dbg}");
     }
 
+    /// EditOrDrill must trust its `edit_mode` parameter rather than checking
+    /// `ctx.mode` — the records-normal keymap supplies `records-editing`,
+    /// but the command itself never inspects the runtime mode. This is the
+    /// parallel of the (deleted) `enter_edit_mode_produces_editing_mode`
+    /// test for the records branch.
+    #[test]
+    fn edit_or_drill_passes_records_editing_mode_through() {
+        let m = two_cat_model();
+        let layout = make_layout(&m);
+        let reg = make_registry();
+        // Note: ctx.mode is still Normal here — the command must not look at it.
+        let ctx = make_ctx(&m, &layout, &reg);
+        let effects = EditOrDrill {
+            edit_mode: AppMode::records_editing(),
+        }
+        .execute(&ctx);
+        assert_eq!(effects.len(), 2);
+        let dbg = effects_debug(&effects);
+        assert!(
+            dbg.contains("RecordsEditing"),
+            "Expected RecordsEditing mode, got: {dbg}"
+        );
+    }
+
+    /// `EnterEditAtCursorCmd` must hand its `target_mode` straight through
+    /// to the `EnterEditAtCursor` effect — the keymap (records `o` sequence
+    /// or commit-and-advance) decides; the command never inspects ctx.
+    #[test]
+    fn enter_edit_at_cursor_cmd_passes_target_mode_to_effect() {
+        let m = two_cat_model();
+        let layout = make_layout(&m);
+        let reg = make_registry();
+        let ctx = make_ctx(&m, &layout, &reg);
+        let effects = EnterEditAtCursorCmd {
+            target_mode: AppMode::records_editing(),
+        }
+        .execute(&ctx);
+        assert_eq!(effects.len(), 1);
+        let dbg = format!("{:?}", effects[0]);
+        assert!(
+            dbg.contains("RecordsEditing"),
+            "Expected RecordsEditing target_mode, got: {dbg}"
+        );
+    }
+
+    /// The edit branch pre-fills the `edit` buffer with the cell's current
+    /// display value so the user can modify rather than retype.
+    #[test]
+    fn edit_or_drill_pre_fills_edit_buffer_with_display_value() {
+        let m = two_cat_model();
+        let layout = make_layout(&m);
+        let reg = make_registry();
+        let mut ctx = make_ctx(&m, &layout, &reg);
+        ctx.display_value = "42".to_string();
+        let effects = EditOrDrill {
+            edit_mode: AppMode::editing(),
+        }
+        .execute(&ctx);
+        let dbg = effects_debug(&effects);
+        assert!(
+            dbg.contains("SetBuffer") && dbg.contains("\"edit\"") && dbg.contains("\"42\""),
+            "Expected SetBuffer(\"edit\", \"42\"), got: {dbg}"
+        );
+    }
+
     #[test]
     fn enter_search_mode_sets_flag_and_clears_query() {
         let m = two_cat_model();
@@ -188,36 +242,18 @@ impl Cmd for SaveAndQuit {
 
 // ── Editing entry ───────────────────────────────────────────────────────
 
-/// Enter editing mode with an initial buffer value.
-#[derive(Debug)]
-pub struct EnterEditMode {
-    pub initial_value: String,
-}
-impl Cmd for EnterEditMode {
-    fn name(&self) -> &'static str {
-        "enter-edit-mode"
-    }
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
-        let edit_mode = if ctx.mode.is_records() {
-            AppMode::records_editing()
-        } else {
-            AppMode::editing()
-        };
-        vec![
-            Box::new(effect::SetBuffer {
-                name: "edit".to_string(),
-                value: self.initial_value.clone(),
-            }),
-            effect::change_mode(edit_mode),
-        ]
-    }
-}
-
 /// Smart dispatch for i/a: if the cursor is on an aggregated pivot cell
-/// (categories on `Axis::None`, no records mode), drill into it instead of
-/// editing. Otherwise enter edit mode with the current displayed value.
+/// (categories on `Axis::None` and the cell is not a synthetic records-mode
+/// row), drill into it instead of editing. Otherwise pre-fill the edit
+/// buffer with the displayed cell value and enter `edit_mode`.
+///
+/// `edit_mode` is supplied by the keymap binding — the command itself is
+/// mode-agnostic, so the records-normal keymap passes `records-editing`
+/// while the normal keymap passes `editing`.
 #[derive(Debug)]
-pub struct EditOrDrill;
+pub struct EditOrDrill {
+    pub edit_mode: AppMode,
+}
 impl Cmd for EditOrDrill {
     fn name(&self) -> &'static str {
         "edit-or-drill"
@@ -232,7 +268,8 @@ impl Cmd for EditOrDrill {
                 .map(|cat| cat.kind.is_regular())
                 .unwrap_or(false)
         });
-        // In records mode (synthetic key), always edit directly — no drilling.
+        // Synthetic records-mode cells are never aggregated — edit directly.
+        // (This is a layout property, not a mode flag.)
         let is_synthetic = ctx.synthetic_record_at_cursor().is_some();
         let is_aggregated = !is_synthetic && regular_none;
         if is_aggregated {
@@ -241,23 +278,31 @@ impl Cmd for EditOrDrill {
             };
             return DrillIntoCell { key }.execute(ctx);
         }
-        EnterEditMode {
-            initial_value: ctx.display_value.clone(),
-        }
-        .execute(ctx)
+        vec![
+            Box::new(effect::SetBuffer {
+                name: "edit".to_string(),
+                value: ctx.display_value.clone(),
+            }),
+            effect::change_mode(self.edit_mode.clone()),
+        ]
     }
 }
 
 /// Thin command wrapper around the `EnterEditAtCursor` effect so it can
-/// participate in `Binding::Sequence`.
+/// participate in `Binding::Sequence`. `target_mode` is supplied as the
+/// command argument by the keymap binding.
 #[derive(Debug)]
-pub struct EnterEditAtCursorCmd;
+pub struct EnterEditAtCursorCmd {
+    pub target_mode: AppMode,
+}
 impl Cmd for EnterEditAtCursorCmd {
     fn name(&self) -> &'static str {
         "enter-edit-at-cursor"
     }
     fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
-        vec![Box::new(effect::EnterEditAtCursor)]
+        vec![Box::new(effect::EnterEditAtCursor {
+            target_mode: self.target_mode.clone(),
+        })]
     }
 }
 
diff --git a/src/command/cmd/registry.rs b/src/command/cmd/registry.rs
index bf726e0..a137f58 100644
--- a/src/command/cmd/registry.rs
+++ b/src/command/cmd/registry.rs
@@ -2,6 +2,27 @@ use crate::model::cell::CellKey;
 use crate::ui::app::AppMode;
 use crate::ui::effect::Panel;
 
+/// Decode a mode-name string (as supplied by `enter-mode`/`edit-or-drill`
+/// keymap bindings) into an `AppMode`.
+fn parse_mode_name(s: &str) -> Result<AppMode, String> {
+    match s {
+        "normal" => Ok(AppMode::Normal),
+        "help" => Ok(AppMode::Help),
+        "formula-panel" => Ok(AppMode::FormulaPanel),
+        "category-panel" => Ok(AppMode::CategoryPanel),
+        "view-panel" => Ok(AppMode::ViewPanel),
+        "tile-select" => Ok(AppMode::TileSelect),
+        "command" => Ok(AppMode::command_mode()),
+        "category-add" => Ok(AppMode::category_add()),
+        "editing" => Ok(AppMode::editing()),
+        "records-normal" => Ok(AppMode::RecordsNormal),
+        "records-editing" => Ok(AppMode::records_editing()),
+        "formula-edit" => Ok(AppMode::formula_edit()),
+        "export-prompt" => Ok(AppMode::export_prompt()),
+        other => Err(format!("Unknown mode: {other}")),
+    }
+}
+
 use super::cell::*;
 use super::commit::*;
 use super::core::*;
@@ -266,22 +287,16 @@ pub fn default_registry() -> CmdRegistry {
     r.register_nullary(|| Box::new(SaveAndQuit));
     r.register_nullary(|| Box::new(SaveCmd));
     r.register_nullary(|| Box::new(EnterSearchMode));
-    r.register(
-        &EnterEditMode {
-            initial_value: String::new(),
-        },
-        |args| {
-            let val = args.first().cloned().unwrap_or_default();
-            Ok(Box::new(EnterEditMode { initial_value: val }))
-        },
-        |_args, ctx| {
-            Ok(Box::new(EnterEditMode {
-                initial_value: ctx.display_value.clone(),
-            }))
-        },
-    );
-    r.register_nullary(|| Box::new(EditOrDrill));
-    r.register_nullary(|| Box::new(EnterEditAtCursorCmd));
+    r.register_pure(&NamedCmd("edit-or-drill"), |args| {
+        require_args("edit-or-drill", args, 1)?;
+        let edit_mode = parse_mode_name(&args[0])?;
+        Ok(Box::new(EditOrDrill { edit_mode }))
+    });
+    r.register_pure(&NamedCmd("enter-edit-at-cursor"), |args| {
+        require_args("enter-edit-at-cursor", args, 1)?;
+        let target_mode = parse_mode_name(&args[0])?;
+        Ok(Box::new(EnterEditAtCursorCmd { target_mode }))
+    });
     r.register_nullary(|| Box::new(EnterExportPrompt));
     r.register_nullary(|| Box::new(EnterFormulaEdit));
     r.register_nullary(|| Box::new(EnterTileSelect));
@@ -310,23 +325,7 @@ pub fn default_registry() -> CmdRegistry {
     );
     r.register_pure(&NamedCmd("enter-mode"), |args| {
         require_args("enter-mode", args, 1)?;
-        let mode = match args[0].as_str() {
-            "normal" => AppMode::Normal,
-            "help" => AppMode::Help,
-            "formula-panel" => AppMode::FormulaPanel,
-            "category-panel" => AppMode::CategoryPanel,
-            "view-panel" => AppMode::ViewPanel,
-            "tile-select" => AppMode::TileSelect,
-            "command" => AppMode::command_mode(),
-            "category-add" => AppMode::category_add(),
-            "editing" => AppMode::editing(),
-            "records-normal" => AppMode::RecordsNormal,
-            "records-editing" => AppMode::records_editing(),
-            "formula-edit" => AppMode::formula_edit(),
-            "export-prompt" => AppMode::export_prompt(),
-            other => return Err(format!("Unknown mode: {other}")),
-        };
-        Ok(Box::new(EnterMode(mode)))
+        Ok(Box::new(EnterMode(parse_mode_name(&args[0])?)))
     });
 
     // ── Search ───────────────────────────────────────────────────────────
@@ -522,25 +521,33 @@ pub fn default_registry() -> CmdRegistry {
     r.register_nullary(|| Box::new(CommandModeBackspace));
 
     // ── Commit ───────────────────────────────────────────────────────────
+    // commit-cell-edit / commit-and-advance-right take a mode-name arg
+    // (e.g. "editing" or "records-editing") as args[0]. The keymap supplies
+    // it; the command never inspects ctx.mode.
     r.register(
         &CommitAndAdvance {
             key: CellKey::new(vec![]),
             value: String::new(),
             advance: AdvanceDir::Down,
             cursor: CursorState::default(),
+            edit_mode: AppMode::editing(),
         },
         |args| {
-            if args.len() < 2 {
-                return Err("commit-cell-edit requires a value and coords".into());
+            if args.len() < 3 {
+                return Err("commit-cell-edit requires a mode, value, and coords".into());
             }
+            let edit_mode = parse_mode_name(&args[0])?;
             Ok(Box::new(CommitAndAdvance {
-                key: parse_cell_key_from_args(&args[1..]),
-                value: args[0].clone(),
+                key: parse_cell_key_from_args(&args[2..]),
+                value: args[1].clone(),
                 advance: AdvanceDir::Down,
                 cursor: CursorState::default(),
+                edit_mode,
             }))
         },
-        |_args, ctx| {
+        |args, ctx| {
+            require_args("commit-cell-edit", args, 1)?;
+            let edit_mode = parse_mode_name(&args[0])?;
             let value = read_buffer(ctx, "edit");
             let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
             Ok(Box::new(CommitAndAdvance {
@@ -548,6 +555,7 @@ pub fn default_registry() -> CmdRegistry {
                 value,
                 advance: AdvanceDir::Down,
                 cursor: CursorState::from_ctx(ctx),
+                edit_mode,
             }))
         },
     );
@@ -557,9 +565,12 @@ pub fn default_registry() -> CmdRegistry {
             value: String::new(),
             advance: AdvanceDir::Right,
             cursor: CursorState::default(),
+            edit_mode: AppMode::editing(),
         },
         |_| Err("commit-and-advance-right requires context".into()),
-        |_args, ctx| {
+        |args, ctx| {
+            require_args("commit-and-advance-right", args, 1)?;
+            let edit_mode = parse_mode_name(&args[0])?;
             let value = read_buffer(ctx, "edit");
             let key = ctx.cell_key().clone().ok_or("no cell at cursor")?;
             Ok(Box::new(CommitAndAdvance {
@@ -567,6 +578,7 @@ pub fn default_registry() -> CmdRegistry {
                 value,
                 advance: AdvanceDir::Right,
                 cursor: CursorState::from_ctx(ctx),
+                edit_mode,
             }))
         },
     );
diff --git a/src/command/keymap.rs b/src/command/keymap.rs
index a0276e3..92d5870 100644
--- a/src/command/keymap.rs
+++ b/src/command/keymap.rs
@@ -431,9 +431,21 @@ impl KeymapSet {
         );
         normal.bind(KeyCode::Tab, none, "cycle-panel-focus");
 
-        // Editing entry — i/a drill into aggregated cells, else edit
-        normal.bind(KeyCode::Char('i'), none, "edit-or-drill");
-        normal.bind(KeyCode::Char('a'), none, "edit-or-drill");
+        // Editing entry — i/a drill into aggregated cells, else edit.
+        // The mode arg controls which editing mode is entered; records-normal
+        // overrides these to "records-editing" via its own bindings.
+        normal.bind_args(
+            KeyCode::Char('i'),
+            none,
+            "edit-or-drill",
+            vec!["editing".into()],
+        );
+        normal.bind_args(
+            KeyCode::Char('a'),
+            none,
+            "edit-or-drill",
+            vec!["editing".into()],
+        );
         normal.bind(KeyCode::Enter, none, "enter-advance");
         normal.bind(KeyCode::Char('e'), ctrl, "enter-export-prompt");
 
@@ -488,10 +500,27 @@ impl KeymapSet {
 
         // ── Records normal mode (inherits from normal) ────────────────────
         let mut rn = Keymap::with_parent(normal);
+        // Override i/a so the edit branch produces records-editing mode
+        // instead of inheriting the normal-mode "editing" arg.
+        rn.bind_args(
+            KeyCode::Char('i'),
+            none,
+            "edit-or-drill",
+            vec!["records-editing".into()],
+        );
+        rn.bind_args(
+            KeyCode::Char('a'),
+            none,
+            "edit-or-drill",
+            vec!["records-editing".into()],
+        );
         rn.bind_seq(
             KeyCode::Char('o'),
             none,
-            vec![("add-record-row", vec![]), ("enter-edit-at-cursor", vec![])],
+            vec![
+                ("add-record-row", vec![]),
+                ("enter-edit-at-cursor", vec!["records-editing".into()]),
+            ],
         );
         set.insert(ModeKey::RecordsNormal, Arc::new(rn));
 
@@ -736,6 +765,8 @@ impl KeymapSet {
         set.insert(ModeKey::TileSelect, Arc::new(ts));
 
         // ── Editing mode ─────────────────────────────────────────────────
+        // commit-* takes the target edit-mode arg so the command stays
+        // mode-agnostic; records-editing overrides Enter/Tab below.
         let mut ed = Keymap::new();
         ed.bind_seq(
             KeyCode::Esc,
@@ -749,7 +780,7 @@ impl KeymapSet {
             KeyCode::Enter,
             none,
             vec![
-                ("commit-cell-edit", vec![]),
+                ("commit-cell-edit", vec!["editing".into()]),
                 ("clear-buffer", vec!["edit".into()]),
             ],
         );
@@ -757,7 +788,7 @@ impl KeymapSet {
             KeyCode::Tab,
             none,
             vec![
-                ("commit-and-advance-right", vec![]),
+                ("commit-and-advance-right", vec!["editing".into()]),
                 ("clear-buffer", vec!["edit".into()]),
             ],
         );
@@ -767,6 +798,7 @@ impl KeymapSet {
         set.insert(ModeKey::Editing, ed.clone());
 
         // ── Records editing mode (inherits from editing) ──────────────────
+        // Override Enter/Tab so the post-commit re-enter targets records-editing.
         let mut re = Keymap::with_parent(ed);
         re.bind_seq(
             KeyCode::Esc,
@@ -776,6 +808,22 @@ impl KeymapSet {
                 ("enter-mode", vec!["records-normal".into()]),
             ],
         );
+        re.bind_seq(
+            KeyCode::Enter,
+            none,
+            vec![
+                ("commit-cell-edit", vec!["records-editing".into()]),
+                ("clear-buffer", vec!["edit".into()]),
+            ],
+        );
+        re.bind_seq(
+            KeyCode::Tab,
+            none,
+            vec![
+                ("commit-and-advance-right", vec!["records-editing".into()]),
+                ("clear-buffer", vec!["edit".into()]),
+            ],
+        );
         set.insert(ModeKey::RecordsEditing, Arc::new(re));
 
         // ── Formula edit ─────────────────────────────────────────────────
diff --git a/src/ui/effect.rs b/src/ui/effect.rs
index eb40c9d..16ae172 100644
--- a/src/ui/effect.rs
+++ b/src/ui/effect.rs
@@ -124,10 +124,19 @@ impl Effect for RemoveFormula {
 
 /// Re-enter edit mode by reading the cell value at the current cursor.
 /// Used after commit+advance to continue data entry.
+///
+/// `target_mode` is supplied by the caller (keymap binding via
+/// `EnterEditAtCursorCmd`, or `CommitAndAdvance` from its own `edit_mode`
+/// field). The effect itself never inspects `app.mode` — the mode is decided
+/// statically by whoever invoked us.
 #[derive(Debug)]
-pub struct EnterEditAtCursor;
+pub struct EnterEditAtCursor {
+    pub target_mode: AppMode,
+}
 impl Effect for EnterEditAtCursor {
     fn apply(&self, app: &mut App) {
+        // Layout may be stale relative to prior effects in this batch (e.g.
+        // AddRecordRow added a row); rebuild before reading display_value.
         app.rebuild_layout();
         let ctx = app.cmd_context(
             crossterm::event::KeyCode::Null,
@@ -136,11 +145,7 @@ impl Effect for EnterEditAtCursor {
         let value = ctx.display_value.clone();
         drop(ctx);
         app.buffers.insert("edit".to_string(), value);
-        app.mode = if app.mode.is_records() {
-            AppMode::records_editing()
-        } else {
-            AppMode::editing()
-        };
+        app.mode = self.target_mode.clone();
     }
 }
 
@@ -1288,6 +1293,41 @@ mod tests {
         assert_eq!(app.mode, AppMode::Help);
     }
 
+    /// `EnterEditAtCursor` must use its `target_mode` field, *not* whatever
+    /// `app.mode` happens to be when applied. Previous implementation
+    /// branched on `app.mode.is_records()` — the parameterized version
+    /// trusts the caller (keymap or composing command).
+    #[test]
+    fn enter_edit_at_cursor_uses_target_mode_not_app_mode() {
+        let mut app = test_app();
+        // App starts in Normal mode — but caller has decided we want
+        // RecordsEditing (e.g. records-mode `o` sequence).
+        assert_eq!(app.mode, AppMode::Normal);
+        EnterEditAtCursor {
+            target_mode: AppMode::records_editing(),
+        }
+        .apply(&mut app);
+        assert!(
+            matches!(app.mode, AppMode::RecordsEditing { .. }),
+            "Expected RecordsEditing, got {:?}",
+            app.mode
+        );
+
+        // Same effect with editing target — should land in plain Editing
+        // even if app.mode was something else.
+        let mut app2 = test_app();
+        app2.mode = AppMode::RecordsNormal;
+        EnterEditAtCursor {
+            target_mode: AppMode::editing(),
+        }
+        .apply(&mut app2);
+        assert!(
+            matches!(app2.mode, AppMode::Editing { .. }),
+            "Expected Editing, got {:?}",
+            app2.mode
+        );
+    }
+
     /// SetBuffer with empty value clears the buffer (used by clear-buffer command
     /// in keymap sequences after commit).
     #[test]
