Merge PR #1589: Web: keep preferences such as dark mode between visits

docs/ui-parity.md: header line kept as main's (newer); revision-history row for the web preferences added once.
This commit is contained in:
aiongg
2026-10-22 02:34:56 +00:00
6 changed files with 202 additions and 4 deletions
+3 -1
View File
@@ -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) |
+1 -1
View File
@@ -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"] }
+4 -1
View File
@@ -11,11 +11,14 @@
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
//! notes and comments.
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
//! `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(target_arch = "wasm32")]
mod web;
+162
View File
@@ -0,0 +1,162 @@
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
//! whenever they change, since a reload or a closed tab runs no exit code.
use gridcraft_ui_egui::SheetApp;
/// The `localStorage` key for the desktop's `ui.json`.
pub const UI_KEY: &str = "gridcraft.ui";
/// The `localStorage` key for the desktop's `prefs.json`.
pub const PREFS_KEY: &str = "gridcraft.prefs";
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
const MAX_STORED: usize = 1 << 20;
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
let readable = |s: &&str| s.len() <= MAX_STORED;
if let Some(s) = ui.filter(readable)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
{
// No usable saved language: keep the browser's, as the desktop keeps the system's.
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
app.ui = state;
app.ui.language = language;
}
if let Some(s) = prefs.filter(readable)
&& let Ok(p) = serde_json::from_str(s)
{
app.session.prefs = p;
}
}
/// Tracks what was last written, so the store is written only when a preference changed.
#[derive(Default)]
pub struct PrefsSync {
ui: Option<String>,
prefs: Option<String>,
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
user_name: Option<String>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
Self { ui, prefs, user_name: None }
}
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
/// framing page names the author for this visit only, and must not replace the user's own.
pub fn keep_user_name(&mut self, name: String) {
self.user_name = Some(name);
}
/// The `(key, JSON)` pairs to store because they differ from what was last written.
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
let prefs = match &self.user_name {
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
None => serde_json::to_string(&app.session.prefs),
};
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
match json {
Ok(json) if last.as_deref() != Some(json.as_str()) => {
*last = Some(json.clone());
out.push((key, json));
}
Ok(_) => {}
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use gridcraft_engine::Session;
use gridcraft_ui_egui::Services;
use gridcraft_ui_egui::i18n::Language;
use serde_json::json;
fn app() -> SheetApp {
let mut app = SheetApp::new(Session::new(), Services::default());
// As `web::start` sets it up before restoring.
app.ui.system_theme = true;
app.ui.language = Language::De;
app
}
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
}
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
#[test]
fn dark_mode_survives_reload() {
let mut first = app();
let mut sync = PrefsSync::new(None, None);
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
first.run("view.theme", json!({"mode": "dark"})).unwrap();
first.session.prefs.enter_direction = "right".into();
let writes = sync.changed(&first);
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
let mut reloaded = app();
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
assert_eq!(reloaded.session.prefs.enter_direction, "right");
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
}
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
#[test]
fn host_author_is_not_stored() {
let mut first = app();
first.session.prefs.user_name = "Ada".into();
let mut sync = PrefsSync::new(None, None);
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
let mut hosted = app();
restore(&mut hosted, None, stored_prefs.as_deref());
let mut sync = PrefsSync::new(None, stored_prefs);
sync.keep_user_name(hosted.session.prefs.user_name.clone());
hosted.session.prefs.user_name = "Host Author".into();
hosted.session.prefs.r1c1 = true;
let writes = sync.changed(&hosted);
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
let mut reloaded = app();
restore(&mut reloaded, None, Some(&prefs));
assert_eq!(reloaded.session.prefs.user_name, "Ada");
assert!(reloaded.session.prefs.r1c1);
}
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
#[test]
fn missing_language_keeps_the_browsers() {
let mut a = app();
restore(&mut a, Some(r#"{"dark":true}"#), None);
assert!(a.ui.dark);
assert_eq!(a.ui.language, Language::De);
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
assert_eq!(a.ui.language, Language::Fr);
}
#[test]
fn junk_storage_keeps_defaults() {
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
let mut a = app();
restore(&mut a, junk, junk);
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
assert_eq!(a.ui.language, Language::De);
assert_eq!(a.session.prefs.enter_direction, "down");
}
}
}
+31 -1
View File
@@ -58,14 +58,20 @@ pub fn start() {
) {
app.ui.language = browser;
}
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
// and `prefs.json`; a saved language replaces the browser's.
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
if let Some(bridge) = &app_bridge {
prefs.keep_user_name(app.session.prefs.user_name.clone());
if let Some(author) = query_param("author") {
crate::host::Bridge::set_author(&mut app, &author);
}
bridge.post_ready();
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
}),
)
.await;
@@ -93,6 +99,16 @@ struct WebShell {
app: SheetApp,
inbox: Inbox,
bridge: Option<crate::host::Bridge>,
prefs: crate::prefs::PrefsSync,
}
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
/// A value stored on an earlier visit, if the browser allows storage.
fn stored(key: &str) -> Option<String> {
local_storage()?.get_item(key).ok()?
}
impl eframe::App for WebShell {
@@ -113,6 +129,20 @@ impl eframe::App for WebShell {
});
}
self.app.logic(ctx);
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
let changed = self.prefs.changed(&self.app);
if !changed.is_empty() {
match local_storage() {
Some(store) => {
for (key, json) in changed {
if let Err(e) = store.set_item(key, &json) {
log::warn!("preferences: cannot save {key}: {e:?}");
}
}
}
None => log::warn!("preferences: browser storage is unavailable"),
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
+1
View File
@@ -163,6 +163,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|---|---|---|
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
| 2026-10-16 | trivial | Web: preferences (theme, interface size, language, editing options…) are kept in the browser's `localStorage` between visits, as the desktop app keeps `ui.json` and `prefs.json`; before, a reload brought back light mode and the defaults (#1562) |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |