diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -942,6 +942,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
         "references.citation" if !has("tag") && !has("source") => Some("createSource"),
         // Templates and Add-ins, and the Organizer without a file to list (#377).
         "tools.templates" => Some("templates"),
+        // Macros without a name to run, define or delete shows its dialog (#4327); scripts get the list.
+        "tools.macros" if !["run", "define", "delete"].into_iter().any(has) => Some("macros"),
         "styles.organizer" if !has("path") => Some("organizer"),
         // Language, Envelopes and Labels without settings show their dialogs (#407).
         "review.language" if params.as_object().is_none_or(|m| m.is_empty()) => Some("language"),
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -278,6 +278,10 @@ pub enum Dialog {
         /// The line the border choices draw.
         pen: crate::dialogs_table_style::BorderPen,
     },
+    /// View › Macros: the recorded macros to run or delete, and Record (#4327).
+    Macros {
+        name: String,
+    },
     Commands {
         query: String,
         /// Opened by a shortcut that may also send its key's text (Alt+Q's "q", in the same frame
@@ -502,6 +506,7 @@ impl Dialog {
             Dialog::TableStyle { id: None, .. } => "newTableStyle",
             Dialog::TableStyle { .. } => "modifyTableStyle",
             Dialog::Commands { .. } => "commands",
+            Dialog::Macros { .. } => "macros",
             Dialog::Insert(d) => d.name(),
             Dialog::Refs(d) => d.name(),
             Dialog::Icons { .. } => "icons",
@@ -732,6 +737,7 @@ impl Dialog {
             "manageStyles" => Dialog::ManageStyles { alphabetical: false, selected: s("style") },
             "newTableStyle" | "modifyTableStyle" => crate::dialogs_table_style::open(app, name)?,
             "commands" => Dialog::Commands { query: String::new(), swallow: true },
+            "macros" => Dialog::Macros { name: String::new() },
             "icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() },
             "tableProperties" => {
                 let form = Box::new(TableForm::read(app)?);
diff --git a/crates/ui-egui/src/dialogs/bookmarks.rs b/crates/ui-egui/src/dialogs/bookmarks.rs
--- a/crates/ui-egui/src/dialogs/bookmarks.rs
+++ b/crates/ui-egui/src/dialogs/bookmarks.rs
@@ -198,3 +198,63 @@ pub(super) fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str)
     let dy = if shape == "triangle" && box_h > 0.0 { h * 0.3 } else { 0.0 };
     p.text(c + vec2(0.0, dy), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(px), ink);
 }
+
+/// View › Macros (#4327): the macros recorded or defined this session, with Run and Delete beside
+/// the list and Record below it. While a recording is running the box offers Stop Recording instead.
+/// The list is read from the session, not from `tools.macros`, which would ask to open this
+/// dialog again. Run and Record close the dialog; Delete leaves it open.
+pub(super) fn macros(app: &mut WordApp, ui: &mut Ui, name: &mut String) -> bool {
+    let names: Vec<String> = app.session.macros.keys().cloned().collect();
+    let recording = app.session.recording.as_ref().map(|(n, _)| n.clone());
+    let t = Tokens::get(ui.ctx());
+    let (mut run, mut delete, mut record) = (false, false, false);
+    ui.horizontal_top(|ui| {
+        ui.vertical(|ui| {
+            let name_label = ui.label(tl!("Macro name:"));
+            ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id);
+            egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| {
+                ui.set_width(232.0);
+                ui.set_height(150.0);
+                egui::ScrollArea::vertical().id_salt("macro_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| {
+                    if names.is_empty() {
+                        ui.label(egui::RichText::new(tl!("There are no macros yet. Record one to add it here.")).small().color(t.text_dim));
+                    }
+                    for n in &names {
+                        if ui.selectable_label(*n == *name, n.as_str()).clicked() {
+                            *name = n.clone();
+                        }
+                    }
+                });
+            });
+        });
+        ui.vertical(|ui| {
+            ui.add_space(18.0);
+            let listed = names.contains(name);
+            let w = vec2(110.0, 0.0);
+            run = ui.add_enabled(listed && recording.is_none(), egui::Button::new(tl!("Run")).min_size(w)).clicked();
+            delete = ui.add_enabled(listed, egui::Button::new(tl!("Delete")).min_size(w)).clicked();
+            let record_label = if recording.is_some() { tl!("Stop Recording") } else { tl!("Record…") };
+            record = ui.add_enabled(recording.is_some() || !name.trim().is_empty(), egui::Button::new(record_label).min_size(w)).clicked();
+        });
+    });
+    if run {
+        let _ = app.run("tools.macros", json!({"run": name.as_str()}));
+        return true;
+    }
+    if delete && app.run("tools.macros", json!({"delete": name.as_str()})).is_ok() {
+        name.clear();
+    }
+    if record {
+        // Stopping takes no name; starting records under the one typed.
+        let _ = app.run("tools.recordMacro", if recording.is_some() { json!({}) } else { json!({"name": name.trim()}) });
+        return true;
+    }
+    ui.add_space(8.0);
+    let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape));
+    ui.horizontal(|ui| {
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            close |= ui.button(tl!("Close")).clicked();
+        });
+    });
+    close
+}
diff --git a/crates/ui-egui/src/dialogs/chrome.rs b/crates/ui-egui/src/dialogs/chrome.rs
--- a/crates/ui-egui/src/dialogs/chrome.rs
+++ b/crates/ui-egui/src/dialogs/chrome.rs
@@ -97,6 +97,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
         Dialog::TableStyle { id: None, .. } => "New Table Style",
         Dialog::TableStyle { .. } => "Modify Table Style",
         Dialog::Commands { .. } => "Search Commands",
+        Dialog::Macros { .. } => "Macros",
         Dialog::Insert(d) => d.title(),
         Dialog::Refs(d) => d.title(),
         Dialog::Icons { .. } => "Icons",
diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs
--- a/crates/ui-egui/src/dialogs/dispatch.rs
+++ b/crates/ui-egui/src/dialogs/dispatch.rs
@@ -1,4 +1,4 @@
-use super::bookmarks::{bookmark, enclose_preview, icons_body};
+use super::bookmarks::{bookmark, enclose_preview, icons_body, macros};
 use super::chrome::{buttons, color_field};
 use super::find::{FindTab, SEARCH_SUGGESTIONS, command_matches, find_in_navigation_pane, goto_body, special_menu, swallow_shortcut_text};
 use super::font::{apply_font, preview_size};
@@ -874,6 +874,7 @@ pub(super) fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
         }
         Dialog::Insert(f) => crate::dialogs_insert::body(app, ui, f),
         Dialog::Refs(f) => crate::dialogs_refs::body(app, ui, f),
+        Dialog::Macros { name } => macros(app, ui, name),
         Dialog::Commands { query, swallow } => {
             if *swallow {
                 ui.ctx().input_mut(|i| i.events.retain(|e| !swallow_shortcut_text(swallow, e)));
diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs
--- a/crates/ui-egui/src/dialogs/tests.rs
+++ b/crates/ui-egui/src/dialogs/tests.rs
@@ -144,6 +144,67 @@ fn find_replace_and_go_to_are_one_dialog() {
     }
 }
 
+/// #4327: View › Macros opens a dialog that runs, deletes and records macros; the command with
+/// a name to run or delete (what scripts send) still acts without one.
+#[test]
+fn the_macros_button_opens_a_dialog_that_runs_deletes_and_records() {
+    fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) {
+        use egui_kittest::kittest::Queryable;
+        let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Macros")))).expect("the dialog is open");
+        h.query_all_by_label(label).find(|n| dialog.contains(n.rect().center())).expect(label).click();
+        for _ in 0..3 {
+            h.step();
+        }
+    }
+    let mut app = app_with("Start");
+    for name in ["Greet", "Spare"] {
+        app.run("tools.macros", json!({"define": {"name": name, "steps": [{"command": "text.insert", "params": {"text": name}}]}})).unwrap();
+    }
+    assert!(app.dialog.is_none(), "defining a macro opens nothing");
+    assert_eq!(app.run("tools.macros", json!({})).unwrap(), json!({"pending": "macros"}));
+    let mut h = app_harness(app);
+    h.run_steps(4);
+    assert!(matches!(&h.state().dialog, Some(Dialog::Macros { .. })));
+
+    press(&mut h, "Spare");
+    press(&mut h, "Delete");
+    let names: Vec<&String> = h.state().session.macros.keys().collect();
+    assert_eq!(names, ["Greet"]);
+    assert!(h.state().dialog.is_some(), "Delete leaves the dialog open");
+
+    press(&mut h, "Greet");
+    press(&mut h, "Run");
+    assert!(h.state().dialog.is_none());
+    assert!(body_of(h.state()).contains("Greet"), "the macro ran");
+
+    // A macro whose name has spaces around it (only a script can make one) runs by its exact name.
+    h.state_mut()
+        .run("tools.macros", json!({"define": {"name": " Pad ", "steps": [{"command": "text.insert", "params": {"text": "Padded"}}]}}))
+        .unwrap();
+    h.state_mut().dialog = Dialog::open("macros", h.state_mut());
+    if let Some(Dialog::Macros { name }) = &mut h.state_mut().dialog {
+        *name = " Pad ".into();
+    }
+    h.run_steps(3);
+    press(&mut h, "Run");
+    assert!(h.state().dialog.is_none());
+    assert!(body_of(h.state()).contains("Padded"), "the macro with spaces in its name ran");
+
+    // Record starts a recording under the typed name; the dialog then offers to stop it.
+    h.state_mut().dialog = Dialog::open("macros", h.state_mut());
+    if let Some(Dialog::Macros { name }) = &mut h.state_mut().dialog {
+        *name = "Fresh".into();
+    }
+    h.run_steps(3);
+    press(&mut h, "Record…");
+    assert_eq!(h.state().session.recording.as_ref().map(|(n, _)| n.as_str()), Some("Fresh"));
+    assert!(h.state().dialog.is_none());
+}
+
+fn body_of(app: &WordApp) -> String {
+    app.session.doc.plain_text(wordcraft_doc::StoryRef::Body)
+}
+
 fn app_with(text: &str) -> WordApp {
     let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
     let _ = app.run("document.setText", json!({"text": text}));
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -82,6 +82,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | trivial | View › Macros opens a Macros dialog (also Alt+F8 and command search): the macros of this session listed with Run, Delete and Record, which reads Stop Recording while a recording runs; the button did nothing before, as `tools.macros` without a macro to run, define or delete only returned the list (#4327) |
 | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window |
 | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window |
 | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language |
