From d0d4d26c74c3d74626135b77742fac930ab7b5c7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 07:12:34 +0200 Subject: [PATCH 01/16] =?UTF-8?q?UI:=20File=20=E2=80=BA=20Account=20shows?= =?UTF-8?q?=20the=20version,=20platform=20and=20licence,=20with=20an=20Abo?= =?UTF-8?q?ut=20GridCraft=20button=20(fixes=20#896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/backstage.rs | 63 ++++++++++++++++++++++++++++--- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 7 ++++ crates/ui-egui/src/i18n/cs.rs | 6 +++ crates/ui-egui/src/i18n/de.rs | 7 ++++ crates/ui-egui/src/i18n/es.rs | 7 ++++ crates/ui-egui/src/i18n/fr.rs | 7 ++++ crates/ui-egui/src/i18n/ko.rs | 7 ++++ crates/ui-egui/src/i18n/pt.rs | 5 +++ crates/ui-egui/src/i18n/ru.rs | 7 ++++ crates/ui-egui/src/i18n/sr.rs | 7 ++++ crates/ui-egui/src/i18n/zh.rs | 7 ++++ crates/ui-egui/tests/file_menu.rs | 16 +++++++- docs/control-protocol.md | 2 +- 14 files changed, 142 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ae2155419..c57537a32 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -2,7 +2,7 @@ //! and sheet tabs until Esc or the back arrow returns to the sheet. //! //! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, -//! Options); Save and Close act at once. Every action runs an existing command through +//! Options, Account); Save and Close act at once. Every action runs an existing command through //! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is //! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched //! until the user picks one. @@ -31,10 +31,11 @@ pub enum Page { Print, Export, Options, + Account, } impl Page { - pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options]; + pub const ALL: [Page; 9] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options, Page::Account]; /// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take. pub fn id(self) -> &'static str { @@ -47,6 +48,7 @@ impl Page { Page::Print => "print", Page::Export => "export", Page::Options => "options", + Page::Account => "account", } } @@ -66,6 +68,7 @@ impl Page { Page::Print => "Print", Page::Export => "Export", Page::Options => "Options", + Page::Account => "Account", } } @@ -79,6 +82,7 @@ impl Page { Page::Print => Icon::Print, Page::Export => Icon::Share, Page::Options => Icon::Settings, + Page::Account => Icon::Info, } } } @@ -174,9 +178,12 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { out.close = true; } } + // Bottom up: Account sits just above Options, as in Excel. ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { - if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() { - out.page = Some(Page::Options); + for page in [Page::Options, Page::Account] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } } }); }); @@ -195,6 +202,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { Page::Print => print(app, ui, &mut bs, &mut out), Page::Export => export(app, ui, &mut out), Page::Options => options(app, ui), + Page::Account => account(app, ui), } }); }); @@ -852,6 +860,51 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { dim(ui, lang.tr("Calculation settings are saved with the workbook.")); } +/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". +fn platform() -> String { + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + _ if cfg!(target_arch = "wasm32") => return "Web".into(), + other => other, + }; + format!("{os} ({})", std::env::consts::ARCH) +} + +/// Excel's File › Account: product information and the About window. +fn account(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + section(ui, lang, "Product Information"); + ui.label(RichText::new("GridCraft").font(theme::ui_bold(16.0)).color(t.text)); + ui.add_space(8.0); + egui::Grid::new("backstage_account").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + for (label, value) in + [("Version", env!("CARGO_PKG_VERSION").to_string()), ("Platform", platform()), ("Licence", env!("CARGO_PKG_LICENSE").to_string())] + { + ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + ui.label(value); + ui.end_row(); + } + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + if square_button(ui, Icon::Info, lang.tr("About GridCraft")).clicked() { + app.open_dialog("about", json!({})); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(lang.tr("About GridCraft")).font(theme::ui_bold(13.5)).color(t.text)); + dim(ui, lang.tr("Version, contributors and the AI models that helped")); + }); + }); +} + #[cfg(test)] mod tests { use super::*; @@ -862,7 +915,7 @@ mod tests { assert_eq!(Page::parse(page.id()), Some(page)); } assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); - assert_eq!(Page::parse("account"), None); + assert_eq!(Page::parse("feedback"), None); assert_eq!(Page::parse(""), None); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d27ea052e..54dcadaeb 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -183,7 +183,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), - ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), + ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), ( "ui.printSettings", "Print Settings", diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4cc6a3e37..f8c318bc2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -519,6 +519,13 @@ const JAPANESE: &[(&str, &str)] = &[ ("Export", "エクスポート"), ("Close", "閉じる"), ("Options", "オプション"), + ("Account", "アカウント"), + ("Product Information", "製品情報"), + ("About GridCraft", "GridCraft について"), + ("Version", "バージョン"), + ("Platform", "プラットフォーム"), + ("Licence", "ライセンス"), + ("Version, contributors and the AI models that helped", "バージョン、貢献者、支援した AI モデル"), ("Blank workbook", "空白のブック"), ("Recent", "最近使ったアイテム"), ("No recent workbooks yet.", "最近使ったブックはまだありません。"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index bf8c35f9b..81848f599 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -258,6 +258,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportovat", "Close" => "Zavřít", "Options" => "Možnosti", + "Account" => "Účet", + "Product Information" => "Informace o produktu", + "About GridCraft" => "O aplikaci GridCraft", + "Version" => "Verze", + "Platform" => "Platforma", + "Licence" => "Licence", "Blank workbook" => "Prázdný sešit", "Recent" => "Naposledy použité", "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 75653e583..246c63572 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -256,6 +256,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportieren", "Close" => "Schließen", "Options" => "Optionen", + "Account" => "Konto", + "Product Information" => "Produktinformationen", + "About GridCraft" => "Info zu GridCraft", + "Version" => "Version", + "Platform" => "Plattform", + "Licence" => "Lizenz", + "Version, contributors and the AI models that helped" => "Version, Mitwirkende und die KI-Modelle, die geholfen haben", "Blank workbook" => "Leere Arbeitsmappe", "Recent" => "Zuletzt verwendet", "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 0582110c7..948dc7dd1 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -257,6 +257,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportar", "Close" => "Cerrar", "Options" => "Opciones", + "Account" => "Cuenta", + "Product Information" => "Información del producto", + "About GridCraft" => "Acerca de GridCraft", + "Version" => "Versión", + "Platform" => "Plataforma", + "Licence" => "Licencia", + "Version, contributors and the AI models that helped" => "Versión, colaboradores y los modelos de IA que ayudaron", "Blank workbook" => "Libro en blanco", "Recent" => "Recientes", "No recent workbooks yet." => "Todavía no hay libros recientes.", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index c70b25cf5..780065501 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -342,6 +342,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exporter", "Close" => "Fermer", "Options" => "Options", + "Account" => "Compte", + "Product Information" => "Informations sur le produit", + "About GridCraft" => "À propos de GridCraft", + "Version" => "Version", + "Platform" => "Plateforme", + "Licence" => "Licence", + "Version, contributors and the AI models that helped" => "Version, contributeurs et les modèles d'IA qui ont aidé", "Blank workbook" => "Classeur vide", "Recent" => "Récent", "No recent workbooks yet." => "Aucun classeur récent pour le moment.", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index f3e1688d0..9849d451c 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -344,6 +344,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "내보내기", "Close" => "닫기", "Options" => "옵션", + "Account" => "계정", + "Product Information" => "제품 정보", + "About GridCraft" => "GridCraft 정보", + "Version" => "버전", + "Platform" => "플랫폼", + "Licence" => "라이선스", + "Version, contributors and the AI models that helped" => "버전, 기여자 및 도움을 준 AI 모델", "Blank workbook" => "새 통합 문서", "Recent" => "최근 항목", "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 9ab5b7baf..27f504eee 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -345,6 +345,11 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Exportar", "Close" => "Fechar", "Options" => "Opções", + "Account" => "Conta", + "Product Information" => "Informações do produto", + "Version" => "Versão", + "Platform" => "Plataforma", + "Licence" => "Licença", "Blank workbook" => "Pasta de trabalho em branco", "Recent" => "Recentes", "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 868f653b8..2bfa63bb4 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -344,6 +344,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Экспорт", "Close" => "Закрыть", "Options" => "Параметры", + "Account" => "Учетная запись", + "Product Information" => "Сведения о продукте", + "About GridCraft" => "О программе GridCraft", + "Version" => "Версия", + "Platform" => "Платформа", + "Licence" => "Лицензия", + "Version, contributors and the AI models that helped" => "Версия, участники и модели ИИ, которые помогали", "Blank workbook" => "Пустая книга", "Recent" => "Последние", "No recent workbooks yet." => "Недавних книг пока нет.", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 152bddee8..9621d7d3a 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -336,6 +336,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "Izvezi", "Close" => "Zatvori", "Options" => "Opcije", + "Account" => "Nalog", + "Product Information" => "Informacije o proizvodu", + "About GridCraft" => "O programu GridCraft", + "Version" => "Verzija", + "Platform" => "Platforma", + "Licence" => "Licenca", + "Version, contributors and the AI models that helped" => "Verzija, saradnici i AI modeli koji su pomogli", "Blank workbook" => "Prazna radna sveska", "Recent" => "Nedavno", "No recent workbooks yet." => "Još nema nedavnih radnih svezaka.", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index a9989f2e4..1af3d4563 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -342,6 +342,13 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Export" => "导出", "Close" => "关闭", "Options" => "选项", + "Account" => "账户", + "Product Information" => "产品信息", + "About GridCraft" => "关于 GridCraft", + "Version" => "版本", + "Platform" => "平台", + "Licence" => "许可证", + "Version, contributors and the AI models that helped" => "版本、贡献者以及参与协助的 AI 模型", "Blank workbook" => "空白工作簿", "Recent" => "最近", "No recent workbooks yet." => "还没有最近使用的工作簿。", diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 691eef367..abdc11592 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -122,7 +122,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { h.run_steps(2); assert_eq!(page(&h), Some(id)); } - assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err()); + assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err()); h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); assert!(h.state().backstage.is_none()); // The back arrow returns to the sheet too. @@ -132,6 +132,20 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { assert!(h.state().backstage.is_none()); } +#[test] +fn file_account_shows_the_version_and_opens_about() { + let mut h = harness(Services::default()); + choose(&mut h, "Account"); + assert_eq!(page(&h), Some("account")); + assert!(h.state().dialog.is_none()); + // The About dialog also shows the version; it must not be open yet. + h.get_by_label(env!("CARGO_PKG_VERSION")); + h.get_by_label(env!("CARGO_PKG_LICENSE")); + click(&mut h, "About GridCraft"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("about")); + assert_eq!(page(&h), Some("account"), "About opens over the File view, like Protect Workbook on Info"); +} + #[test] fn export_as_pdf_is_delivered_as_a_download() { let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..bd1c43262 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,7 +32,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | -| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | +| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | From ac8f6f6ce45755006d6ee7fbb2c98d76e884f262 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:35 +0200 Subject: [PATCH 02/16] Keep Middle/Bottom vertical alignment for wrapped text taller than its row Fixes #1347 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/grid.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..592947606 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -926,7 +926,9 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { let pad_v = 1.5 * z; let y_top = rect.top() + pad_v; - let y_bottom = (rect.bottom() - gh - pad_v).max(y_top); + // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways + // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. + let y_bottom = rect.bottom() - gh - pad_v; match valign { VAlign::Top => y_top, VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, @@ -3455,10 +3457,24 @@ mod tests { assert!(y_top < y_center); assert!(y_center < y_bottom); - // Overflow case: gh exceeds rect height + // Overflow case: gh exceeds rect height; Top still starts inside the cell. let gh_overflow = 60.0; - let y_bottom_ovf = wrapped_cell_text_y(VAlign::Bottom, rect, gh_overflow, z); - assert!(y_bottom_ovf >= rect.top()); + let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); + assert!(y_top_ovf >= rect.top()); + } + + #[test] + fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { + // A default-height row holding three wrapped lines. + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let (gh, z) = (60.0, 1.0); + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). + assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); + assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); + assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); } /// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui From 34a4ed3eadffd28119b02463819b35d0eed3da26 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:09:25 +0300 Subject: [PATCH 03/16] engine: rebuild cell-store bands and counts when importing a JSON workbook --- crates/engine/src/io.rs | 9 ++++++++- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/model/src/store.rs | 22 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3e84036cb..f52b15280 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -150,7 +150,14 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { if wb.sheets.is_empty() { wb = Workbook::new(); } - Ok((wb, vec![])) + // The file's band placement and cell count are derived state: rebuild them from the + // cells themselves, dropping what is outside the sheet grid (#1008). + let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum(); + let mut warnings = Vec::new(); + if dropped > 0 { + warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped")); + } + Ok((wb, warnings)) } Some(FileKind::Tsv) => { let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..2ce3418ff 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2908,6 +2908,38 @@ fn autofill_month_pattern_clamps_to_month_end() { assert_eq!(v(&s, "A6"), ymd(2026, 11, 30)); } +#[test] +fn json_import_rebuilds_cell_store_bands_and_counts() { + // #1008: the JSON reader trusted serialized band keys and the cached length. A file written + // with a cell in the wrong band lost it from lookup, and a forged count underflowed on the + // next removal. + let mut s = s(); + s.execute("cell.set", json!({"cell": "A5", "input": "keep"})).unwrap(); + let wb = &s.doc().unwrap().wb; + let mut v: serde_json::Value = serde_json::to_value(&**wb).unwrap(); + { + let cells = v["sheets"][0]["cells"].as_object_mut().unwrap(); + let bands = cells["bands"].as_object_mut().unwrap(); + let band = bands.remove("0").expect("the cell is in band 0"); + let cell = band["rows"]["4"]["0"].clone(); + // Off-grid rows never belong in a sheet, whatever band holds them. + bands.insert("1".into(), band); + bands.insert("16384".into(), json!({"rows": {"1048576": {"0": cell}}})); + cells.insert("len".into(), json!(0)); + } + let bytes = serde_json::to_vec(&v).unwrap(); + let (back, warnings) = crate::io::open_bytes("book.json", &bytes).unwrap(); + let sh = back.active().unwrap(); + let cell = sh.cell(CellRef::parse("A5").unwrap()); + assert_eq!(cell.map(|c| c.value.clone()), Some(Value::text("keep")), "the cell is found where its row says"); + assert_eq!(sh.cells.len(), 1, "the count is rebuilt from the cells"); + assert!(warnings.iter().any(|w| w.contains("outside the 1,048,576")), "{warnings:?}"); + // Removal stays consistent: the forged count used to underflow. + let mut cells = sh.cells.clone(); + cells.remove(CellRef::parse("A5").unwrap()); + assert_eq!(cells.len(), 0); +} + #[test] fn notes_comments_and_links_survive_a_json_save() { let mut s = s(); diff --git a/crates/model/src/store.rs b/crates/model/src/store.rs index d6a9c4561..51a8e72fd 100644 --- a/crates/model/src/store.rs +++ b/crates/model/src/store.rs @@ -57,6 +57,28 @@ impl CellStore { self.len += 1; } } + /// Puts every cell in the band its row says and recounts the length — serialized band keys + /// and the cached count are derived state, and the ones a file carries are not trusted. Cells + /// outside the sheet grid are dropped; returns how many (#1008). + pub fn rebuild(&mut self) -> usize { + let mut out = CellStore::new(); + let mut dropped = 0; + for (_, band) in std::mem::take(&mut self.bands) { + for (row, cols) in band.rows.iter() { + for (col, cell) in cols.iter() { + let at = CellRef::new(*row, *col); + if at.is_valid() { + out.set(at, cell.clone()); + } else { + dropped += 1; + } + } + } + } + *self = out; + dropped + } + pub fn remove(&mut self, c: CellRef) -> Option { let key = c.row / BAND; let band = self.bands.get_mut(&key)?; From 370b2208348ca4890e26d382570afc57e5118828 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:23:19 +0900 Subject: [PATCH 04/16] fix(solver): commit solutions and reports atomically Prepare report data before editing so refused reports cannot leave solution cells or the saved model changed. Commit accepted reports with the optional solution and model, retaining array/protection checks and row autofit after sheet insertion. Closes #1050 Co-Authored-By: OpenAI Codex --- crates/engine/src/cmd/solver.rs | 257 +++++++++++++++++++++++++------- docs/gaps.md | 9 +- 2 files changed, 207 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 4465874bc..ffd426f5f 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result { Ok(out) } -/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step; -/// the model's sheet stays active. Returns the new sheets' names. -fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result> { +/// Builds every report before the solution or saved model can change. +fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result> { if kinds.is_empty() { return Ok(Vec::new()); } - let d = s.doc()?; if d.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); } @@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re .ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?; built.push((name, lines)); } + Ok(built) +} + +/// Inserts prepared reports before the model's sheet, which stays active. +fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec { let names: Vec = built.iter().map(|(n, _)| n.clone()).collect(); - let at = inp.model.sheet; let count = built.len(); - edit(s, |cx| { - let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); - for (i, (name, lines)) in built.into_iter().enumerate() { - let mut sheet = Sheet::new(name); - for (r, c, v, b) in lines { - let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; - sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); - } - for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { - sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); - } - cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); + if count == 0 { + return names; + } + let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); + for (i, (name, lines)) in built.into_iter().enumerate() { + let mut sheet = Sheet::new(name); + for (r, c, v, b) in lines { + let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; + sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); } - for nm in cx.wb.names.iter_mut() { - if let Some(sc) = nm.scope.as_mut() - && *sc >= at - { - *sc += count; - } + for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { + sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); } - if cx.wb.active_sheet >= at { - cx.wb.active_sheet += count; + cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); + } + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += count; } - cx.structural = true; - Ok(()) - })?; + } + if cx.wb.active_sheet >= at { + cx.wb.active_sheet += count; + } + cx.structural = true; + names +} + +fn insert_report_selections(s: &mut Session, at: usize, count: usize) { if let Ok(d) = s.doc_mut() { for i in 0..count { d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default()); } } - Ok(names) } /// `data.solverReport`: reports for a solution of the sheet's saved model. @@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result { .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; - let sheets = write_reports(s, &kinds, &inp)?; + let built = prepare_reports(s.doc()?, &kinds, &inp)?; + let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?; + insert_report_selections(s, model.sheet, sheets.len()); Ok(json!({"sheets": sheets})) } @@ -2068,41 +2075,53 @@ fn run(s: &mut Session, p: &Json) -> Result { } let original: serde_json::Map = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect(); let changed = keep && solution.is_some(); - if changed || save { - let sheet = model.sheet; - let vars = model.vars.clone(); - let m = model.clone(); - edit(s, |cx| { - if changed && let Some(x) = &solution { - let sh = cx.sheet_mut(sheet)?; - for (c, v) in vars.iter().zip(x) { - let style = sh.style_id(*c); - sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style }); - } - for c in &vars { - cx.touch(sheet, *c); - } - } - if save { - save_model(&mut cx.wb, &m); - } - Ok(()) - })?; - } + let built = match (&reports, &solution) { + (Some(kinds), Some(x)) => { + let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget }; + prepare_reports(s.doc()?, kinds, &inp)? + } + _ => Vec::new(), + }; + let count = built.len(); + let before = s.doc()?.wb.clone(); let mut out = json!({ "status": status, "message": message, "objective": objective, "values": values, "original": original, - "model": model_params(&s.doc()?.wb, &model), + "model": model_params(&before, &model), "evaluations": evaluations, "changed": changed, }); - // Reports describe a solution; they are part of the solve's undo step. - if let (Some(kinds), Some(x)) = (reports, solution) { - let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget }; - out["reports"] = json!(write_reports(s, &kinds, &inp)?); + let names = if changed || save || count > 0 { + let sheet = model.sheet; + edit(s, |cx| { + if changed && let Some(x) = &solution { + let sh = cx.sheet_mut(sheet)?; + for (c, v) in model.vars.iter().zip(x) { + let style = sh.style_id(*c); + sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style }); + } + for c in &model.vars { + cx.touch(sheet + count, *c); + } + } + if save { + save_model(&mut cx.wb, &model); + } + if count > 0 { + // Inserting sheets makes this structural, so retain the variable edit's array check. + check_arrays(&before, &cx.wb)?; + } + Ok(write_reports(cx, sheet, built)) + })? + } else { + Vec::new() + }; + insert_report_selections(s, model.sheet, count); + if reports.is_some() && solution.is_some() { + out["reports"] = json!(names); } Ok(out) } @@ -2390,6 +2409,132 @@ mod tests { assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0)); } + #[test] + fn refused_solver_reports_preserve_the_document_and_history() { + for (protected, method, constraints, reports, error) in [ + (true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"), + (false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"), + (false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"), + ] { + for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + s.execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}), + ) + .unwrap(); + set(&mut s, &[("C1", "redo")]); + s.execute("edit.undo", json!({})).unwrap(); + let d = s.doc_mut().unwrap(); + std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected; + d.saved = d.wb.clone(); + let before = d.clone(); + let journal = s.journal.clone(); + let err = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method, + "reports": reports, "keep": keep, "saveModel": save}), + ) + .unwrap_err(); + assert!(err.to_string().contains(error), "{err}"); + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}"); + assert!(!d.is_dirty()); + assert_eq!(d.revision, before.revision); + assert_eq!(d.selection, before.selection); + assert_eq!(d.sheet_selections, before.sheet_selections); + for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] { + assert_eq!(after.len(), before.len()); + for (a, b) in after.iter().zip(before) { + assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb)); + assert_eq!((&a.label, &a.selection), (&b.label, &b.selection)); + } + } + assert_eq!(s.journal, journal); + } + } + } + + #[test] + fn solver_reports_and_optional_solution_and_model_share_one_undo() { + for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + let before = s.doc().unwrap().clone(); + let out = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", + "reports": ["answer"], "keep": keep, "saveModel": save}), + ) + .unwrap(); + assert_eq!(out["status"], "optimal"); + assert_eq!(out["reports"], json!(["Answer Report 1"])); + assert_eq!(out["model"]["objective"], "Sheet1!B1"); + assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); + assert_eq!(saved(&s), if save { "solver_opt" } else { "" }); + assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1); + assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1"); + let r = row_of(&s, "Answer Report 1", "$A$1") + 1; + assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); + } + } + + #[test] + fn solver_reports_keep_row_autofit_on_the_model_sheet() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().row_height(0); + s.execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}), + ) + .unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.name, "Sheet1"); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0)); + assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before); + } + + #[test] + fn solver_report_insertion_keeps_variable_edit_guards() { + for array in [false, true] { + let mut s = s(); + set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]); + let error = if array { + s.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap(); + "part of an array" + } else { + s.execute("review.protectSheet", json!({})).unwrap(); + "protected sheet" + }; + let before = s.doc().unwrap().clone(); + let err = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}), + ) + .unwrap_err(); + assert!(err.to_string().contains(error), "{err}"); + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb)); + assert_eq!(d.undo.len(), before.undo.len()); + assert_eq!(d.sheet_selections, before.sheet_selections); + } + } + #[test] fn limits_report_of_a_nonlinear_model() { // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c5..6fbd4b7f9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Solver report refusals preserve the workbook and history) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -110,8 +110,10 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h. - Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and - Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and - Population reports and Load/Save Model remain. The Analysis ToolPak + Limits reports. Report refusal leaves the solution, saved model and history unchanged; a + successful solve and its reports share one Undo step (#1050). AllDifferent constraints, + the Solver Options dialog, the Sensitivity and Population reports and Load/Save Model remain. + The Analysis ToolPak (`data.analysisToolPak`) has 13 of 19 tools. Forecast Sheet (`data.forecastSheet`) and the FORECAST.ETS family are in; the sheet has no Forecast Start (hindcast) option yet. **Estimate:** 12–20 h. @@ -168,6 +170,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | From 79e8973c30dc8e5c5f49cce52ad532562507fb0a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 01:33:28 +0200 Subject: [PATCH 05/16] Web: keep preferences such as dark mode between visits (fixes #1562) --- ROADMAP.md | 4 +- apps/gridcraft-web/Cargo.toml | 2 +- apps/gridcraft-web/src/main.rs | 5 +- apps/gridcraft-web/src/prefs.rs | 162 ++++++++++++++++++++++++++++++++ apps/gridcraft-web/src/web.rs | 32 ++++++- docs/ui-parity.md | 3 +- 6 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 apps/gridcraft-web/src/prefs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..e63d4da12 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (progress log: web preferences kept between visits, #1562; previously minor: rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/apps/gridcraft-web/Cargo.toml b/apps/gridcraft-web/Cargo.toml index eac6c1354..5f670ad19 100644 --- a/apps/gridcraft-web/Cargo.toml +++ b/apps/gridcraft-web/Cargo.toml @@ -26,4 +26,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "Storage"] } diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 2920a872d..36f3f3eca 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -11,11 +11,14 @@ //! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample; //! `?host=parent` (inside an `