diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@
 - 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
 - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (History panel and grouped icon flyouts; placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
 
 - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
@@ -12,6 +12,8 @@
 
 DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
 
+- **2026-10-25** · A floating panel docks flush to the left of the right icon bar, in line with its icon. No readiness percentage change.
+- **2026-10-25** · History panel lists every undo state, jumps to one, deletes later states, clears the list, and makes a new document from a state. Icon groups in a workspace open as one flyout attached beside that group, with a tab per panel, until the header is dragged free. No readiness percentage change.
 - **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual
   rewrite per distinct source before editing; reset to file defaults bypasses the check. Native
   format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change.
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -6,7 +6,7 @@ use designcraft_doc::{ItemId, Selection, SpreadRef};
 use serde_json::{Value, json};
 
 use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
-use crate::{HistoryEntry, Result, Session};
+use crate::{Result, Session};
 
 fn can_undo(s: &Session) -> std::result::Result<(), String> {
     s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
@@ -211,29 +211,12 @@ fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
 pub fn specs() -> Vec<CommandSpec> {
     vec![
         cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| {
-            let st = s.doc_mut()?;
-            let e = st.history.undo.pop().ok_or_else(|| super::bad("edit.undo", "nothing to undo"))?;
-            st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
-            let mut doc = e.doc;
-            super::rulers::keep_zero_point(&st.doc, &mut doc);
-            st.doc = doc;
-            st.selection = e.selection;
-            st.bump_revision();
-            // Pending typing overrides belong to the current edit state, not a restored snapshot.
-            s.typing_format = None;
-            Ok(json!({"undone": e.label}))
+            let label = super::history::undo_once(s)?;
+            Ok(json!({"undone": label}))
         }),
         cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
-            let st = s.doc_mut()?;
-            let e = st.history.redo.pop().ok_or_else(|| super::bad("edit.redo", "nothing to redo"))?;
-            st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
-            let mut doc = e.doc;
-            super::rulers::keep_zero_point(&st.doc, &mut doc);
-            st.doc = doc;
-            st.selection = e.selection;
-            st.bump_revision();
-            s.typing_format = None;
-            Ok(json!({"redone": e.label}))
+            let label = super::history::redo_once(s)?;
+            Ok(json!({"redone": label}))
         }),
         cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
             let ids = ids_param(p, "ids").unwrap_or_default();
diff --git a/crates/engine/src/cmd/history.rs b/crates/engine/src/cmd/history.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/cmd/history.rs
@@ -0,0 +1,471 @@
+//! History panel: every undo state, as a list the panel can jump, trim, and branch from.
+//!
+//! `document.history` stays `{undo, redo}` label arrays. The panel reads `document.history.states`.
+
+use std::sync::Arc;
+
+use designcraft_doc::{Document, Selection};
+use serde_json::{Value, json};
+
+use super::{CommandSpec, bad, cmd, has_doc};
+use crate::{DocState, HistoryEntry, Result, Session};
+
+pub fn specs() -> Vec<CommandSpec> {
+    vec![
+        cmd!(
+            noundo "edit.history.go",
+            "Go to History State",
+            [],
+            None,
+            "{index: number}",
+            has_doc,
+            |s, p| go(s, p)
+        ),
+        cmd!(
+            noundo "edit.history.delete",
+            "Delete History State",
+            [],
+            None,
+            "{index?: number}",
+            has_doc,
+            |s, p| delete_state(s, p)
+        ),
+        cmd!(noundo "edit.history.clear", "Clear History", [], None, "{}", has_doc, |s, _| clear(s)),
+        cmd!(
+            noundo "edit.history.newDocument",
+            "New Document",
+            [],
+            None,
+            "{index?: number}",
+            has_doc,
+            |s, p| new_document(s, p)
+        ),
+        cmd!(query "document.history.states", "History States", [], None, "{}", has_doc, |s, _| states(s)),
+    ]
+}
+
+/// One undo step, shared with Edit › Undo. Does not leave data-merge preview.
+pub(crate) fn undo_once(s: &mut Session) -> Result<String> {
+    let st = s.doc_mut()?;
+    let e = st.history.undo.pop().ok_or_else(|| bad("edit.undo", "nothing to undo"))?;
+    let label = e.label.clone();
+    st.history.redo.push(HistoryEntry { label: label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
+    let mut doc = e.doc;
+    super::rulers::keep_zero_point(&st.doc, &mut doc);
+    st.doc = doc;
+    st.selection = e.selection;
+    st.bump_revision();
+    // Pending typing overrides belong to the current edit state, not a restored snapshot.
+    s.typing_format = None;
+    Ok(label)
+}
+
+/// One redo step, shared with Edit › Redo.
+pub(crate) fn redo_once(s: &mut Session) -> Result<String> {
+    let st = s.doc_mut()?;
+    let e = st.history.redo.pop().ok_or_else(|| bad("edit.redo", "nothing to redo"))?;
+    let label = e.label.clone();
+    st.history.undo.push(HistoryEntry { label: label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
+    let mut doc = e.doc;
+    super::rulers::keep_zero_point(&st.doc, &mut doc);
+    st.doc = doc;
+    st.selection = e.selection;
+    st.bump_revision();
+    s.typing_format = None;
+    Ok(label)
+}
+
+fn go(s: &mut Session, p: &Value) -> Result<Value> {
+    let (current, max) = lengths(s)?;
+    let index = required_index("edit.history.go", p)?;
+    if index > max {
+        return Err(bad("edit.history.go", "history state is out of range"));
+    }
+    if index < current {
+        for _ in index..current {
+            undo_once(s)?;
+        }
+    } else if index > current {
+        for _ in current..index {
+            redo_once(s)?;
+        }
+    }
+    Ok(json!({"index": index}))
+}
+
+fn delete_state(s: &mut Session, p: &Value) -> Result<Value> {
+    let (current, max) = lengths(s)?;
+    let index = optional_index("edit.history.delete", p, current)?;
+    if index == 0 {
+        return Err(bad("edit.history.delete", "the first history state stays"));
+    }
+    if index > max {
+        return Err(bad("edit.history.delete", "history state is out of range"));
+    }
+    let target = index - 1;
+    if current > target {
+        for _ in target..current {
+            undo_once(s)?;
+        }
+        s.doc_mut()?.history.redo.clear();
+    } else if current == target {
+        s.doc_mut()?.history.redo.clear();
+    } else {
+        let keep = target - current;
+        let redo = &mut s.doc_mut()?.history.redo;
+        let drop_n = redo.len().saturating_sub(keep);
+        redo.drain(0..drop_n);
+    }
+    let index = s.doc()?.history.undo.len();
+    Ok(json!({"index": index}))
+}
+
+fn clear(s: &mut Session) -> Result<Value> {
+    s.stop_data_preview();
+    let st = s.doc_mut()?;
+    if st.history.undo.is_empty() && st.history.redo.is_empty() {
+        return Ok(json!({"cleared": true}));
+    }
+    if st.history.undo.is_empty() && st.history.origin.is_none() {
+        st.history.redo.clear();
+        return Ok(json!({"cleared": true}));
+    }
+    let label = st.history.undo.last().map(|e| e.label.clone()).or_else(|| st.history.origin.clone()).unwrap_or_else(|| "New".to_string());
+    st.history.origin = Some(label);
+    st.history.undo.clear();
+    st.history.redo.clear();
+    Ok(json!({"cleared": true}))
+}
+
+fn new_document(s: &mut Session, p: &Value) -> Result<Value> {
+    let (current, _) = lengths(s)?;
+    let index = optional_index("edit.history.newDocument", p, current)?;
+    let (doc, selection) = {
+        let st = s.doc()?;
+        snapshot(st, index).ok_or_else(|| bad("edit.history.newDocument", "history state is out of range"))?
+    };
+    s.untitled += 1;
+    let mut inner = (*doc).clone();
+    inner.title = format!("Untitled-{}", s.untitled);
+    let mut state = DocState::new(inner, None);
+    state.selection = selection;
+    state.saved_doc = Arc::new((*state.doc).clone());
+    let i = s.add_document(state);
+    Ok(json!({"index": i}))
+}
+
+fn states(s: &Session) -> Result<Value> {
+    let st = s.doc()?;
+    let current = st.history.undo.len();
+    let (base_label, base_kind) = baseline(st);
+    let mut rows = Vec::with_capacity(current.saturating_add(st.history.redo.len()).saturating_add(1));
+    rows.push(json!({
+        "index": 0,
+        "label": base_label,
+        "kind": base_kind,
+        "current": current == 0,
+        "future": false,
+    }));
+    for (i, entry) in st.history.undo.iter().enumerate() {
+        let index = i + 1;
+        rows.push(json!({
+            "index": index,
+            "label": entry.label,
+            "kind": "action",
+            "current": index == current,
+            "future": false,
+        }));
+    }
+    let redo_len = st.history.redo.len();
+    for step in 1..=redo_len {
+        let Some(entry) = st.history.redo.get(redo_len - step) else { continue };
+        let index = current + step;
+        rows.push(json!({
+            "index": index,
+            "label": entry.label,
+            "kind": "action",
+            "current": false,
+            "future": true,
+        }));
+    }
+    Ok(json!({"current": current, "states": rows}))
+}
+
+fn lengths(s: &mut Session) -> Result<(usize, usize)> {
+    s.stop_data_preview();
+    let st = s.doc()?;
+    Ok((st.history.undo.len(), st.history.undo.len().saturating_add(st.history.redo.len())))
+}
+
+fn baseline(st: &DocState) -> (String, &'static str) {
+    if let Some(label) = &st.history.origin {
+        return (label.clone(), "action");
+    }
+    if let Some(name) = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()) {
+        return (name.to_string_lossy().into_owned(), "file");
+    }
+    ("New".to_string(), "new")
+}
+
+/// Document and selection at `index` without moving the current state.
+fn snapshot(st: &DocState, index: usize) -> Option<(Arc<Document>, Selection)> {
+    let current = st.history.undo.len();
+    let max = current.saturating_add(st.history.redo.len());
+    if index > max {
+        return None;
+    }
+    if index == current {
+        return Some((st.doc.clone(), st.selection.clone()));
+    }
+    if index < current {
+        let entry = st.history.undo.get(index)?;
+        return Some((entry.doc.clone(), entry.selection.clone()));
+    }
+    let step = index - current;
+    let entry = st.history.redo.get(st.history.redo.len().checked_sub(step)?)?;
+    Some((entry.doc.clone(), entry.selection.clone()))
+}
+
+fn required_index(cmd: &str, p: &Value) -> Result<usize> {
+    match p.get("index") {
+        Some(n) => parse_index(cmd, n),
+        _ => Err(bad(cmd, "index must be a state number")),
+    }
+}
+
+fn optional_index(cmd: &str, p: &Value, current: usize) -> Result<usize> {
+    match p.get("index") {
+        None | Some(Value::Null) => Ok(current),
+        Some(n) => parse_index(cmd, n),
+    }
+}
+
+fn parse_index(cmd: &str, n: &Value) -> Result<usize> {
+    let Some(u) = n.as_u64() else {
+        return Err(bad(cmd, "index must be a state number"));
+    };
+    usize::try_from(u).map_err(|_| bad(cmd, "index is out of range"))
+}
+
+#[cfg(test)]
+mod tests {
+    use serde_json::{Value, json};
+
+    use crate::Session;
+
+    fn items(s: &Session) -> usize {
+        s.doc().unwrap().doc.spreads[0].items.len()
+    }
+
+    fn states(s: &mut Session) -> Value {
+        s.execute("document.history.states", &json!({})).unwrap()
+    }
+
+    fn labels(v: &Value) -> Vec<String> {
+        v["states"].as_array().unwrap().iter().map(|row| row["label"].as_str().unwrap().to_string()).collect()
+    }
+
+    fn frame(s: &mut Session) {
+        s.execute("frame.create", &json!({"rect": [36.0, 36.0, 136.0, 136.0]})).unwrap();
+    }
+
+    #[test]
+    fn a_new_document_has_one_state_named_new() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(v["current"], 0);
+        assert_eq!(labels(&v), ["New"]);
+        assert_eq!(v["states"][0]["kind"], "new");
+        assert_eq!(v["states"][0]["current"], true);
+        assert_eq!(v["states"][0]["future"], false);
+        assert_eq!(s.execute("document.history", &json!({})).unwrap(), json!({"undo": [], "redo": []}));
+    }
+
+    #[test]
+    fn two_frames_list_new_then_each_action_and_undo_stays_one_step() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        let v = states(&mut s);
+        assert_eq!(labels(&v), ["New", "Create Frame", "Create Frame"]);
+        assert_eq!(v["current"], 2);
+        assert_eq!(v["states"][2]["current"], true);
+        assert_eq!(items(&s), 2);
+        assert_eq!(s.execute("document.history", &json!({})).unwrap(), json!({"undo": ["Create Frame", "Create Frame"], "redo": []}));
+        assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Create Frame"}));
+        assert_eq!(s.doc().unwrap().history.undo.len(), 1);
+        assert_eq!(s.doc().unwrap().history.redo.len(), 1);
+        assert_eq!(items(&s), 1);
+    }
+
+    #[test]
+    fn go_moves_between_states_and_rejects_a_missing_one() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        s.execute("edit.history.go", &json!({"index": 1})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(v["current"], 1);
+        assert_eq!(items(&s), 1);
+        assert_eq!(v["states"][2]["future"], true);
+        s.execute("edit.history.go", &json!({"index": 0})).unwrap();
+        assert_eq!(states(&mut s)["current"], 0);
+        assert_eq!(items(&s), 0);
+        let err = s.execute("edit.history.go", &json!({"index": 9})).unwrap_err();
+        assert!(err.to_string().contains("out of range"), "{err}");
+        assert_eq!(items(&s), 0);
+        assert_eq!(s.doc().unwrap().history.undo.len(), 0);
+    }
+
+    #[test]
+    fn delete_refuses_the_first_state_and_drops_the_current_state_onward() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        let err = s.execute("edit.history.delete", &json!({"index": 0})).unwrap_err();
+        assert!(err.to_string().contains("first history state"), "{err}");
+        assert_eq!(items(&s), 2);
+        assert_eq!(s.doc().unwrap().history.undo.len(), 2);
+        s.execute("edit.history.delete", &json!({"index": 2})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(v["current"], 1);
+        assert_eq!(labels(&v), ["New", "Create Frame"]);
+        assert!(s.doc().unwrap().history.redo.is_empty());
+        assert_eq!(items(&s), 1);
+    }
+
+    #[test]
+    fn delete_of_a_future_state_truncates_redo_without_moving() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        s.execute("edit.history.go", &json!({"index": 0})).unwrap();
+        assert_eq!(items(&s), 0);
+        s.execute("edit.history.delete", &json!({"index": 2})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(v["current"], 0);
+        assert_eq!(items(&s), 0);
+        assert_eq!(s.doc().unwrap().history.redo.len(), 1);
+        assert_eq!(labels(&v), ["New", "Create Frame"]);
+        assert_eq!(v["states"][1]["future"], true);
+    }
+
+    #[test]
+    fn clear_keeps_the_document_and_names_the_remaining_state() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        s.execute("edit.history.clear", &json!({})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(items(&s), 2);
+        assert_eq!(labels(&v), ["Create Frame"]);
+        assert_eq!(v["states"][0]["kind"], "action");
+        assert_eq!(v["current"], 0);
+        assert!(s.doc().unwrap().history.undo.is_empty());
+        assert!(s.doc().unwrap().history.redo.is_empty());
+    }
+
+    #[test]
+    fn clear_of_an_undone_document_with_no_origin_stays_new() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        s.execute("edit.undo", &json!({})).unwrap();
+        s.execute("edit.history.clear", &json!({})).unwrap();
+        let v = states(&mut s);
+        assert_eq!(labels(&v), ["New"]);
+        assert_eq!(v["states"][0]["kind"], "new");
+        assert!(s.doc().unwrap().history.origin.is_none());
+        assert!(s.doc().unwrap().history.redo.is_empty());
+        assert_eq!(items(&s), 0);
+    }
+
+    #[test]
+    fn new_document_copies_one_state_and_leaves_the_source_alone() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        frame(&mut s);
+        let untitled = s.untitled;
+        let made = s.execute("edit.history.newDocument", &json!({"index": 1})).unwrap();
+        assert_eq!(made["index"], 1);
+        assert_eq!(s.active_index(), Some(1));
+        assert_eq!(s.untitled, untitled + 1);
+        let new_doc = &s.documents()[1];
+        assert_eq!(new_doc.title(), "Untitled-2");
+        assert!(new_doc.is_dirty());
+        assert!(new_doc.path.is_none());
+        assert!(new_doc.history.undo.is_empty());
+        assert!(new_doc.history.redo.is_empty());
+        assert!(new_doc.fonts.is_none());
+        assert_eq!(new_doc.doc.spreads[0].items.len(), 1);
+        let source = &s.documents()[0];
+        assert_eq!(source.history.undo.len(), 2);
+        assert!(source.history.redo.is_empty());
+        assert_eq!(source.doc.spreads[0].items.len(), 2);
+    }
+
+    #[test]
+    fn a_bad_new_document_index_does_not_take_an_untitled_number() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let untitled = s.untitled;
+        assert!(s.execute("edit.history.newDocument", &json!({"index": 4})).is_err());
+        assert_eq!(s.untitled, untitled);
+        assert_eq!(s.documents().len(), 1);
+    }
+
+    #[test]
+    fn the_limit_names_the_dropped_state_and_never_empties_undo() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        s.doc_mut().unwrap().history.limit = 1;
+        frame(&mut s);
+        frame(&mut s);
+        frame(&mut s);
+        assert_eq!(s.doc().unwrap().history.undo.len(), 1);
+        assert_eq!(s.doc().unwrap().history.origin.as_deref(), Some("Create Frame"));
+        let v = states(&mut s);
+        assert_eq!(v["states"][0]["label"], "Create Frame");
+        assert_eq!(v["states"][0]["kind"], "action");
+        assert_ne!(v["states"][0]["label"], "New");
+        s.doc_mut().unwrap().history.limit = 0;
+        frame(&mut s);
+        assert_eq!(s.doc().unwrap().history.undo.len(), 1);
+    }
+
+    #[test]
+    fn a_saved_path_names_the_first_state_until_history_is_trimmed() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        s.doc_mut().unwrap().path = Some("/tmp/Sample.designcraft".into());
+        let v = states(&mut s);
+        assert_eq!(v["states"][0]["label"], "Sample.designcraft");
+        assert_eq!(v["states"][0]["kind"], "file");
+    }
+
+    #[test]
+    fn hostile_indexes_are_rejected() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        frame(&mut s);
+        for (cmd, value) in [
+            ("edit.history.go", json!({"index": -1})),
+            ("edit.history.go", json!({"index": 1.5})),
+            ("edit.history.go", json!({"index": u64::MAX})),
+            ("edit.history.go", json!({})),
+            ("edit.history.delete", json!({"index": -1})),
+            ("edit.history.delete", json!({"index": 1.5})),
+        ] {
+            assert!(s.execute(cmd, &value).is_err(), "{cmd} {value}");
+        }
+        assert_eq!(s.doc().unwrap().history.undo.len(), 1);
+        assert_eq!(items(&s), 1);
+    }
+}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -17,6 +17,7 @@ mod endnotes;
 mod export;
 mod file;
 pub(crate) mod find;
+mod history;
 pub use edit::copied_objects_text;
 pub use find::same_find_domain;
 pub(crate) mod flex;
@@ -172,6 +173,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
         v.extend(interchange::specs());
         v.extend(export::specs());
         v.extend(edit::specs());
+        v.extend(history::specs());
         v.extend(paste_svg::specs());
         v.extend(endnotes::specs());
         v.extend(editorial::specs());
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -72,6 +72,8 @@ pub struct History {
     pub undo: Vec<HistoryEntry>,
     pub redo: Vec<HistoryEntry>,
     pub limit: usize,
+    /// Name of the oldest row after the undo limit drops it, or after Clear History.
+    pub origin: Option<String>,
 }
 
 #[derive(Clone, Debug)]
@@ -747,8 +749,10 @@ impl Session {
 fn push_undo(st: &mut DocState, e: HistoryEntry) {
     st.history.undo.push(e);
     st.history.redo.clear();
-    if st.history.undo.len() > st.history.limit.max(1) {
-        st.history.undo.remove(0);
+    let cap = st.history.limit.max(1);
+    while st.history.undo.len() > cap {
+        let dropped = st.history.undo.remove(0);
+        st.history.origin = Some(dropped.label);
     }
 }
 
diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs
--- a/crates/ui-egui/src/dock.rs
+++ b/crates/ui-egui/src/dock.rs
@@ -1,5 +1,7 @@
 //! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon
-//! column whose panels open as flyouts beside the dock.
+//! column whose panels open as flyouts beside the dock. A workspace icon group opens as one
+//! flyout, with a tab for each panel in the group. The flyout stays attached to that group's
+//! icon; dragging its header pulls it off into a floating window.
 
 use egui::{Color32, Sense, Stroke, vec2};
 
@@ -12,6 +14,7 @@ pub const DOCK_TABS: &[(&str, &str, &str)] =
 
 /// Panels in the collapsed icon column.
 pub const ICON_PANELS: &[(&str, &str, &str)] = &[
+    ("history", "History", "panel-history"),
     ("stroke", "Stroke", "panel-stroke"),
     ("swatches", "Swatches", "panel-swatches"),
     ("color", "Color", "panel-color"),
@@ -129,51 +132,68 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
     // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups
     // (Essentials Classic) show their names.
     let groups = app.ui.icon_groups.clone();
-    egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
-        if dock_header(ui, &t, "«").clicked() {
-            app.ui.dock_expanded = true;
-        }
-        ui.add_space(4.0);
-        match &groups {
-            Some(groups) => {
-                for (i, group) in groups.iter().enumerate() {
-                    if i > 0 {
-                        let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
-                        ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
-                    }
-                    for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) {
-                        let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id);
-                        let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());
-                        if on {
-                            ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active);
-                        } else if response.hovered() {
-                            ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover);
+    let icon_rect = egui::Panel::right("icon_column")
+        .exact_size(icon_column_width(app))
+        .resizable(false)
+        .frame(egui::Frame::NONE.fill(t.panel))
+        .show(ui, |ui| {
+            if dock_header(ui, &t, "«").clicked() {
+                app.ui.dock_expanded = true;
+            }
+            ui.add_space(4.0);
+            match &groups {
+                Some(groups) => {
+                    for (i, group) in groups.iter().enumerate() {
+                        if i > 0 {
+                            let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
+                            ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border));
+                        }
+                        for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) {
+                            let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id);
+                            let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());
+                            if on {
+                                ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active);
+                            } else if response.hovered() {
+                                ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover);
+                            }
+                            icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon);
+                            crate::rtl::paint(
+                                ui.painter(),
+                                r.left_center() + vec2(32.0, 0.0),
+                                egui::Align2::LEFT_CENTER,
+                                crate::i18n::tr(&app.ui.language, label),
+                                semibold(11.5),
+                                t.text,
+                            );
+                            remember_icon(ui, id, r);
+                            icon_clicked(app, ui, id, &response);
                         }
-                        icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon);
-                        crate::rtl::paint(
-                            ui.painter(),
-                            r.left_center() + vec2(32.0, 0.0),
-                            egui::Align2::LEFT_CENTER,
-                            crate::i18n::tr(&app.ui.language, label),
-                            semibold(11.5),
-                            t.text,
-                        );
-                        icon_clicked(app, ui, id, &response);
                     }
                 }
+                None => {
+                    ui.vertical_centered(|ui| {
+                        for (id, label, icon) in ICON_PANELS {
+                            let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id);
+                            let response =
+                                icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());
+                            remember_icon(ui, id, response.rect);
+                            icon_clicked(app, ui, id, &response);
+                            ui.add_space(1.0);
+                        }
+                    });
+                }
             }
-            None => {
-                ui.vertical_centered(|ui| {
-                    for (id, label, icon) in ICON_PANELS {
-                        let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id);
-                        let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag());
-                        icon_clicked(app, ui, id, &response);
-                        ui.add_space(1.0);
-                    }
-                });
-            }
-        }
-    });
+        })
+        .response
+        .rect;
+    // The flyout sits against whichever column is furthest left: the named icons, or History beside them.
+    let bar = if side_column_width(app) > 0.0 {
+        let history = history_column(app, ui, &t);
+        if history.min.x < icon_rect.min.x { history } else { icon_rect }
+    } else {
+        icon_rect
+    };
+    remember_right_bar(ui.ctx(), bar);
 }
 
 /// Width of the icon column: icons only, or icons with names for a workspace's groups.
@@ -186,15 +206,87 @@ fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> {
     DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied()
 }
 
+/// Panels in `groups` that share a column with `id`, in that column's order.
+pub(crate) fn icon_group_ids(groups: &[Vec<String>], id: &str) -> Vec<&'static str> {
+    groups
+        .iter()
+        .find(|group| group.iter().any(|panel| panel == id))
+        .map(|group| group.iter().filter_map(|panel| panel_entry(panel).map(|entry| entry.0)).collect())
+        .unwrap_or_default()
+}
+
+/// Width of the history icon column beside a workspace's named groups. Essentials has no groups
+/// and lists History in the main icon column instead.
+pub(crate) fn side_column_width(app: &DesignApp) -> f32 {
+    if app.ui.icon_groups.as_ref().is_some_and(|groups| !groups.iter().flatten().any(|panel| panel == "history")) { 37.0 } else { 0.0 }
+}
+
+/// Raise a floating panel, or open and close its flyout.
+fn toggle_open(app: &mut DesignApp, ui: &egui::Ui, id: &str) {
+    if app.ui.floating.iter().any(|(panel, _)| panel == id) {
+        ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id))));
+    } else {
+        let open = app.ui.open_panel.as_deref() == Some(id);
+        app.ui.open_panel = if open { None } else { Some(id.to_string()) };
+    }
+}
+
+fn history_column(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens) -> egui::Rect {
+    egui::Panel::right("history_column")
+        .exact_size(37.0)
+        .resizable(false)
+        .frame(egui::Frame::NONE.fill(t.panel))
+        .show(ui, |ui| {
+            if dock_header(ui, t, "«").clicked() {
+                toggle_open(app, ui, "history");
+            }
+            ui.add_space(4.0);
+            ui.vertical_centered(|ui| {
+                let on = app.ui.open_panel.as_deref() == Some("history") || app.ui.floating.iter().any(|(panel, _)| panel == "history");
+                let response =
+                    icons::button(ui, "panel-history", 28.0, on, crate::i18n::tr(&app.ui.language, "History")).interact(Sense::click_and_drag());
+                remember_icon(ui, "history", response.rect);
+                icon_clicked(app, ui, "history", &response);
+            });
+        })
+        .response
+        .rect
+}
+
+/// Where an icon was drawn, so its flyout can sit beside that row.
+pub(crate) fn remember_icon(ui: &egui::Ui, id: &str, rect: egui::Rect) {
+    ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new(("icon-row", id)), rect));
+}
+
+/// The right icon bar for this frame, so a flyout and a drop can meet its left edge.
+pub(crate) fn remember_right_bar(ctx: &egui::Context, rect: egui::Rect) {
+    if rect.width() > 1.0 && rect.height() > 1.0 && rect.min.x.is_finite() && rect.min.y.is_finite() {
+        ctx.data_mut(|data| data.insert_temp(egui::Id::new("right-icon-bar"), rect));
+    }
+}
+
+/// Icon bar drawn last frame (during a drag) or earlier this frame (when the flyout opens).
+pub(crate) fn right_bar(ctx: &egui::Context) -> Option<egui::Rect> {
+    ctx.data(|data| data.get_temp(egui::Id::new("right-icon-bar")))
+}
+
+/// The icon bar, plus a gutter on its left. Releasing a floating panel here docks it.
+pub(crate) fn beside_bar_zone(bar: egui::Rect) -> egui::Rect {
+    egui::Rect::from_min_max(egui::pos2(bar.min.x - 48.0, bar.min.y), bar.max)
+}
+
+/// Top of the flyout: the first icon in the group, or this panel's own icon.
+fn flyout_y(ctx: &egui::Context, anchor: &str) -> f32 {
+    ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", anchor))).map(|rect| rect.min.y))
+        .or_else(|| right_bar(ctx).map(|rect| rect.min.y))
+        .or_else(|| ctx.memory(|memory| memory.area_rect(egui::Id::new("icon_column")).map(|rect| rect.min.y)))
+        .unwrap_or(120.0)
+}
+
 /// A click on a panel's icon: raise it when it floats, else open or close its flyout.
 fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) {
     if response.clicked() {
-        if app.ui.floating.iter().any(|(p, _)| p == id) {
-            ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id))));
-        } else {
-            let open = app.ui.open_panel.as_deref() == Some(id);
-            app.ui.open_panel = if open { None } else { Some(id.to_string()) };
-        }
+        toggle_open(app, ui, id);
     }
     crate::panel_docking::legacy_tab(app, ui, id, response);
 }
@@ -208,60 +300,192 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response {
     response
 }
 
-/// A panel opened from the icon column, shown as a floating flyout next to the dock.
+/// A panel opened from the icon column, attached beside that icon. It is not a free window.
+///
+/// A group of panels uses the header as its tab strip and lines up with the group's first icon.
+/// Dragging the header pulls the panel off the dock. A later click on the canvas closes it;
+/// the click that opened it does not, because that id was not yet remembered.
 pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) {
-    if app.ui.docking.is_some() {
+    let remembered = ctx.data(|data| data.get_temp::<String>(egui::Id::new("flyout-open-id")));
+    let Some(id) = app.ui.open_panel.clone() else {
+        if remembered.is_some() {
+            ctx.data_mut(|data| data.remove::<String>(egui::Id::new("flyout-open-id")));
+        }
         return;
-    }
-    let Some(id) = app.ui.open_panel.clone() else { return };
+    };
     let t = Tokens::get(ctx);
-    let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned();
+    let groups = app.ui.icon_groups.clone().unwrap_or_default();
+    let members = icon_group_ids(&groups, &id);
+    let grouped = members.len() > 1;
+    let width = if grouped { 312.0 } else { 268.0 };
     let screen = ctx.content_rect();
-    let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };
-    let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0);
+    // Flush with the icon bar's left edge. The expanded dock, when it is open, stays to the right of that bar.
+    let x = right_bar(ctx).map(|bar| (bar.min.x - width).max(screen.min.x)).unwrap_or_else(|| {
+        let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 };
+        (screen.max.x - icon_column_width(app) - side_column_width(app) - dock_w - width).max(screen.min.x)
+    });
+    let anchor = members.first().copied().unwrap_or(id.as_str());
+    let y = flyout_y(ctx, anchor);
+    let pos = egui::pos2(x, y);
     let mut open = true;
-    egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
+    let mut shown = id.clone();
+    // Middle keeps the flyout above the page and the dock. Menus, dialogs and floating
+    // windows are Foreground, so they stay above it.
+    let area = egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
         egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| {
-            ui.set_width(256.0);
-            // A previous shorter flyout must not constrain the next panel's scroll viewport.
+            ui.set_width(width);
             ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0));
-            let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
-            ui.painter().rect_filled(strip, 0.0, t.panel_darker);
-            crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong);
-            let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0));
-            if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() {
+            let stay = if grouped { group_strip(app, ui, &t, width, &members, &mut shown) } else { single_strip(app, ui, &t, width, &shown, pos) };
+            if !stay {
                 open = false;
+                return;
             }
-            ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
-            // Float: the button beside the close box, or drag the header away from the dock.
-            let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0));
-            let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| {
-                crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel"));
-            });
-            float_glyph(ui.painter(), float_r, t.text_dim);
-            let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y));
-            let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag());
-            let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0);
-            if fr.clicked() || torn {
-                let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) };
-                float_panel(app, &id, at);
+            if app.ui.open_panel.is_none() {
                 return;
             }
-            egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
-                scrolled_body(app, ui, &id, screen.height() - 220.0);
+            let room = (screen.max.y - pos.y - 48.0).clamp(80.0, 640.0);
+            // Each panel hugs its content. History sizes from its rows. The others scroll only
+            // once the body is taller than the space left beside the icon.
+            let (body_h, margin) =
+                if shown == "history" { (history_flyout_body(app, room.min(480.0)), egui::Margin::ZERO) } else { (room, egui::Margin::same(10)) };
+            egui::Frame::NONE.inner_margin(margin).show(ui, |ui| {
+                scrolled_body(app, ui, &shown, body_h);
             });
         });
     });
     if !open {
         app.ui.open_panel = None;
     }
+    let flyout_rect = area.response.rect;
+    let click = ctx.input(|input| input.pointer.interact_pos().filter(|_| input.pointer.primary_clicked()));
+    let on_canvas = click.is_some_and(|point| app.canvas_rect.is_some_and(|rect| rect.contains(point)) && !flyout_rect.contains(point));
+    // After the body, so a menu drawn from this flyout (it can extend past the flyout) counts
+    // as open. The click that dismisses that menu does not also dismiss the flyout.
+    let popup_open = ctx.any_popup_open();
+    if app.ui.open_panel.as_deref() == Some(id.as_str()) && remembered.as_deref() == Some(id.as_str()) && on_canvas && !popup_open {
+        app.ui.open_panel = None;
+    }
+    if let Some(current) = app.ui.open_panel.clone() {
+        ctx.data_mut(|data| data.insert_temp(egui::Id::new("flyout-open-id"), current));
+    } else {
+        ctx.data_mut(|data| data.remove::<String>(egui::Id::new("flyout-open-id")));
+    }
+}
+
+/// Group header: one tab per panel, then collapse and the panel menu. `false` closes the flyout.
+fn group_strip(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens, width: f32, members: &[&str], shown: &mut String) -> bool {
+    let (strip, _) = ui.allocate_exact_size(vec2(width, 28.0), Sense::hover());
+    ui.painter().rect_filled(strip, 0.0, t.tab_strip);
+    let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 5.0), vec2(18.0, 18.0));
+    let collapse_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 5.0), vec2(18.0, 18.0));
+    let tabs_left = strip.min.x + 2.0;
+    let avail = (collapse_r.min.x - 2.0 - tabs_left).max(40.0);
+    let font = semibold(11.5);
+    let lang = app.ui.language.clone();
+    let mut widths: Vec<f32> = members
+        .iter()
+        .map(|panel| {
+            let text = crate::i18n::tr(&lang, panel_label(panel));
+            let galley = crate::rtl::plain(ui.ctx(), text, font.clone(), t.text);
+            galley.size().x + if *panel == shown.as_str() { 28.0 } else { 16.0 }
+        })
+        .collect();
+    if widths.iter().copied().sum::<f32>() > avail {
+        let each = avail / widths.len().max(1) as f32;
+        widths.fill(each);
+    }
+    let mut rects = Vec::with_capacity(members.len());
+    let mut x = tabs_left;
+    let mut torn: Option<&str> = None;
+    for (panel, tab_w) in members.iter().copied().zip(widths.iter().copied()) {
+        let rect = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(tab_w, strip.height()));
+        let response = ui.interact(rect, ui.id().with(("flytab", panel)), Sense::click_and_drag());
+        if response.clicked() {
+            app.ui.open_panel = Some(panel.to_string());
+            *shown = panel.to_string();
+        }
+        if response.drag_stopped() && response.total_drag_delta().is_some_and(|delta| delta.length() > 24.0) {
+            torn = Some(panel);
+        }
+        rects.push(rect);
+        x += tab_w;
+    }
+    if let Some(panel) = torn {
+        let at = ui.ctx().pointer_latest_pos().unwrap_or(strip.min) - vec2(20.0, 10.0);
+        float_panel(app, panel, at);
+        return true;
+    }
+    for (panel, rect) in members.iter().copied().zip(rects) {
+        let active = panel == shown.as_str();
+        if active {
+            ui.painter().rect_filled(rect, 0.0, t.panel);
+            diamond(ui.painter(), rect.left_center() + vec2(8.0, 0.0), 3.0, t.accent);
+        }
+        let color = if active { t.text_strong } else { t.text_dim };
+        let text_x = rect.min.x + if active { 16.0 } else { 6.0 };
+        let label = crate::i18n::tr(&lang, panel_label(panel));
+        let galley = crate::rtl::truncated(ui.ctx(), label, font.clone(), color, (rect.max.x - 4.0 - text_x).max(8.0));
+        ui.painter().galley(egui::pos2(text_x, rect.center().y - galley.size().y / 2.0), galley, color);
+    }
+    ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
+    let collapse = ui.interact(collapse_r, ui.id().with("flycollapse"), Sense::click());
+    if collapse.hovered() {
+        ui.painter().rect_filled(collapse_r, 3.0, t.hover);
+    }
+    ui.painter().text(collapse_r.center(), egui::Align2::CENTER_CENTER, "»", egui::FontId::proportional(12.0), t.text_dim);
+    crate::panel_docking::header_menu(app, ui, menu_r, shown);
+    !collapse.clicked()
+}
+
+/// One panel: title, menu, float, close. `false` closes the flyout.
+fn single_strip(app: &mut DesignApp, ui: &mut egui::Ui, t: &Tokens, width: f32, id: &str, pos: egui::Pos2) -> bool {
+    let label = crate::i18n::tr(&app.ui.language, panel_label(id)).to_owned();
+    let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
+    ui.painter().rect_filled(strip, 0.0, t.panel_darker);
+    crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong);
+    let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0));
+    let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0));
+    let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 66.0, strip.min.y + 4.0), vec2(18.0, 18.0));
+    crate::panel_docking::header_menu(app, ui, menu_r, id);
+    let closed = ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked();
+    ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
+    if closed {
+        return false;
+    }
+    let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| {
+        crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel"));
+    });
+    float_glyph(ui.painter(), float_r, t.text_dim);
+    let head = egui::Rect::from_min_max(strip.min, egui::pos2(menu_r.min.x - 2.0, strip.max.y));
+    let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag());
+    let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|delta| delta.length() > 24.0);
+    if fr.clicked() || torn {
+        let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) };
+        float_panel(app, id, at);
+    }
+    true
+}
+
+/// Height of the History flyout body: one row per state, plus the footer, never past `max_h`.
+fn history_flyout_body(app: &DesignApp, max_h: f32) -> f32 {
+    let rows = app.session.active().map(|state| state.history.undo.len() + state.history.redo.len() + 1).unwrap_or(1);
+    (rows as f32 * 22.0 + 36.0).clamp(72.0, max_h.max(72.0))
+}
+
+fn diamond(painter: &egui::Painter, center: egui::Pos2, radius: f32, color: Color32) {
+    painter.add(egui::Shape::convex_polygon(
+        vec![center + vec2(0.0, -radius), center + vec2(radius, 0.0), center + vec2(0.0, radius), center + vec2(-radius, 0.0)],
+        color,
+        Stroke::NONE,
+    ));
 }
 
 /// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its
 /// parts itself and keeps its footer at the bottom, so it gets the height instead.
 fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) {
     if scrolls_itself(id) {
-        ui.allocate_ui(vec2(ui.available_width(), max_h.clamp(160.0, 560.0)), |ui| panel_body(app, ui, id));
+        let min = if id == "history" { 72.0 } else { 160.0 };
+        ui.allocate_ui(vec2(ui.available_width(), max_h.clamp(min, 560.0)), |ui| panel_body(app, ui, id));
     } else {
         egui::ScrollArea::vertical().max_height(max_h).show(ui, |ui| panel_body(app, ui, id));
     }
@@ -269,7 +493,7 @@ fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) {
 
 /// Panels that lay out their own scrolling parts (the Pages panel: parents, pages, footer).
 pub fn scrolls_itself(id: &str) -> bool {
-    id == "pages"
+    id == "pages" || id == "history"
 }
 
 /// The contents of an icon-column panel.
@@ -285,6 +509,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) {
         "character" => panels::properties::character_panel(app, ui),
         "typography" => panels::typography::show(app, ui),
         "glyphs" => panels::glyphs::show(app, ui),
+        "history" => panels::history::show(app, ui),
         "paragraph" => panels::properties::paragraph_panel(app, ui),
         "story" => panels::story::show(app, ui),
         "textWrap" => panels::properties::wrap_panel(app, ui),
@@ -336,15 +561,15 @@ pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) {
     }
 }
 
-/// Put a floating panel back in the dock's icon column.
+/// Put a floating panel on the left of the right icon bar, in line with its icon.
 pub fn dock_panel(app: &mut DesignApp, id: &str) {
-    if app.ui.docking.is_some() && !float_only(id) {
-        if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) {
-            app.status(error);
-        }
+    if float_only(id) {
+        app.ui.floating.retain(|(p, _)| p != id);
         return;
     }
-    app.ui.floating.retain(|(p, _)| p != id);
+    if let Err(error) = crate::panel_docking::attach_beside(app, id) {
+        app.status(error);
+    }
 }
 
 /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column.
@@ -380,7 +605,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
             crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong);
             let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 3.0), vec2(18.0, 18.0));
             if ui
-                .interact(close, ui.id().with("fclose"), Sense::click())
+                .interact(close, ui.id().with("fclose"), Sense::click_and_drag())
                 .on_hover_ui(|ui| {
                     crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Close"));
                 })
@@ -390,9 +615,12 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
             }
             ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim);
             let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0));
+            let menu_x = if docks { strip.max.x - 66.0 } else { strip.max.x - 44.0 };
+            let menu_r = egui::Rect::from_min_size(egui::pos2(menu_x, strip.min.y + 3.0), vec2(18.0, 18.0));
+            crate::panel_docking::header_menu(app, ui, menu_r, &id);
             if docks
                 && ui
-                    .interact(dock_r, ui.id().with("fdock"), Sense::click())
+                    .interact(dock_r, ui.id().with("fdock"), Sense::click_and_drag())
                     .on_hover_ui(|ui| {
                         crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel"));
                     })
@@ -408,12 +636,18 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) {
             });
         });
         // Remember where it was left.
+        let dropped_on_bar = r.as_ref().is_some_and(|r| {
+            r.response.drag_stopped()
+                && ctx
+                    .input(|input| input.pointer.interact_pos())
+                    .is_some_and(|point| right_bar(ctx).is_some_and(|bar| beside_bar_zone(bar).contains(point)))
+        });
         if let Some(r) = r
             && let Some(f) = app.ui.floating.iter_mut().find(|(p, _)| *p == id)
         {
             f.1 = [r.response.rect.min.x, r.response.rect.min.y];
         }
-        if !open || dock {
+        if !open || dock || dropped_on_bar {
             dock_panel(app, &id);
         }
     }
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -392,6 +392,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
         ],
     ),
     ("Clear", ["Löschen", "Effacer", "Borrar", "消去", "清除"]),
+    ("Clear History", ["Verlauf löschen", "Effacer l'historique", "Borrar historial", "ヒストリーを消去", "清除历史记录"]),
+    ("No document", ["Kein Dokument", "Aucun document", "Ningún documento", "ドキュメントなし", "无文档"]),
     ("Duplicate", ["Duplizieren", "Dupliquer", "Duplicar", "複製", "重复"]),
     ("Select All", ["Alles auswählen", "Tout sélectionner", "Seleccionar todo", "すべてを選択", "全选"]),
     ("Deselect All", ["Auswahl aufheben", "Tout désélectionner", "Deseleccionar todo", "すべての選択を解除", "取消全选"]),
@@ -2310,6 +2312,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
     ),
     ("Split Path", ["Split Path", "Fractionner le tracé", "Split Path", "Split Path", "拆分路径"]),
     ("Step and Repeat…", ["Step and Repeat…", "Exécuter et répéter…", "Step and Repeat…", "Step and Repeat…", "步进和重复…"]),
+    ("Step Backward", ["Schritt zurück", "État précédent", "Paso atrás", "前のステップ", "后退一步"]),
+    ("Step Forward", ["Schritt vorwärts", "État suivant", "Paso adelante", "次のステップ", "前进一步"]),
     ("Stroke Styles", ["Stroke Styles", "Styles de contour", "Stroke Styles", "Stroke Styles", "描边样式"]),
     ("Structure", ["Structure", "Structure", "Structure", "Structure", "结构"]),
     ("Style Source", ["Style Source", "Source du style", "Style Source", "Style Source", "样式源"]),
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -1140,6 +1140,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) {
             pen.rect(3.0, 4.0, 17.0, 16.0);
             pen.line(&[(3.0, 7.0), (17.0, 7.0)]);
         }
+        "panel-history" => {
+            pen.rect(3.0, 3.0, 17.0, 17.0);
+            pen.line(&[(6.0, 7.0), (14.0, 7.0)]);
+            pen.line(&[(6.0, 10.5), (14.0, 10.5)]);
+            pen.line(&[(6.0, 14.0), (11.0, 14.0)]);
+        }
         _ => {
             pen.rect(4.0, 4.0, 16.0, 16.0);
         }
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -893,6 +893,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
             "ui:window.panel|Track Changes|{\"panel\": \"trackChanges\"}",
             "<",
             "ui:window.panel|Effects|{\"panel\": \"effects\"}",
+            "ui:window.panel|History|{\"panel\": \"history\"}",
             "ui:window.panel|Info|{\"panel\": \"info\"}",
             ">Interactive",
             "ui:window.panel|Bookmarks|{\"panel\": \"bookmarks\"}",
@@ -980,14 +981,14 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[
     &["gradient"],
 ];
 
-/// The Typography preset's available panels in the order observed in InDesign's workspace.
-/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is
-/// not recorded, so these groups leave it out.
+/// The Typography preset's panels in the order observed in InDesign's workspace.
+/// CC Libraries and Style Packs are Adobe cloud features and have no DesignCraft panel.
+/// Story leads the group with Hyperlinks and Effects.
 pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[
     &["pages", "layers", "swatches"],
     &["glyphs", "textWrap"],
     &["stroke", "gradient"],
-    &["hyperlinks", "effects"],
+    &["story", "hyperlinks", "effects"],
     &["paragraph", "paragraphStyles"],
     &["character", "characterStyles"],
 ];
@@ -5618,8 +5619,17 @@ mod tests {
         assert!(app.ui.open_panel.is_none());
         let groups = app.ui.icon_groups.clone().unwrap();
         assert_eq!(groups[0], ["pages", "layers", "swatches"]);
+        assert_eq!(groups[3], ["story", "hyperlinks", "effects"]);
         assert_eq!(groups[4], ["paragraph", "paragraphStyles"]);
         assert_eq!(groups[5], ["character", "characterStyles"]);
+        assert_eq!(crate::dock::icon_group_ids(&groups, "swatches"), vec!["pages", "layers", "swatches"]);
+        assert!(crate::dock::icon_group_ids(&groups, "history").is_empty());
+        assert_eq!(crate::dock::side_column_width(&app), 37.0);
+        assert_eq!(crate::dock::panel_label("history"), "History");
+        run_ui(&mut app, "window.panel", &json!({"panel": "history"})).unwrap().unwrap();
+        assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("history"), false));
+        run_ui(&mut app, "window.panel", &json!({"panel": "history"})).unwrap().unwrap();
+        assert!(app.ui.open_panel.is_none());
         for panel in ["pages", "paragraphStyles", "characterStyles"] {
             run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap();
             assert_eq!(app.ui.open_panel.as_deref(), Some(panel));
@@ -5632,6 +5642,7 @@ mod tests {
         run_ui(&mut app, "window.workspace", &json!({"name":"Essentials"})).unwrap().unwrap();
         assert!(app.ui.icon_groups.is_none());
         assert!(app.ui.dock_expanded);
+        assert_eq!(crate::dock::side_column_width(&app), 0.0);
     }
 
     #[test]
diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs
--- a/crates/ui-egui/src/panel_docking.rs
+++ b/crates/ui-egui/src/panel_docking.rs
@@ -505,7 +505,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool {
             &mut PanelContent(app),
         )
     };
-    let output = if layout.root.is_some() {
+    let mut output = if layout.root.is_some() {
         egui::Panel::right("shared-panel-dock")
             .default_size(310.0)
             .size_range(220.0..=720.0)
@@ -516,10 +516,10 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool {
     } else {
         draw(ui)
     };
-    if let Some(panel) = output.focused {
+    if let Some(panel) = output.focused.take() {
         app.ui.docking_opened = Some(panel);
     }
-    if let Some(error) = output.error {
+    if let Some(error) = output.error.take() {
         app.ui.status = error.to_string();
     }
     if ui.input(|input| input.pointer.any_released()) {
@@ -528,15 +528,151 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool {
         }
         ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("designcraft-panel-docking").with("legacy-origin")));
     }
+    let dropped = bar_drop(app, ui, &mut output);
     for action in output.actions {
         if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
             app.ui.status = error;
         }
     }
+    if let Some(panel) = dropped
+        && let Err(error) = attach_beside(app, &panel)
+    {
+        app.ui.status = error;
+    }
     icon_rail(app, ui);
     true
 }
 
+/// A floating panel released against the icon bar docks there.
+///
+/// The title is the drag handle, and it sits at the left of the window. The pointer is often
+/// still over the page when the window's right edge meets the bar, so the window rectangle is
+/// what has to arrive. Releasing in the gutter just left of the icons docks too. A drop on the
+/// expanded stack, to the right of the icons, stays with that stack.
+fn bar_drop(app: &DesignApp, ui: &egui::Ui, output: &mut craft_ui::docking::DockOutput<String>) -> Option<String> {
+    let bar = crate::dock::right_bar(ui.ctx())?;
+    let zone = crate::dock::beside_bar_zone(bar);
+    let pointer = ui.input(|input| input.pointer.interact_pos());
+    let in_zone = pointer.is_some_and(|point| zone.contains(point));
+    let released = ui.input(|input| input.pointer.any_released());
+    let dragging = output.group_drag.is_some() || ui.input(|input| input.pointer.primary_down() && input.pointer.is_decidedly_dragging());
+    let mut placed = placed_windows(output);
+    if let Some((panel, _)) = &output.group_drop
+        && !placed.iter().any(|(id, _)| id == panel)
+        && let Some(rect) = floating_window(app, panel)
+    {
+        placed.push((panel.clone(), rect));
+    }
+    let arrived = placed.iter().find(|(id, rect)| !crate::dock::float_only(id) && window_meets_bar(*rect, bar)).map(|(id, _)| id.clone());
+    if app.ui.docking.is_some() && (arrived.is_some() || (in_zone && dragging)) {
+        paint_bar_preview(ui, bar, arrived.as_deref().or(output.group_drag.as_deref()));
+    }
+    if !released {
+        return None;
+    }
+    let panel = arrived.or_else(|| gutter_panel(output, in_zone))?;
+    if crate::dock::float_only(&panel) {
+        return None;
+    }
+    output.actions.retain(|action| match action {
+        Action::Float { panel: id, .. } | Action::Move { panel: id, .. } | Action::MoveFloating { panel: id, .. } => id != &panel,
+        _ => true,
+    });
+    output.group_drop = None;
+    Some(panel)
+}
+
+/// Windows this frame actually moved: a torn-off tab (`Float`) or a title drag (`MoveFloating`
+/// while a group drag is active). A corner resize also emits `MoveFloating` and must not dock.
+fn placed_windows(output: &craft_ui::docking::DockOutput<String>) -> Vec<(String, egui::Rect)> {
+    let moving = output.group_drag.is_some() || output.group_drop.is_some();
+    let mut placed = Vec::new();
+    for action in &output.actions {
+        let Some((panel, rect)) = action_window(action, moving) else { continue };
+        if let Some(slot) = placed.iter_mut().find(|(id, _)| id == &panel) {
+            slot.1 = rect;
+        } else {
+            placed.push((panel, rect));
+        }
+    }
+    placed
+}
+
+fn action_window(action: &Action<String>, moving: bool) -> Option<(String, egui::Rect)> {
+    let (panel, rect) = match action {
+        Action::Float { panel, rect } => (panel, rect),
+        Action::MoveFloating { panel, rect } if moving => (panel, rect),
+        _ => return None,
+    };
+    let [x, y, width, height] = *rect;
+    if ![x, y, width, height].into_iter().all(f32::is_finite) || width <= 1.0 || height <= 1.0 {
+        return None;
+    }
+    Some((panel.clone(), egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(width, height))))
+}
+
+fn floating_window(app: &DesignApp, panel: &str) -> Option<egui::Rect> {
+    let group = app.ui.docking.as_ref()?.floating.iter().find(|group| group.panels.iter().any(|id| id == panel))?;
+    let [x, y, width, height] = group.rect;
+    if ![x, y, width, height].into_iter().all(f32::is_finite) || width <= 1.0 || height <= 1.0 {
+        return None;
+    }
+    Some(egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(width, height)))
+}
+
+/// The right edge is against the icon bar, or overlapping it, without the window sitting
+/// entirely on the expanded stack past the icons.
+fn window_meets_bar(window: egui::Rect, bar: egui::Rect) -> bool {
+    if window.bottom() <= bar.top() || window.top() >= bar.bottom() {
+        return false;
+    }
+    let gap = bar.min.x - window.max.x;
+    gap <= 96.0 && gap > -window.width() * 0.7
+}
+
+/// Pointer released on the icons or in the gutter beside them.
+fn gutter_panel(output: &craft_ui::docking::DockOutput<String>, in_zone: bool) -> Option<String> {
+    if !in_zone {
+        return None;
+    }
+    output.group_drop.as_ref().map(|(panel, _)| panel.clone()).or_else(|| {
+        output.actions.iter().find_map(|action| match action {
+            Action::Float { panel, .. } | Action::Move { panel, .. } => Some(panel.clone()),
+            _ => None,
+        })
+    })
+}
+
+fn paint_bar_preview(ui: &egui::Ui, bar: egui::Rect, panel: Option<&str>) {
+    let t = crate::theme::Tokens::get(ui.ctx());
+    let width = 268.0;
+    let top = panel.map(|id| flyout_top(ui.ctx(), id)).unwrap_or(bar.min.y);
+    let height = (bar.max.y - top - 12.0).clamp(120.0, 480.0);
+    let preview = egui::Rect::from_min_size(egui::pos2((bar.min.x - width).max(0.0), top), egui::vec2(width, height));
+    let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("beside-bar-preview")));
+    painter.rect(preview, 0.0, t.accent.gamma_multiply(0.18), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
+}
+
+fn flyout_top(ctx: &egui::Context, id: &str) -> f32 {
+    ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", id))).map(|rect| rect.min.y))
+        .or_else(|| crate::dock::right_bar(ctx).map(|rect| rect.min.y))
+        .unwrap_or(120.0)
+}
+
+/// Take `panel` out of any floating window or tab group and open it against the icon bar.
+pub(crate) fn attach_beside(app: &mut DesignApp, raw: &str) -> Result<(), String> {
+    let panel = normalize(raw).ok_or_else(|| "panel must name a registered panel".to_string())?;
+    if crate::dock::float_only(panel) {
+        return Err("panel does not dock".into());
+    }
+    if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel.to_string())) {
+        change(app, "close", &json!({"panel": panel}))?;
+    }
+    app.ui.floating.retain(|(id, _)| id != panel);
+    app.ui.open_panel = Some(panel.to_string());
+    Ok(())
+}
+
 fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) {
     let t = crate::theme::Tokens::get(ui.ctx());
     let column = egui::Panel::right("shared-panel-icons")
@@ -553,10 +689,13 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) {
                         continue;
                     }
 
+                    let on = app.ui.open_panel.as_deref() == Some(id);
                     let response =
-                        crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());
+                        crate::icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag());
+                    crate::dock::remember_icon(ui, id, response.rect);
+                    // A click opens the panel beside this icon. Dragging it still starts a dock gesture.
                     if response.clicked() {
-                        let _ = app.run("window.panel.activate", json!({"panel":id}));
+                        app.ui.open_panel = if on { None } else { Some(id.to_string()) };
                     }
                     legacy_tab(app, ui, id, &response);
                 }
@@ -564,7 +703,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) {
         })
         .response
         .rect;
-    let _ = column;
+    crate::dock::remember_right_bar(ui.ctx(), column);
 }
 
 pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
@@ -606,9 +745,24 @@ pub(crate) fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &st
         crate::panels::pages::panel_menu(app, ui);
         ui.separator();
     }
+    if panel == "history" {
+        crate::panels::history::panel_menu(app, ui);
+        ui.separator();
+    }
     panel_menu(app, ui, panel);
 }
 
+/// The three-line button at `rect`, opening [`three_line_menu`] for `panel`.
+pub(crate) fn header_menu(app: &mut DesignApp, ui: &mut egui::Ui, rect: egui::Rect, panel: &str) {
+    let t = crate::theme::Tokens::get(ui.ctx());
+    let menu = ui.interact(rect, ui.id().with(("header-menu", panel)), egui::Sense::click_and_drag());
+    if menu.hovered() {
+        ui.painter().rect_filled(rect, 3.0, t.hover);
+    }
+    crate::icons::paint(ui.painter(), rect, "menu", t.icon);
+    egui::Popup::menu(&menu).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| three_line_menu(app, ui, panel));
+}
+
 /// The three-line button at the right of a tab strip, for the panel in front.
 fn three_line_button(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) {
     let t = crate::theme::Tokens::get(ui.ctx());
@@ -628,6 +782,14 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) {
     tab_commands(app, ui, panel);
     ui.separator();
     let language = app.ui.language.clone();
+    let floating_now = app.ui.floating.iter().any(|(id, _)| id == panel)
+        || app.ui.docking.as_ref().is_some_and(|layout| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel)));
+    if floating_now && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Dock panel"))).clicked() {
+        if let Err(error) = attach_beside(app, panel) {
+            app.status(error);
+        }
+        ui.close();
+    }
     if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() {
         if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
             app.status(error);
@@ -668,10 +830,23 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
     }
     fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) {
         // The docking surface adds Float Panel and Group with after these, so a tab's menu lists
-        // what the classic dock's does.
+        // what the classic dock's does. A floating tab can also dock against the icon bar.
         if let Some(panel) = normalize(id) {
             tab_commands(self.0, ui, panel);
             ui.separator();
+            let floating_now = self.0.ui.floating.iter().any(|(existing, _)| existing == panel)
+                || self
+                    .0
+                    .ui
+                    .docking
+                    .as_ref()
+                    .is_some_and(|layout| layout.floating.iter().any(|group| group.panels.iter().any(|existing| existing == panel)));
+            if floating_now && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&self.0.ui.language, "Dock panel"))).clicked() {
+                if let Err(error) = attach_beside(self.0, panel) {
+                    self.0.status(error);
+                }
+                ui.close();
+            }
         }
     }
     fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 {
diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs
--- a/crates/ui-egui/src/panel_docking_tests.rs
+++ b/crates/ui-egui/src/panel_docking_tests.rs
@@ -787,3 +787,91 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() {
     assert!(h.query_by_label("Close Panel").is_none());
     assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu");
 }
+
+fn harness_flyout(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> {
+    let mut ready = false;
+    let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
+        move |ui, app: &mut DesignApp| {
+            if !ready {
+                crate::theme::install_fonts(ui.ctx(), &app.ui.language);
+                crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark));
+                ready = true;
+                return;
+            }
+            crate::dock::show(app, ui);
+            egui::CentralPanel::default().show(ui, |_| {});
+            crate::dock::flyout(app, ui.ctx());
+        },
+        app,
+    );
+    harness.run_steps(4);
+    harness
+}
+
+#[test]
+fn effects_attaches_beside_the_icon_bar_instead_of_staying_afloat() {
+    let mut app = app();
+    app.run("window.panel.float", json!({"panel":"effects","x":180.0,"y":160.0,"width":300.0,"height":420.0})).unwrap();
+    assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|group| group.panels == ["effects"]));
+    attach_beside(&mut app, "effects").unwrap();
+    let layout = app.ui.docking.as_ref().unwrap();
+    assert!(!layout.contains(&"effects".into()));
+    assert!(layout.contains(&"properties".into()), "the expanded dock stays");
+    assert_eq!(app.ui.open_panel.as_deref(), Some("effects"));
+}
+
+/// Dropping the floating Effects window on the gutter left of the icon bar docks it there,
+/// flush with the bar, instead of leaving it over the page.
+#[test]
+fn dragging_effects_onto_the_right_icon_bar_docks_it_flush() {
+    let mut app = app();
+    app.run("window.panel.float", json!({"panel":"effects","x":180.0,"y":160.0,"width":300.0,"height":420.0})).unwrap();
+    let mut h = harness_flyout(app, vec2(1280.0, 800.0));
+    let tab = h.query_all_by_label("Effects").find(|node| node.rect().height() <= 45.0).expect("effects tab").rect();
+    let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).expect("icon bar").outer_rect;
+    drag(&mut h, tab.center(), egui::pos2(bar.left() - 16.0, bar.center().y));
+    h.run_steps(4);
+    assert_eq!(h.state().ui.open_panel.as_deref(), Some("effects"), "releasing on the bar docks Effects");
+    let layout = h.state().ui.docking.clone().unwrap();
+    assert!(!layout.floating.iter().any(|group| group.panels.iter().any(|panel| panel == "effects")));
+    assert!(layout.contains(&"properties".into()));
+    let flyout = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("panel_flyout"))).expect("attached flyout");
+    let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).unwrap().outer_rect;
+    assert!((flyout.right() - bar.left()).abs() < 8.0, "flyout {flyout:?} should meet bar {bar:?}");
+    assert!(flyout.height() < 180.0, "attached panel hugs its content, got {}", flyout.height());
+    let icon = h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", "effects")))).expect("effects icon");
+    assert!((icon.top() - flyout.top()).abs() < 12.0, "flyout {flyout:?} should line up with icon {icon:?}");
+}
+
+/// The title is at the left of the window. Dropping so the window meets the bar docks it even
+/// when that title, and the pointer, are still over the page.
+#[test]
+fn dropping_effects_against_the_bar_docks_it_when_the_pointer_is_still_over_the_page() {
+    let mut app = app();
+    app.run("window.panel.float", json!({"panel": "effects", "x": 180.0, "y": 160.0, "width": 300.0, "height": 420.0})).unwrap();
+    let mut h = harness_flyout(app, vec2(1280.0, 800.0));
+    let tab = h.query_all_by_label("Effects").find(|node| node.rect().height() <= 45.0).expect("effects tab").rect();
+    let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).expect("icon bar").outer_rect;
+    let group = h
+        .state()
+        .ui
+        .docking
+        .as_ref()
+        .and_then(|layout| layout.floating.iter().find(|group| group.panels.iter().any(|panel| panel == "effects")))
+        .expect("floating effects")
+        .rect;
+    let grab_x = tab.center().x - group[0];
+    let pointer_x = bar.left() - 8.0 + grab_x - group[2];
+    assert!(pointer_x < bar.left() - 48.0, "pointer {pointer_x} should be left of the gutter at {}", bar.left() - 48.0);
+    drag(&mut h, tab.center(), egui::pos2(pointer_x, bar.center().y));
+    h.run_steps(4);
+    assert_eq!(h.state().ui.open_panel.as_deref(), Some("effects"), "the window edge docks Effects");
+    let layout = h.state().ui.docking.clone().unwrap();
+    assert!(!layout.floating.iter().any(|group| group.panels.iter().any(|panel| panel == "effects")));
+    let flyout = h.ctx.memory(|memory| memory.area_rect(egui::Id::new("panel_flyout"))).expect("attached flyout");
+    let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-icons")).unwrap().outer_rect;
+    assert!((flyout.right() - bar.left()).abs() < 8.0, "flyout {flyout:?} should meet bar {bar:?}");
+    assert!(flyout.height() < 180.0, "attached panel hugs its content, got {}", flyout.height());
+    let icon = h.ctx.data(|data| data.get_temp::<egui::Rect>(egui::Id::new(("icon-row", "effects")))).expect("effects icon");
+    assert!((icon.top() - flyout.top()).abs() < 12.0, "flyout {flyout:?} should line up with icon {icon:?}");
+}
diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/panels/history.rs
@@ -0,0 +1,141 @@
+//! History panel: one row per undo state, with New Document and Delete in the footer.
+
+use egui::{Color32, Sense, Stroke, vec2};
+use serde_json::{Value, json};
+
+use crate::DesignApp;
+use crate::theme::Tokens;
+
+/// The list of states. The footer stays put while the list scrolls.
+pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
+    let t = Tokens::get(ui.ctx());
+    let lang = app.ui.language.clone();
+    let value = match app.session.execute("document.history.states", &json!({})) {
+        Ok(value) => value,
+        Err(_) => {
+            dim(ui, &t, crate::i18n::tr(&lang, "No document"));
+            return;
+        }
+    };
+    let current = value.get("current").and_then(Value::as_u64).unwrap_or(0);
+    let Some(states) = value.get("states").and_then(Value::as_array) else {
+        dim(ui, &t, crate::i18n::tr(&lang, "No document"));
+        return;
+    };
+    let footer_h = 28.0;
+    let list_h = (ui.available_height() - footer_h).max(48.0);
+    egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| {
+        for state in states {
+            let index = state.get("index").and_then(Value::as_u64).unwrap_or(0);
+            let raw = state.get("label").and_then(Value::as_str).unwrap_or("");
+            let kind = state.get("kind").and_then(Value::as_str).unwrap_or("action");
+            let future = state.get("future").and_then(Value::as_bool).unwrap_or(false);
+            let selected = state.get("current").and_then(Value::as_bool).unwrap_or(false);
+            let owned = raw.to_string();
+            let shown = if kind == "file" { owned } else { crate::i18n::tr(&lang, &owned).to_string() };
+            let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
+            if selected {
+                ui.painter().rect_filled(rect, 2.0, t.row_selected);
+            } else if response.hovered() {
+                ui.painter().rect_filled(rect, 2.0, t.hover);
+            }
+            let icon = egui::Rect::from_min_size(rect.min + vec2(4.0, 3.0), vec2(16.0, 16.0));
+            let color = if future {
+                t.text_dim
+            } else if selected {
+                t.text_strong
+            } else {
+                t.text
+            };
+            crate::icons::paint(ui.painter(), icon, "panel-history", if future { t.text_disabled } else { t.icon });
+            crate::rtl::paint(
+                ui.painter(),
+                rect.left_center() + vec2(24.0, 0.0),
+                egui::Align2::LEFT_CENTER,
+                &shown,
+                egui::FontId::proportional(12.0),
+                color,
+            );
+            if response.clicked()
+                && let Err(error) = app.run("edit.history.go", json!({"index": index}))
+            {
+                app.status_error("History", error);
+            }
+        }
+    });
+    let (line, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
+    ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(1.0, t.border));
+    ui.horizontal(|ui| {
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            ui.add_space(6.0);
+            if footer_button(ui, &t, &lang, "Delete", current > 0, true).clicked()
+                && let Err(error) = app.run("edit.history.delete", json!({}))
+            {
+                app.status_error("History", error);
+            }
+            if footer_button(ui, &t, &lang, "New Document", true, false).clicked()
+                && let Err(error) = app.run("edit.history.newDocument", json!({}))
+            {
+                app.status_error("History", error);
+            }
+        });
+    });
+}
+
+/// History commands at the top of the panel menu.
+pub fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui) {
+    let lang = app.ui.language.clone();
+    let (back, forward, delete, clear, has_doc) = match app.session.active() {
+        Some(st) => {
+            let undo = !st.history.undo.is_empty();
+            let redo = !st.history.redo.is_empty();
+            (undo, redo, undo, undo || redo, true)
+        }
+        None => (false, false, false, false, false),
+    };
+    let mut command = |ui: &mut egui::Ui, label: &str, on: bool, id: &str| {
+        let text = crate::rtl::widget(ui, crate::i18n::tr(&lang, label));
+        if ui.add_enabled(on, egui::Button::new(text)).clicked() {
+            if let Err(error) = app.run(id, json!({})) {
+                app.status_error("History", error);
+            }
+            ui.close();
+        }
+    };
+    command(ui, "Step Backward", back, "edit.undo");
+    command(ui, "Step Forward", forward, "edit.redo");
+    command(ui, "Delete", delete, "edit.history.delete");
+    command(ui, "Clear History", clear, "edit.history.clear");
+    ui.separator();
+    command(ui, "New Document", has_doc, "edit.history.newDocument");
+}
+
+fn dim(ui: &mut egui::Ui, t: &Tokens, text: &str) {
+    ui.label(egui::RichText::new(text).color(t.text_dim));
+}
+
+/// Footer icon. `trash` paints the trash can; otherwise a page with a plus.
+fn footer_button(ui: &mut egui::Ui, t: &Tokens, lang: &str, tip: &str, enabled: bool, trash: bool) -> egui::Response {
+    let (rect, response) = ui.allocate_exact_size(vec2(26.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
+    if enabled && response.hovered() {
+        ui.painter().rect_filled(rect, 3.0, t.hover);
+    }
+    let color = if enabled { t.icon } else { t.text_disabled };
+    if trash {
+        crate::icons::paint(ui.painter(), rect.shrink(3.0), "trash", color);
+    } else {
+        page_plus(ui.painter(), rect, color);
+    }
+    let tip = crate::i18n::tr(lang, tip).to_string();
+    response.on_hover_ui(move |ui| {
+        crate::rtl::label(ui, tip);
+    })
+}
+
+fn page_plus(painter: &egui::Painter, rect: egui::Rect, color: Color32) {
+    let page = egui::Rect::from_center_size(rect.center() + vec2(-2.0, 0.0), vec2(10.0, 12.0));
+    painter.rect_stroke(page, 1.0, Stroke::new(1.0, color), egui::StrokeKind::Middle);
+    let center = page.right_bottom() + vec2(-1.0, -1.0);
+    painter.line_segment([center + vec2(-3.5, 0.0), center + vec2(3.5, 0.0)], Stroke::new(1.2, color));
+    painter.line_segment([center + vec2(0.0, -3.5), center + vec2(0.0, 3.5)], Stroke::new(1.2, color));
+}
diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs
--- a/crates/ui-egui/src/panels/mod.rs
+++ b/crates/ui-egui/src/panels/mod.rs
@@ -6,6 +6,7 @@ mod font_menu;
 pub use font_menu::{FontPick, SAMPLES as FONT_SAMPLES, font_menu_body};
 pub mod datamerge;
 pub mod glyphs;
+pub mod history;
 pub mod interactive;
 pub mod layers;
 pub mod library;
diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md
--- a/docs/parity-checklist.md
+++ b/docs/parity-checklist.md
@@ -16,12 +16,12 @@ of in-scope menu items (75% against the documented menu tree) (see [target-app-p
 bound.
 
 <!-- SUMMARY -->
-**Weighted breadth parity: 99%** over 277 features.
+**Weighted breadth parity: 99%** over 278 features.
 
 | Priority | Features | Done | Partial | Missing | Parity |
 |---|---|---|---|---|---|
 | P0 | 102 | 100 | 2 | 0 | 99% |
-| P1 | 99 | 99 | 0 | 0 | 100% |
+| P1 | 100 | 100 | 0 | 0 | 100% |
 | P2 | 76 | 72 | 4 | 0 | 97% |
 
 | Area | Parity |
@@ -323,6 +323,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
 | View & navigation | Rotate spread view | P2 | D |
 | View & navigation | Find/zoom to object | P1 | D |
 | Undo, history, saving | Unlimited undo/redo | P0 | D |
+| Undo, history, saving | History panel | P1 | D |
 | Undo, history, saving | Revert | P0 | D |
 | Undo, history, saving | Save / Save As / Save a Copy | P0 | D |
 | Accessibility | Tagged PDF export | P2 | D |
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 and interaction parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (History panel; icon groups open as one tabbed flyout; Story panel; text Control panel Character/Paragraph modes; Properties ••• expands Character and Paragraph; live corners work; document views row: scrollbars, Alt pans while editing text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; paragraph style override feedback and clearing) · **Target:** Adobe InDesign 2026 (21.6, Medium Dark, macOS)
 
 Tools, handles, snapping, nudging, modifiers, shortcuts, panels, menus, context menus and feel,
 against InDesign 2026. The look was measured from the running app
@@ -47,7 +47,7 @@ basics of editing fail for too many users on Windows and Linux.
 | Quick Apply | ✓ | ✓ | ✓ | |
 | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
 | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
-| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
+| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story, History | ✓ | all present; an icon group opens as one flyout attached beside that group, with a tab per panel, and floats when its header is dragged. Dropping a floating panel on the right icon bar docks it flush to the left of that bar, in line with its icon | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
 | Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
 | Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) |
 | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
@@ -122,6 +122,7 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-25 | minor | Panels row: a floating panel docks flush to the left of the right icon bar. History panel. An icon group opens as one flyout attached beside that group, with a tab per panel. |
 | 2026-10-24 | minor | Panels row: Story panel (Optical Margin Alignment and its size) |
 | 2026-10-21 | minor | Chrome row: the text Control panel's A/¶ modes; Properties row: ••• expands Character and Paragraph in place |
 | 2026-10-11 | minor | Live corners row to ✓: widget, diamonds, drag, Shift-drag, Alt-click shape cycling (#186) |
