diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
 
 | Date | What landed |
 |---|---|
+| 2026-10-27 | Report an Issue… (#2643): the Automate tab, File › Account and the macOS Help menu open a new GitHub issue with the version, platform and the workbook's file type filled in (never its name or contents); when no browser opens, a dialog shows the address to copy (`help.reportIssue`) |
 | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
 | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
 | 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs
--- a/apps/gridcraft-web/src/web.rs
+++ b/apps/gridcraft-web/src/web.rs
@@ -229,10 +229,14 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
             if let Some(w) = web_sys::window()
                 && let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
             {
-                // With `noopener` the new browsing context can't hold a reference back to the
-                // editor window and navigate it while the user reads the linked page.
-                let _ = w.open_with_url_and_target(&url, "_blank,noopener");
+                // `None` means a pop-up blocker stopped it. The opener is cut at once so the new
+                // browsing context can't navigate the editor window while the user reads the page.
+                if let Ok(Some(opened)) = w.open_with_url_and_target(&url, "_blank") {
+                    let _ = opened.set_opener(&JsValue::NULL);
+                    return true;
+                }
             }
+            false
         })),
         inbox: Some(inbox),
         ..Default::default()
diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs
--- a/apps/gridcraft/src/main.rs
+++ b/apps/gridcraft/src/main.rs
@@ -203,13 +203,13 @@ fn services() -> Services {
     }
 }
 
-/// Opens a URL or file with the system's default application.
-fn open_with_system(target: &str) {
+/// Opens a URL or file with the system's default application; `false` when nothing opened.
+fn open_with_system(target: &str) -> bool {
     // Last line of defence behind `link.open` and the click prompt: the system only ever gets
     // http, https and mailto addresses.
     let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else {
         log::warn!("refused to open {target:?}: only http, https and mailto links are opened");
-        return;
+        return false;
     };
     let target = target.as_str();
     let cmd = if cfg!(target_os = "macos") {
@@ -219,9 +219,7 @@ fn open_with_system(target: &str) {
     } else {
         "xdg-open"
     };
-    if let Err(e) = std::process::Command::new(cmd).arg(target).spawn() {
-        log::warn!("{cmd} {target}: {e}");
-    }
+    std::process::Command::new(cmd).arg(target).spawn().inspect_err(|e| log::warn!("{cmd} {target}: {e}")).is_ok()
 }
 
 fn icon() -> Option<egui::IconData> {
diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs
--- a/apps/gridcraft/src/native_menu.rs
+++ b/apps/gridcraft/src/native_menu.rs
@@ -186,6 +186,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
                 ("Contributors…", "dialog:about:Contributors", None),
                 ("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
                 ("Join the ArtCraft Discord…", "url:https://discord.gg/artcraft", None),
+                ("Report an Issue…", "help.reportIssue", None),
             ],
         ),
     ]
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
@@ -76,6 +76,9 @@ impl SheetApp {
     }
 
     pub fn run_or_alert(&mut self, id: &str, params: Json) {
+        if id == "help.reportIssue" {
+            return crate::report_issue::run_menu(self);
+        }
         // A menu/shortcut opens the settings first; the Print page's button submits explicitly.
         if id == "file.print" {
             if self.services.print_native.is_some() {
@@ -128,6 +131,7 @@ impl SheetApp {
                 self.check_for_updates();
                 Ok(Json::Null)
             }
+            "help.reportIssue" => Ok(crate::report_issue::run(self)),
             "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
             "ui.printSubmit" => self.submit_print(p.clone()),
             "ui.ribbonTab" => {
diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs
--- a/crates/ui-egui/src/backstage.rs
+++ b/crates/ui-egui/src/backstage.rs
@@ -23,7 +23,8 @@ use crate::theme::{self, Tokens};
 mod options;
 mod print;
 
-use options::{options, platform};
+use options::options;
+pub(crate) use options::platform;
 use print::print;
 
 /// Width of the navigation column.
@@ -817,6 +818,18 @@ fn account(app: &mut SheetApp, ui: &mut Ui) {
             dim(ui, lang.tr("Version, contributors and the AI models that helped"));
         });
     });
+    ui.add_space(10.0);
+    ui.horizontal(|ui| {
+        if square_button(ui, Icon::Info, lang.tr("Report an Issue…")).clicked() {
+            app.run_or_alert("help.reportIssue", json!({}));
+        }
+        ui.add_space(12.0);
+        ui.vertical(|ui| {
+            ui.add_space(14.0);
+            ui.label(RichText::new(lang.tr("Report an Issue…")).font(theme::ui_bold(13.5)).color(t.text));
+            dim(ui, lang.tr("Opens a new GitHub issue with the version and system filled in"));
+        });
+    });
 }
 
 #[cfg(test)]
diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs
--- a/crates/ui-egui/src/backstage/options.rs
+++ b/crates/ui-egui/src/backstage/options.rs
@@ -163,7 +163,7 @@ fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale
 }
 
 /// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".
-pub(super) fn platform() -> String {
+pub(crate) fn platform() -> String {
     let os = match std::env::consts::OS {
         "macos" => "macOS",
         "windows" => "Windows",
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
@@ -899,6 +899,7 @@ impl Dialog {
             "manageRules" => Dialog::custom("manageRules", "Conditional Formatting Rules Manager", json!({})),
             "commandSearch" => Dialog::custom("commandSearch", "Search Commands", json!({})),
             "agents" => Dialog::custom("agents", "Agent Control", json!({})),
+            "failedLink" => Dialog::custom("failedLink", "Couldn't open your web browser", json!({"url": p("url")})),
             "about" => {
                 let mut d = Dialog::custom("about", "About GridCraft", json!({}));
                 d.tab = match p("tab").as_str() {
diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs
--- a/crates/ui-egui/src/dialogs/show.rs
+++ b/crates/ui-egui/src/dialogs/show.rs
@@ -169,6 +169,16 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
                     ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len()));
                 }
                 "about" => about(ui, &mut d),
+                "failedLink" => {
+                    let url = d.values.get("url").and_then(Json::as_str).unwrap_or_default();
+                    ui.label("Open this address in a browser to report the issue:");
+                    // Read-only: a `&str` can be selected but not edited, so Copy always copies the address shown.
+                    let mut shown = url;
+                    ui.add(egui::TextEdit::multiline(&mut shown).desired_width(f32::INFINITY).desired_rows(4));
+                    if ui.button("Copy").clicked() {
+                        ctx.copy_text(url.to_string());
+                    }
+                }
                 "journal" => {
                     egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
                         for (id, p) in app.session.journal.iter().rev().take(500) {
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -32,6 +32,7 @@ pub mod panes;
 pub mod paper_view;
 pub mod pivot_pane;
 pub mod printing;
+mod report_issue;
 pub mod ribbon;
 pub mod selection_glide;
 pub mod tabs;
diff --git a/crates/ui-egui/src/report_issue.rs b/crates/ui-egui/src/report_issue.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/report_issue.rs
@@ -0,0 +1,62 @@
+//! Help ▸ Report an Issue…: opens a new GitHub issue with the version and system filled in. Nothing
+//! is created until the user submits it.
+
+use gridcraft_engine::io::FileKind;
+use serde_json::json;
+
+use crate::SheetApp;
+
+/// The new-issue address. `version` and `environment` (`os` too, for the other open form) are the bug form's field ids, and `body`
+/// fills a blank issue where no form exists. The document is described by its file extension
+/// alone, never a name, path or contents. Plain strings in, so it can move to a shared crate.
+pub(crate) fn issue_url(version: &str, os: &str, document_ext: Option<&str>) -> String {
+    let short = |s: &str| s.chars().filter(|c| !c.is_control()).take(16).collect::<String>();
+    let ext: String = document_ext.unwrap_or("none").chars().filter(char::is_ascii_alphanumeric).take(8).collect();
+    let (version, environment) = (short(version), format!("{}; document: {ext}", short(os)));
+    let encode = |s: &str| {
+        let keep = |b: &u8| b.is_ascii_alphanumeric() || b"-_.~".contains(b);
+        s.bytes().map(|b| if keep(&b) { char::from(b).to_string() } else { format!("%{b:02X}") }).collect::<String>()
+    };
+    let body = format!("GridCraft {version}\n{environment}");
+    let (v, e, b) = (encode(&version), encode(&environment), encode(&body));
+    format!("https://github.com/storytold/gridcraft/issues/new?template=bug_report.yml&version={v}&environment={e}&os={e}&body={b}")
+}
+
+/// `help.reportIssue`: opens the issue form. Returns `{url, opened}` and never opens a dialog;
+/// [`run_menu`] shows the address when no browser opened.
+pub(crate) fn run(app: &mut SheetApp) -> serde_json::Value {
+    // Only a format GridCraft opens is named: an odd extension could be private text.
+    let ext = app
+        .session
+        .active()
+        .and_then(|d| d.path.as_deref())
+        .filter(|p| FileKind::from_path(p).is_some())
+        .and_then(|p| std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase));
+    let url = issue_url(env!("CARGO_PKG_VERSION"), &crate::backstage::platform(), ext.as_deref());
+    let opened = app.services.open_url.as_ref().is_some_and(|open| open(&url));
+    json!({"url": url, "opened": opened})
+}
+
+/// The menu, ribbon and Account entries: like [`run`], plus the address to copy when no browser opened.
+pub(crate) fn run_menu(app: &mut SheetApp) {
+    let result = run(app);
+    if result["opened"] != true {
+        app.open_dialog("failedLink", json!({"url": result["url"]}));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn the_address_carries_version_and_system_only_and_stays_short() {
+        let url = issue_url("0.1.0", "Linux (x86_64)", Some("xlsx"));
+        assert!(url.contains("&version=0.1.0&environment=Linux%20%28x86_64%29%3B%20document%3A%20xlsx&os=Linux%20%28x86_64%29%3B%20document%3A%20xlsx&body=GridCraft%200.1.0%0A"), "{url}");
+        // Hostile text and an unusual extension can't add a parameter, leak, or grow the address.
+        let url = issue_url(&"\u{1F600}".repeat(99), &"&#=".repeat(99), Some("x.y/Z"));
+        assert_eq!(url.matches('?').count() + url.matches('#').count(), 1, "{url}");
+        assert!(url.is_ascii() && url.contains("document%3A%20xyZ") && url.len() < 2048, "{} bytes: {url}", url.len());
+        assert!(issue_url("1", "w", None).contains("document%3A%20none"));
+    }
+}
diff --git a/crates/ui-egui/src/ribbon/automate.rs b/crates/ui-egui/src/ribbon/automate.rs
--- a/crates/ui-egui/src/ribbon/automate.rs
+++ b/crates/ui-egui/src/ribbon/automate.rs
@@ -25,6 +25,10 @@ pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) {
     if __k.clicked() {
         app.run_or_alert("app.checkForUpdates", json!({}));
     }
+    let __k = big_button(ui, Icon::Script, "Report an\nIssue…", "Open a new GitHub issue with the version and system filled in", false);
+    if __k.clicked() {
+        app.run_or_alert("help.reportIssue", json!({}));
+    }
     let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false);
     control_keytip(app, ui, &__k, "about");
     if __k.clicked() {
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -14,7 +14,8 @@ pub struct Services {
     /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot
     /// insert into a subsequent dialog or open the image as a workbook.
     pub pick_picture_async: Option<Box<dyn Fn(Inbox)>>,
-    pub open_url: Option<Box<dyn Fn(&str)>>,
+    /// Opens an http, https or mailto address in the user's browser; `false` when none opened.
+    pub open_url: Option<Box<dyn Fn(&str) -> bool>>,
     /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
     /// write (including any asynchronous fallback); an immediate error uses egui's text path.
     pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
