diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -403,6 +403,7 @@ Newest first.
 - **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change.
 - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
 - **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
+- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view moves to the row's page when that page is not the one at the center of the view. A row number past the section's last row is refused. No readiness percentage change.
 - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
 - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
 - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -401,6 +401,15 @@ fn table_specs() -> Vec<CommandSpec> {
         cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
             select(s, &super::with_param(p, "what", json!("column")))
         }),
+        cmd!(
+            noundo "table.goToRow",
+            "Go to Row…",
+            ["Table"],
+            None,
+            "{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the page showing the row (from 1), or null when the row is overset}",
+            in_table_or_ids,
+            go_to_row
+        ),
         cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
         cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
         cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get),
@@ -1531,6 +1540,54 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
     Ok(json!({"story": g.story.0, "table": g.table, "range": r}))
 }
 
+/// Table › Go to Row…: select row `row` (from 1) of the header, body or footer rows.
+fn go_to_row(s: &mut Session, p: &Value) -> Result<Value> {
+    const ID: &str = "table.goToRow";
+    // Only the table comes from `p`: `row` here counts within a section, not from the table's top.
+    let mut at = serde_json::Map::new();
+    for key in ["story", "table"] {
+        if let Some(v) = p.get(key) {
+            at.insert(key.into(), v.clone());
+        }
+    }
+    let g = target(s, &Value::Object(at), ID)?;
+    let n = p.get("row").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| bad(ID, "row must be a whole number from 1"))?;
+    let (index, owners, nc) = {
+        let st = s.doc()?;
+        let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
+        let (h, f, nr) = (t.header_rows(), t.footer_rows(), t.nrows());
+        let (section, first, count) = match str_param(p, "section").unwrap_or("body") {
+            "header" => ("header", 0, h),
+            "body" => ("body", h, nr.saturating_sub(h + f)),
+            "footer" => ("footer", nr.saturating_sub(f), f),
+            other => return Err(bad(ID, format!("unknown section `{other}` (header, body or footer)"))),
+        };
+        if count == 0 {
+            return Err(bad(ID, format!("the table has no {section} rows")));
+        }
+        let n = usize::try_from(n).unwrap_or(usize::MAX);
+        if n > count {
+            return Err(bad(ID, format!("row {n} is past the last of the table's {count} {section} rows")));
+        }
+        (first + n - 1, t.owners(), t.ncols())
+    };
+    let r = select(s, &json!({"story": g.story.0, "table": g.table, "rows": [index, index], "what": "row"}))?;
+    // The page of the frame that shows the row: the cells that start in it or reach it from above.
+    let start = index.saturating_mul(nc);
+    let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default();
+    let st = s.doc()?;
+    let cs = s.cache.get(&st.doc, g.story, None);
+    let frame = cs.frames.iter().find_map(|ft| {
+        ft.tables
+            .iter()
+            .filter(|t| t.table == g.table)
+            .any(|t| t.cells.iter().any(|c| !c.repeated && cells.contains(&(c.row, c.col))))
+            .then_some(ft.frame)
+    });
+    let page = frame.and_then(|f| st.doc.page_of_item(f)).map(|p| p + 1);
+    Ok(json!({"story": r["story"], "table": r["table"], "range": r["range"], "page": page}))
+}
+
 /// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last
 /// cell adds a row.
 pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
@@ -2321,6 +2378,95 @@ mod sort_tests {
     }
 }
 
+#[cfg(test)]
+mod go_to_row_tests {
+    use designcraft_doc::CellRange;
+    use serde_json::json;
+
+    use crate::Session;
+
+    /// A table of 1 header, 3 body and 1 footer rows, 2 columns wide, with the caret in its first cell.
+    fn session() -> Session {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
+        s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
+        s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1, "footerRows": 1})).unwrap();
+        s
+    }
+
+    fn selected(s: &Session) -> CellRange {
+        s.doc().unwrap().selection.cells.unwrap().range
+    }
+
+    #[test]
+    fn go_to_row_selects_a_row_counted_within_its_section() {
+        let mut s = session();
+        let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap();
+        assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }, "the second body row, under the header row");
+        assert_eq!(r["page"], 1);
+        s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap();
+        assert_eq!(selected(&s), CellRange { r0: 0, c0: 0, r1: 0, c1: 1 });
+        s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).unwrap();
+        assert_eq!(selected(&s), CellRange { r0: 4, c0: 0, r1: 4, c1: 1 });
+        s.execute("table.goToRow", &json!({"row": 3, "section": "body"})).unwrap();
+        assert_eq!(selected(&s), CellRange { r0: 3, c0: 0, r1: 3, c1: 1 });
+        // The caret goes to the row's first cell.
+        assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((3, 0)));
+    }
+
+    #[test]
+    fn go_to_row_refuses_rows_outside_the_section() {
+        let mut s = session();
+        s.execute("table.goToRow", &json!({"row": 1})).unwrap();
+        let before = selected(&s);
+        for p in [
+            json!({"row": 4}),
+            json!({"row": 0}),
+            json!({"row": -1}),
+            json!({"row": "2"}),
+            json!({}),
+            json!({"row": 2, "section": "footer"}),
+            json!({"row": 1, "section": "side"}),
+        ] {
+            assert!(s.execute("table.goToRow", &p).is_err(), "{p}");
+            assert_eq!(selected(&s), before, "{p} leaves the selection");
+        }
+        s.execute("table.options", &json!({"headerRows": 0, "footerRows": 0})).unwrap();
+        assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).is_err());
+        assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err());
+    }
+
+    #[test]
+    fn go_to_row_selects_a_row_of_a_named_table_outside_the_selection() {
+        let mut s = session();
+        let t = s.execute("table.get", &json!({})).unwrap();
+        let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap();
+        s.execute("text.select", &json!({"story": other["story"], "anchor": 0, "focus": 0})).unwrap();
+        assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected");
+        s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap();
+        assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 });
+    }
+
+    #[test]
+    fn go_to_row_names_the_page_that_shows_the_row() {
+        let mut s = Session::new();
+        s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+        let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
+        let b = s.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap();
+        s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap();
+        s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
+        s.execute("table.insert", &json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap();
+        for row in 0..21 {
+            s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
+        }
+        assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1);
+        assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2);
+        // The header row repeats on page 2; the row itself is the one on page 1.
+        assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1);
+    }
+}
+
 #[cfg(test)]
 mod arabic_tests {
     use super::*;
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
@@ -1005,6 +1005,60 @@ fn doc_unit(app: &DesignApp) -> Unit {
     app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas)
 }
 
+/// Table › Go to Row…: opens on the target table's first selected row, numbered within its header,
+/// body or footer rows.
+pub fn open_go_to_row(app: &mut DesignApp) {
+    let Ok(t) = app.run("table.get", json!({})) else { return };
+    let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0"));
+    let nr = t["rows"].as_array().map_or(0, Vec::len);
+    let (section, n) = if r < h {
+        ("header", r + 1)
+    } else if r >= nr.saturating_sub(f) {
+        ("footer", r + 1 - nr.saturating_sub(f))
+    } else {
+        ("body", r + 1 - h)
+    };
+    app.ui.dialog = Some(Dialog::new(
+        "goToRow",
+        json!({
+            "uid": app.session.active().map(|doc| doc.uid),
+            "story": t["story"],
+            "table": t["table"],
+            "section": section,
+            "row": n.to_string(),
+            "hasHeader": h > 0,
+            "hasFooter": f > 0
+        }),
+    ));
+}
+
+fn count_at(v: &Value, k: &str) -> usize {
+    v[k].as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0)
+}
+
+/// The Go to Row dialog: the section menu shows when the table has header or footer rows.
+fn go_to_row_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
+    let tr = |s: &'static str| crate::i18n::tr(&app.ui.language, s);
+    ui.horizontal(|ui| {
+        if d.b("hasHeader") || d.b("hasFooter") {
+            let mut opts = Vec::new();
+            if d.b("hasHeader") {
+                opts.push(("header", tr("Header Rows")));
+            }
+            opts.push(("body", tr("Body Rows")));
+            if d.b("hasFooter") {
+                opts.push(("footer", tr("Footer Rows")));
+            }
+            combo(ui, d, "section", &opts);
+        }
+        crate::rtl::label(ui, tr("Row"));
+        text_field_named(ui, d, "row", 60.0, tr("Row"));
+    });
+    if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
+        ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
+    }
+}
+
 /// Layout › Create Guides…: guides go on the page in view (or the parent page being edited), not
 /// on the first page, and the gutters are typed in the document's ruler unit.
 pub fn open_create_guides(app: &mut DesignApp) {
@@ -2266,6 +2320,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
         "ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
         "newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
         "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
+        "goToRow" => crate::i18n::tr(&app.ui.language, "Go to Row"),
         "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"),
         "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"),
         "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"),
@@ -3027,6 +3082,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
                             text_field(ui, &mut d, "page", 80.0);
                         });
                     }
+                    "goToRow" => go_to_row_dialog(app, ui, &mut d),
                     "ruby" => style_cjk::ruby_dialog(app, ui, &mut d),
                     "insertTable" => {
                         crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0)));
@@ -3342,6 +3398,36 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
             )
         }
         "ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)),
+        "goToRow" => {
+            // The story and table belong to the document the dialog was opened for: that document
+            // becomes active first, and when it has closed, nothing is selected.
+            let uid = d.fields.get("uid").and_then(Value::as_u64);
+            if uid.is_some() && uid != app.session.active().map(|doc| doc.uid) {
+                let Some(i) = app.session.documents().iter().position(|doc| Some(doc.uid) == uid) else { return Ok(Value::Null) };
+                app.run("file.activate", json!({"index": i}))?;
+            }
+            // Anything but a whole number goes as typed, and the command's message names the fault.
+            let typed = d.s("row").trim().to_string();
+            let row = typed.parse::<u64>().map_or_else(|_| json!(typed), |n| json!(n));
+            let p = json!({"story": d.fields.get("story"), "table": d.fields.get("table"), "section": d.s("section"), "row": row});
+            let r = app.run("table.goToRow", p);
+            match &r {
+                Ok(v) => {
+                    // The view moves when the row's page is not the page at the center of the view.
+                    if let Some(page) = v["page"].as_u64().and_then(|p| usize::try_from(p).ok()).and_then(|p| p.checked_sub(1))
+                        && crate::canvas::current_page(app) != Some(page)
+                    {
+                        crate::canvas::go_to_page(app, page);
+                    }
+                }
+                Err(e) => {
+                    let mut d = d.clone();
+                    d.fields.insert("status".into(), json!(e));
+                    app.ui.dialog = Some(d);
+                }
+            }
+            r
+        }
         "goToPage" => {
             // A page name ("iv", "A-3"), a number, or "+n" for an absolute position.
             let typed = match d.fields.get("page") {
@@ -10930,6 +11016,70 @@ mod tests {
         assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]);
     }
 
+    #[test]
+    fn go_to_row_dialog_selects_the_row_and_shows_its_page() {
+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
+        app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
+        let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
+        let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap();
+        app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap();
+        app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
+        app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap();
+        for row in 0..21 {
+            app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
+        }
+        app.run("table.select", json!({"rows": [2, 2]})).unwrap();
+        crate::canvas::go_to_page(&mut app, 0);
+        crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
+        let d = app.ui.dialog.clone().unwrap();
+        assert_eq!(d.id, "goToRow");
+        assert_eq!((d.s("section"), d.s("row")), ("body".to_string(), "2".to_string()), "it opens on the selected row");
+        assert!(d.b("hasHeader") && !d.b("hasFooter"));
+        // A row that is not a whole number keeps the dialog open with the reason, and the view stays.
+        ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "x"}));
+        assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
+        assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "goToRow" && d.fields.contains_key("status")));
+        assert_eq!(crate::canvas::current_page(&app), Some(0));
+        ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "20"}));
+        assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
+        assert!(app.ui.dialog.is_none());
+        let range = app.session.active().unwrap().selection.cells.unwrap().range;
+        assert_eq!((range.r0, range.r1, range.c0, range.c1), (20, 20, 0, 1));
+        assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row");
+    }
+
+    /// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even
+    /// when another document became active meanwhile; when that document has closed, nothing is selected.
+    #[test]
+    fn go_to_row_dialog_goes_to_the_document_it_was_opened_for() {
+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        let table_doc = |app: &mut DesignApp, title: &str| {
+            app.run("file.new", json!({"title": title})).unwrap();
+            let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
+            app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
+            app.run("table.insert", json!({"rows": 3, "cols": 2})).unwrap();
+        };
+        table_doc(&mut app, "first");
+        crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
+        table_doc(&mut app, "second");
+        setup_fields(&mut app, json!({"row": "3"}));
+        confirm(&mut app).unwrap();
+        let active = app.session.active().unwrap();
+        assert_eq!(active.doc.title, "first", "the dialog's document becomes active");
+        assert_eq!(active.selection.cells.map(|c| (c.range.r0, c.range.r1)), Some((2, 2)));
+        let second = app.session.documents().iter().find(|d| d.doc.title == "second").unwrap();
+        assert!(second.selection.cells.is_none(), "the other document's selection stays");
+
+        crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
+        app.run("file.close", json!({})).unwrap();
+        let before = app.session.active().unwrap().selection.clone();
+        setup_fields(&mut app, json!({"row": "1"}));
+        assert_eq!(confirm(&mut app), Ok(Value::Null));
+        assert!(app.ui.dialog.is_none());
+        assert_eq!(app.session.active().unwrap().selection, before, "the dialog's document closed");
+    }
+
     #[test]
     fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() {
         let mut app = facing_app(4);
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
@@ -1104,6 +1104,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
     ("Generate QR Code", ["Generate QR Code", "Générer un code QR", "Generate QR Code", "Generate QR Code", "生成二维码"]),
     ("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]),
     ("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]),
+    ("Go to Row", ["Go to Row", "Atteindre la ligne", "Go to Row", "Go to Row", "转到行"]),
     ("Gutter", ["Gutter", "Gouttière", "Gutter", "Gutter", "栏间距"]),
     ("Header Rows", ["Header Rows", "Lignes d'en-tête", "Header Rows", "Header Rows", "表头行"]),
     ("Height", ["Height", "Hauteur", "Height", "Height", "高度"]),
@@ -2082,6 +2083,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
         ],
     ),
     ("Go to Page…", ["Go to Page…", "Atteindre la page…", "Go to Page…", "Go to Page…", "转到页面…"]),
+    ("Go to Row…", ["Go to Row…", "Atteindre la ligne…", "Go to Row…", "Go to Row…", "转到行…"]),
     ("Gradient Feather", ["Gradient Feather", "Contour progressif dégradé", "Gradient Feather", "Gradient Feather", "渐变羽化"]),
     ("Gridify", ["Gridify", "Mettre en grille", "Gridify", "Gridify", "网格化"]),
     ("Group Transparency", ["Group Transparency", "Transparence du groupe", "Group Transparency", "Group Transparency", "编组透明度"]),
diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs
--- a/crates/ui-egui/src/i18n/ar.rs
+++ b/crates/ui-egui/src/i18n/ar.rs
@@ -203,6 +203,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Numbering & Section Options…", "خيارات الترقيم والأقسام…"),
     ("Go to Page…", "انتقال إلى صفحة…"),
     ("Go to Page", "انتقال إلى صفحة"),
+    ("Go to Row…", "انتقال إلى صف…"),
+    ("Go to Row", "انتقال إلى صف"),
     ("Next Page", "الصفحة التالية"),
     ("Previous Page", "الصفحة السابقة"),
     ("First Page", "الصفحة الأولى"),
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -578,6 +578,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Přejít na zdroj"),
     ("Go to Footnote Reference", "Přejít na odkaz poznámky pod čarou"),
     ("Go to Page…", "Přejít na stránku…"),
+    ("Go to Row…", "Přejít na řádek…"),
+    ("Go to Row", "Přejít na řádek"),
     ("Gradient Feather", "Přechodové zjemnění okraje"),
     ("Gridify", "Rozmístit do mřížky"),
     ("Group Transparency", "Průhlednost skupiny"),
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -278,6 +278,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Generate QR Code", "QR-Code erzeugen"),
     ("Go To", "Gehe zu"),
     ("Go to Page", "Gehe zu Seite"),
+    ("Go to Row", "Gehe zu Zeile"),
     ("Gutter", "Spaltenabstand"),
     ("Header Rows", "Tabellenkopfzeilen"),
     ("Height", "Höhe"),
@@ -615,6 +616,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Gehe zu Quelle"),
     ("Go to Footnote Reference", "Gehe zu Fußnotenverweis"),
     ("Go to Page…", "Gehe zu Seite …"),
+    ("Go to Row…", "Gehe zu Zeile …"),
     ("Gradient Feather", "Weiche Verlaufskante"),
     ("Gridify", "Rastern"),
     ("Group Transparency", "Gruppentransparenz"),
diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs
--- a/crates/ui-egui/src/i18n/it.rs
+++ b/crates/ui-egui/src/i18n/it.rs
@@ -706,6 +706,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Vai all'origine"),
     ("Go to Footnote Reference", "Vai al riferimento della nota a piè di pagina"),
     ("Go to Page…", "Vai alla pagina…"),
+    ("Go to Row…", "Vai alla riga…"),
+    ("Go to Row", "Vai alla riga"),
     ("Gradient Feather", "Bordi sfocati con sfumatura"),
     ("Gridify", "Disponi a griglia"),
     ("Group Transparency", "Trasparenza gruppo"),
diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs
--- a/crates/ui-egui/src/i18n/ja.rs
+++ b/crates/ui-egui/src/i18n/ja.rs
@@ -463,6 +463,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Generate QR Code", "QR コードを生成"),
     ("Go To", "移動"),
     ("Go to Page", "ページへ移動"),
+    ("Go to Row", "行へ移動"),
     ("Gutter", "段間"),
     ("Header Rows", "ヘッダー行"),
     ("Height", "高さ"),
@@ -807,6 +808,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "リンク元へ移動"),
     ("Go to Footnote Reference", "脚注参照番号へ移動"),
     ("Go to Page…", "ページへ移動…"),
+    ("Go to Row…", "行へ移動…"),
     ("Gradient Feather", "グラデーションぼかし"),
     ("Gridify", "グリッド化"),
     ("Group Transparency", "グループの透明"),
diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs
--- a/crates/ui-egui/src/i18n/ka.rs
+++ b/crates/ui-egui/src/i18n/ka.rs
@@ -634,6 +634,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "წყაროზე გადასვლა"),
     ("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"),
     ("Go to Page…", "გვერდზე გადასვლა…"),
+    ("Go to Row…", "სტრიქონზე გადასვლა…"),
+    ("Go to Row", "სტრიქონზე გადასვლა"),
     ("Gradient Feather", "კიდეების გრადიენტული შერბილება"),
     ("Gridify", "ბადედ განლაგება"),
     ("Group Transparency", "ჯგუფის გამჭვირვალობა"),
diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs
--- a/crates/ui-egui/src/i18n/pl.rs
+++ b/crates/ui-egui/src/i18n/pl.rs
@@ -585,6 +585,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Przejdź do źródła"),
     ("Go to Footnote Reference", "Przejdź do odwołania przypisu"),
     ("Go to Page…", "Przejdź do strony…"),
+    ("Go to Row…", "Przejdź do wiersza…"),
+    ("Go to Row", "Przejdź do wiersza"),
     ("Gradient Feather", "Wtapianie gradientowe"),
     ("Gridify", "Rozmieść w siatce"),
     ("Group Transparency", "Przezroczystość grupy"),
diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs
--- a/crates/ui-egui/src/i18n/pt_br.rs
+++ b/crates/ui-egui/src/i18n/pt_br.rs
@@ -208,6 +208,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Create Guides…", "Criar guias…"),
     ("Numbering & Section Options…", "Opções de numeração e de seção…"),
     ("Go to Page…", "Ir para página…"),
+    ("Go to Row…", "Ir para linha…"),
+    ("Go to Row", "Ir para linha"),
     ("Go to Page", "Ir para página"),
     ("Next Page", "Próxima página"),
     ("Previous Page", "Página anterior"),
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -565,6 +565,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Перейти к источнику"),
     ("Go to Footnote Reference", "Перейти к ссылке на сноску"),
     ("Go to Page…", "Перейти к странице…"),
+    ("Go to Row…", "Перейти к строке…"),
+    ("Go to Row", "Перейти к строке"),
     ("Gradient Feather", "Градиентная растушевка"),
     ("Gridify", "Разместить в виде сетки"),
     ("Group Transparency", "Прозрачность группы"),
diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs
--- a/crates/ui-egui/src/i18n/uk.rs
+++ b/crates/ui-egui/src/i18n/uk.rs
@@ -709,6 +709,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
     ("Go To Source", "Перейти до джерела"),
     ("Go to Footnote Reference", "Перейти до посилання на виноску"),
     ("Go to Page…", "Перейти до сторінки…"),
+    ("Go to Row…", "Перейти до рядка…"),
+    ("Go to Row", "Перейти до рядка"),
     ("Gradient Feather", "Градієнтне розтушовування"),
     ("Gridify", "Розмістити сіткою"),
     ("Group Transparency", "Прозорість групи"),
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
@@ -818,6 +818,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
             "cmd:table.distributeColumns",
             "cmd:table.distributeRows",
             "-",
+            "cmd:table.goToRow",
+            "-",
             "ui:app.tablePanel",
         ],
     ),
@@ -2574,6 +2576,10 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
     if id.starts_with("fit.") {
         return enabled(app, "object.fit");
     }
+    // Go to Row runs on a named table from anywhere; the menu item needs the selection in a table.
+    if id == "table.goToRow" {
+        return enabled(app, "table.selectColumn");
+    }
     if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
         return false;
     }
@@ -2662,6 +2668,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
         app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
         return;
     }
+    if params.is_null() && id == "table.goToRow" {
+        crate::dialogs::open_go_to_row(app);
+        return;
+    }
     // New Paragraph/Character Style…: the style options for the new style.
     if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") {
         crate::dialogs::open_new_style(app, id == "style.paragraph.create");
@@ -3745,6 +3755,17 @@ mod tests {
         }
     }
 
+    #[test]
+    fn go_to_row_is_enabled_in_a_table() {
+        let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", json!({})).unwrap();
+        let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": "Notes"})).unwrap();
+        app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
+        assert!(!menu_enabled(&app, "table.goToRow"), "the insertion point is not in a table");
+        app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap();
+        assert!(menu_enabled(&app, "table.goToRow"));
+    }
+
     #[test]
     fn pdf_export_menu_item_opens_the_dialog_or_exports() {
         // No params: the menu item opens the options dialog.
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -150,8 +150,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
 
 ### 12. Tables in depth (F)
 - **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options
-  submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer,
-  Go to Row; general reliability (#20).
+  submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer; general reliability
+  (#20).
 - **Estimate:** 25–40 h.
 
 ### 13. Hyphenation and spelling languages (T, L)
@@ -177,13 +177,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
 - **Estimate:** 30–50 h.
 
 ### 16. Menu items still missing (U)
-- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
-- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
+- **Missing:** 147 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
   and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference
-  update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear
-  Effects / Clear All Transparency / Rotate 180° / Reverse Path,
   update commands, Convert Bullets/Numbering to Text, Object ▸ Clear
-  Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward,
+  Effects / Clear All Transparency / Rotate 180°,
   Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to
   Profile / Edit Original, File ▸ Open PDF / Document and Print Presets / User
   Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
