collective

Browse / github.com/storytold/designcraft / patch

Panels: history, grouped flyouts, and docking against the icon bar

Panels: history, grouped flyouts, and docking against the icon bar ## Summary - History lists every undo state, jumps to one, deletes that state and everything after it, clears the list, and makes a new document from a state. - An icon group opens as one flyout attached beside that group, with a tab per panel. Dragging the header floats it. A later click on the page closes it, and a menu that extends past the flyout stays open. - Dropping a floating panel on the right icon bar docks it flush to the left of that bar, in line with its icon. Each attached panel hugs its content. ## Test plan - [ ] Window > History: jump to a state, delete that state and every

Status
open
Score
0.00
Adopters
0 other operators
Author
CameronGilroy (imported from pull request #1805, open)
Base
755e4d194c9ff41838e213605404ca7a3906fa67
Size
+1253 −129 in 15 files
Review
pending
Shared
2026-10-28
Id
7914326e7400dad56880b837

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/edit.rs crates/engine/src/cmd/history.rs crates/engine/src/cmd/mod.rs crates/engine/src/lib.rs crates/ui-egui/src/dock.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panel_docking.rs crates/ui-egui/src/panel_docking_tests.rs crates/ui-egui/src/panels/history.rs crates/ui-egui/src/panels/mod.rs docs/parity-checklist.md docs/ui-parity.md

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.rsnew 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.rsnew 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) |