Browse / github.com/storytold/cadcraft / patch
feat(web): persist validated browser panel layouts
feat(web): persist validated browser panel layouts ## Change The browser host currently saves preferences but discards panel placement on reload. Save a separate bounded, versioned layout record through the existing host storage, restoring it only after validating the whole record. Floating panels remain embedded in the browser canvas. Native workspace files and drawing data are unchanged. ## Dependencies and overlap - Original Windows test prerequisite #2198 (a2dd814); no dependency on palette menu #2226, ribbon #1023, or our historical fork main. - Reuses the shared docking schema and CADCraft document-view validation already on upstream
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- smacdmac101 (imported from pull request #2258, open)
- Base
d3ae8a6af0b76185e33f6dc2735679fa5c2be77c- Size
- +137 −5 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bf2da33c729e7d107efac813
How it went
No other operator has reported using it yet.
Diff
raw · apps/cadcraft-web/src/main.rs apps/cadcraft-web/src/panel_layout.rs docs/ui-parity.md packaging/web/README.md xtask/src/parity.rs xtask/src/stats.rs
@@ -14,6 +14,9 @@ pub mod download; #[cfg(target_arch = "wasm32")] mod host; +#[cfg(any(target_arch = "wasm32", test))]+mod panel_layout;+ #[cfg(target_arch = "wasm32")] mod web { use cadcraft_engine::Session;@@ -59,10 +62,14 @@ mod web { self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); }- /// Preferences (interface theme and language) survive reloads; egui state is not kept.+ /// Preferences and validated panel placement survive reloads; egui memory is not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json()); storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());+ match crate::panel_layout::save(&self.0.ui) {+ Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),+ Err(error) => log::warn!("panel layout was not saved: {error}"),+ } } fn persist_egui_memory(&self) -> bool { false@@ -160,6 +167,11 @@ mod web { if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); }+ if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))+ && let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)+ {+ log::warn!("panel layout was not restored: {error}");+ } // `?cpu`: no render state for the app, so the canvas draws with egui shapes. if !crate::wants_cpu(&query()) && let Some(rs) = &cc.wgpu_render_statenew file mode 100644@@ -0,0 +1,104 @@+//! Browser panel layout storage, independent of drawings and native workspace files.++use cadcraft_ui_egui::{UiState, docking};+use serde_json::{Value, json};++#[cfg(target_arch = "wasm32")]+pub const KEY: &str = "cadcraft.panel-layout";+const MAX_BYTES: usize = 64 * 1024;++/// Serialize only validated panel placement and visibility, with a bounded record size.+pub fn save(ui: &UiState) -> Result<String, String> {+ docking::validate(&ui.docking)?;+ let saved = json!({+ "version": docking::LAYOUT_VERSION,+ "docking": ui.docking,+ "showToolsets": ui.show_toolsets,+ "showPalettes": ui.show_palettes,+ })+ .to_string();+ if saved.len() > MAX_BYTES {+ return Err("panel layout exceeds 64 KiB".into());+ }+ Ok(saved)+}++/// Validate the entire optional record before changing any UI field.+pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {+ if saved.len() > MAX_BYTES {+ return Err("panel layout exceeds 64 KiB".into());+ }+ let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;+ if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {+ return Err("unsupported panel layout version".into());+ }+ let workspace: docking::Workspace =+ serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;+ docking::validate(&workspace)?;+ let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;+ let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;+ ui.docking = workspace;+ ui.show_toolsets = tools;+ ui.show_palettes = palettes;+ Ok(())+}++#[cfg(test)]+mod tests {+ use super::*;+ use cadcraft_engine::Session;+ use cadcraft_ui_egui::{CadApp, Services};++ #[test]+ fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {+ let mut source = CadApp::new(Session::default(), Services::default());+ source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();+ source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();+ let saved = save(&source.ui).unwrap();+ let mut destination = CadApp::new(Session::default(), Services::default());+ destination.session.start("line").unwrap();+ let before = destination.session.doc().unwrap().clone();+ restore(&mut destination.ui, &saved).unwrap();+ assert_eq!(destination.ui.docking, source.ui.docking);+ assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);+ assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);+ assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));+ assert_eq!(destination.session.doc().unwrap(), &before);+ }++ #[test]+ fn malformed_or_unsupported_storage_is_rejected_atomically() {+ let mut ui = UiState::default();+ let before = serde_json::to_value(&ui).unwrap();+ let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();+ for (field, bad) in [+ ("version", json!(0)),+ ("version", json!(docking::LAYOUT_VERSION + 1)),+ ("docking", json!({})),+ ("showToolsets", json!("false")),+ ("showPalettes", Value::Null),+ ] {+ let mut invalid = valid.clone();+ invalid[field] = bad;+ assert!(restore(&mut ui, &invalid.to_string()).is_err());+ assert_eq!(serde_json::to_value(&ui).unwrap(), before);+ }+ for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {+ assert!(restore(&mut ui, &invalid).is_err());+ assert_eq!(serde_json::to_value(&ui).unwrap(), before);+ }+ }++ #[test]+ fn storage_cannot_remove_the_document_view() {+ let mut ui = UiState::default();+ let before = serde_json::to_value(&ui).unwrap();+ let mut invalid = ui.clone();+ invalid.docking.layout.root = None;+ assert!(save(&invalid).is_err());+ let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();+ record["docking"] = serde_json::to_value(&invalid.docking).unwrap();+ assert!(restore(&mut ui, &record.to_string()).is_err());+ assert_eq!(serde_json::to_value(&ui).unwrap(), before);+ }+}@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (browser panel layout storage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -166,6 +166,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | ViewCube | interactive 3D | clickable for 2D (Top/Home → extents); dragging does nothing (#7) | | Navigation bar, SteeringWheels | yes | no | | Workspaces | Drafting & Annotation, 3D Modeling | no |+| Browser panel layout storage | not applicable | Versioned, validated panel placement and visibility saved through the browser host; floating palettes remain embedded. Requires available browser storage and a completed host save; named workspaces and drawing persistence are separate. | | Options / Preferences dialog | many tabs | **no** (issue #23) | | Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); no CUI or tool palettes | | Themes | Dark, Light | Dark, Light, System (#179) |@@ -203,6 +204,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) | | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions | | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |@@ -50,6 +50,18 @@ location /cadcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Panel layout storage++The browser host saves docked, floating and hidden palette arrangements alongside interface+preferences in its local storage. Floating palettes remain embedded in the browser canvas;+they do not become native operating-system windows. Window > Reset Palettes restores the+default arrangement. This record contains panel placement and visibility, not drawing data.++The optional `cadcraft.panel-layout` record uses the shared docking layout version and is+limited to 64 KiB. Malformed, incompatible or invalid arrangements are ignored atomically,+leaving the startup defaults usable. Storage availability and autosave follow the browser+host's existing eframe persistence; clearing site data removes the saved layout.+ ## Embedding in a page (iframe) ```html@@ -136,7 +136,9 @@ mod tests { assert_eq!(day(20_736), "2026-10-10"); assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01"); // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).- assert_eq!(day(115_740_740), "318857-05-20");+ // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that+ // SystemTime panics there.+ assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20)); // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) { assert_eq!(date(far), "292277026596-12-04");@@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { #[cfg(test)] mod tests {- use super::{Counts, count_tests, walk};+ use super::{Counts, count_tests}; #[test] fn counts_attributes_and_proptest_blocks() {@@ -155,7 +155,7 @@ mod tests { // A directory symlink pointing back at `real` would be re-scanned if followed. symlink(&real, base.join("alias")).unwrap(); let mut c = Counts::default();- walk(&base, &mut c).unwrap();+ super::walk(&base, &mut c).unwrap(); assert_eq!(c.files, 1, "the aliased directory was followed and counted twice"); std::fs::remove_dir_all(&base).ok(); }