diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs
--- a/apps/cadcraft-web/src/main.rs
+++ b/apps/cadcraft-web/src/main.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_state
diff --git a/apps/cadcraft-web/src/panel_layout.rs b/apps/cadcraft-web/src/panel_layout.rs
new file mode 100644
--- /dev/null
+++ b/apps/cadcraft-web/src/panel_layout.rs
@@ -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);
+    }
+}
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -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) |
diff --git a/packaging/web/README.md b/packaging/web/README.md
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -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
diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs
--- a/xtask/src/parity.rs
+++ b/xtask/src/parity.rs
@@ -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");
diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs
--- a/xtask/src/stats.rs
+++ b/xtask/src/stats.rs
@@ -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();
     }
