diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
--- a/crates/ui-egui/src/docking.rs
+++ b/crates/ui-egui/src/docking.rs
@@ -252,8 +252,20 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result
 }
 
 /// `ui.dock` params: `{action}` or `{operation, …}` in craft-ui's shared grammar.
+/// `operation: "dock"` delegates to the shared side/anchor docking command.
 fn apply_command(workspace: &mut Workspace, params: &Value) -> Result<(), String> {
     validate(workspace)?;
+    if params.get("operation").and_then(Value::as_str) == Some("dock") && params.get("action").is_none() {
+        let mut command = params.clone();
+        command["command"] = json!("window.panel.dock");
+        let command: DockCommand<Panel> = serde_json::from_value(command).map_err(|error| error.to_string())?;
+        let mut next = workspace.clone();
+        let space = FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0)));
+        command.apply(&mut next, false, &space, permissions).map_err(|error| error.to_string())?;
+        validate(&next)?;
+        *workspace = next;
+        return Ok(());
+    }
     let operation = if let Some(action) = params.get("action") {
         UiDock::Action { action: serde_json::from_value(action.clone()).map_err(|error| error.to_string())? }
     } else {
@@ -416,6 +428,21 @@ impl DockContent<Panel> for Panels<'_> {
             }
         }
     }
+    fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &Panel) {
+        if !permissions(panel).movable {
+            return;
+        }
+        for (label, side) in [(crate::tl!("Dock Left"), Side::Start), (crate::tl!("Dock Right"), Side::End)] {
+            if ui.button(label).clicked() {
+                let result = self.app.run("ui.dock", json!({"operation": "dock", "panel": panel, "side": side}));
+                if let Err(error) = result {
+                    self.app.set_status(format!("Panel move was not applied: {error}"));
+                }
+                ui.close();
+            }
+        }
+        ui.separator();
+    }
     fn logical_label(&self, panel: &Panel) -> String {
         crate::i18n::t(panel.label()).into()
     }
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -1371,3 +1371,5 @@
 	Check for Updates…	Перевірити оновлення…
 	CADCraft is up to date.	Встановлено найновішу версію CADCraft.
 	Could not check for updates.	Не вдалося перевірити оновлення.
+	Dock Left	Прикріпити ліворуч
+	Dock Right	Прикріпити праворуч
diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs
--- a/crates/ui-egui/tests/it/docking_workflows.rs
+++ b/crates/ui-egui/tests/it/docking_workflows.rs
@@ -593,3 +593,57 @@ fn group_drag_bar_moves_the_whole_group_into_the_other_dock() {
     assert_eq!(frames(layout), [[Panel::Canvas]]);
     assert_eq!(h.state().session.doc().unwrap(), &document);
 }
+
+/// Side choices move the selected palette into a real dock column, without grouping it into
+/// another palette or moving the document; floating and persisted layouts use the same path.
+#[test]
+fn palette_side_menu_redocks_each_panel_without_grouping_the_drawing() {
+    let mut h = harness(None, 1600.0, 1.0);
+    h.state_mut().session.start("line").unwrap();
+    let before = h.state().session.doc().unwrap().model.len();
+    for panel in [Panel::ToolSets, Panel::Layers, Panel::Properties, Panel::QuickCalc, Panel::ExternalRefs] {
+        h.state_mut().run("ui.dock", json!({"operation":"open", "panel":panel})).unwrap();
+        h.state_mut().run("ui.dock", json!({"operation":"float", "panel":panel, "rect":[400,150,280,380]})).unwrap();
+        h.run_steps(5);
+        for (label, side) in [("Dock Left", Side::Start), ("Dock Right", Side::End)] {
+            let pos = hit(&h, Hit::Tab(panel)).center();
+            h.event(Event::PointerMoved(pos));
+            h.event(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE });
+            h.event(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
+            h.run_steps(4);
+            h.get_by_label(label).click();
+            h.run_steps(5);
+            assert!(
+                dock_panels(&h.state().ui.docking.layout, side).iter().flatten().any(|group| group == &[panel]),
+                "{panel:?} {side:?}: {:?}; status {:?}",
+                h.state().ui.docking,
+                h.state().status
+            );
+            assert_eq!(frames(&h.state().ui.docking.layout), [[Panel::Canvas]]);
+            assert!(!h.state().ui.docking.layout.floating.iter().any(|window| window.panels().any(|p| *p == panel)));
+            assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), Some("line"));
+            assert_eq!(h.state().session.doc().unwrap().model.len(), before);
+        }
+    }
+    let saved = serde_json::to_value(&h.state().ui).unwrap();
+    let restored: UiState = serde_json::from_value(saved).unwrap();
+    assert_eq!(restored.docking, h.state().ui.docking);
+}
+
+#[test]
+fn palette_side_command_preserves_siblings_and_rejects_protected_or_locked_moves() {
+    let mut h = harness(None, 1600.0, 1.0);
+    h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"center"})).unwrap();
+    h.state_mut().run("ui.dock", json!({"operation":"dock", "panel":"layers", "side":"Start"})).unwrap();
+    assert!(dock_panels(&h.state().ui.docking.layout, Side::Start).iter().flatten().any(|group| group == &[Panel::Layers]));
+    assert!(dock_panels(&h.state().ui.docking.layout, Side::End).iter().flatten().any(|group| group == &[Panel::Properties]));
+    for params in [json!({"operation":"dock", "panel":"canvas", "side":"Start"}), json!({"operation":"dock", "panel":"layers", "side":"invalid"})] {
+        let before = h.state().ui.docking.clone();
+        assert!(h.state_mut().run("ui.dock", params).is_err());
+        assert_eq!(h.state().ui.docking, before);
+    }
+    h.state_mut().ui.docking.locked = true;
+    let before = h.state().ui.docking.clone();
+    assert!(h.state_mut().run("ui.dock", json!({"operation":"dock", "panel":"layers", "side":"End"})).is_err());
+    assert_eq!(h.state().ui.docking, before);
+}
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -146,6 +146,12 @@ closing/reopening within the same session; they are not saved across application
 
 Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"layers","rect":[100,100,320,440]}}`.
 
+`{"operation":"dock","panel":"layers","side":"Start"}` moves the selected palette into
+its own left-side column; `"End"` selects the right side. The shared docking command creates
+the side dock when absent and adds a column when it already exists. This is also the route
+used by the palette context menu's Dock Left and Dock Right choices. Protected drawing
+placement and workspace locking still apply; an invalid request leaves the layout unchanged.
+
 Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
 `moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
 `size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
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 (explicit palette side docking) · **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
@@ -199,10 +199,25 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
   have menus too (#512). Still missing: in-place layout tab rename, Move or Copy, Select All
   Layouts.
 
+## Explicit palette side docking
+
+Palette tab context menus offer Dock Left and Dock Right in addition to the shared floating
+and grouping actions. Each choice moves that palette to a real side dock column through
+`ui.dock`; it does not turn the drawing into a tab group or move the palette's other tabs.
+The same menu works for floating palettes. Native workspace persistence stores the resulting
+layout across restarts. The browser host does not persist workspace layouts; reloading restores
+the default arrangement. The document view has no side-placement menu and retains its protected permissions.
+This CC-018 extraction uses the upstream shared docking implementation; auto-hide and
+Tool Sets-to-ribbon placement are separate follow-ups.
+AutoCAD 2027's Properties palette menu was observed on Windows: it offers Allow Docking,
+Anchor Left, Anchor Right and Auto-hide. These fixed-column actions are an incremental
+placement/recovery improvement, not a claim to reproduce anchoring or hover auto-hide.
+
 ## Revision history
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | CC-018: palette tab menu Dock Left/Right actions use shared side columns; independent of ribbon and auto-hide |
 | 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/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();
     }
