diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (hidden columns no longer inflate frozen header rows) · **Target:** Microsoft Excel (Microsoft 365)
 
 GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
 is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -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 | Hidden and zero-width columns no longer inflate automatic row heights to the limit, keeping rows below a frozen header visible in the synthetic #2724 repro; tests cover import, AutoFit, zoom, freeze positions and scrolling |
 | 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`) |
@@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Progress log: hidden columns no longer inflate frozen header rows (#2724 synthetic repro) |
 | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
 | 2026-10-23 | minor | Progress log: accounting negatives in number text |
 | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs
--- a/crates/engine/src/cmd.rs
+++ b/crates/engine/src/cmd.rs
@@ -559,8 +559,10 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
     let row_merges: Vec<gridcraft_core::RangeRef> =
         sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
     for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
-        // Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
-        if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
+        // Hidden columns have no displayed text to fit. Measuring their zero width would
+        // wrap even a short header to the row-height limit and consume a frozen viewport.
+        // Merged cells never auto-fit their rows (column autofit skips them too).
+        if sh.col_width(c) == 0.0 || row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
             continue;
         }
         let st = wb.styles.get(cell.style);
diff --git a/crates/engine/tests/it/wrap_row_height.rs b/crates/engine/tests/it/wrap_row_height.rs
--- a/crates/engine/tests/it/wrap_row_height.rs
+++ b/crates/engine/tests/it/wrap_row_height.rs
@@ -2,7 +2,9 @@
 //! estimate (words can't split, so each drawn line ends early and narrow columns
 //! need more lines than width/estimate suggests).
 
-use gridcraft_engine::Session;
+use gridcraft_engine::core::{CellRef, Value};
+use gridcraft_engine::model::{StyleId, Workbook};
+use gridcraft_engine::{DocState, Session, Trust};
 use serde_json::json;
 
 fn session() -> Session {
@@ -40,3 +42,35 @@ fn issue_repro_nasa_sentence() {
     // Excel shows this at ~10 lines in a 12-char column; the character estimate undercounts.
     assert!(h >= 9.0 * 19.0, "NASA sentence in a 12-char column needs ~10 lines, got height {h}");
 }
+
+#[test]
+fn hidden_columns_do_not_inflate_automatic_row_heights() {
+    for width in [0.0, 64.0] {
+        let mut wb = Workbook::new();
+        let wrapped = wb.styles.derive(StyleId::DEFAULT, |s| {
+            s.align.wrap = true;
+            s.font.size = 24.0;
+        });
+        let sh = wb.sheet_mut(0).unwrap();
+        sh.set_value(CellRef::new(0, 0), Value::text("Visible heading"));
+        sh.set_value(CellRef::new(0, 1), Value::text("Hidden heading that must not set the visible row height"));
+        sh.set_style(CellRef::new(0, 1), wrapped);
+        let col = sh.cols.entry(1).or_default();
+        col.size = Some(width);
+        col.hidden = width != 0.0;
+        sh.rows.entry(0).or_default().size = Some(70.0);
+        let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
+        let (opened, _) = gridcraft_engine::io::open_bytes("hidden-header.xlsx", &bytes).unwrap();
+        assert_eq!(opened.active().unwrap().row_height(0), 70.0, "open keeps the saved height, width={width}");
+
+        let mut s = Session::new();
+        s.add_document(DocState::new(opened, None, "Hidden header".into(), Trust::Local));
+        s.execute("home.autofitRowHeight", json!({"rows": "1:1"})).unwrap();
+        let sh = s.active().unwrap().wb.active().unwrap();
+        assert_eq!(sh.row_height(0), sh.default_row_height, "AutoFit measures only the visible heading, width={width}");
+        assert_eq!(sh.value(CellRef::new(0, 1)), Value::text("Hidden heading that must not set the visible row height"));
+        s.execute("home.unhideColumns", json!({"cols": "B:B"})).unwrap();
+        s.execute("home.autofitRowHeight", json!({"rows": "1:1"})).unwrap();
+        assert!(s.active().unwrap().wb.active().unwrap().row_height(0) > 70.0, "the heading fits once its column is visible");
+    }
+}
diff --git a/crates/ui-egui/tests/it/freeze_zoom.rs b/crates/ui-egui/tests/it/freeze_zoom.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/freeze_zoom.rs
@@ -0,0 +1,69 @@
+//! A hidden wrapped header must not push data below the frozen rows out of view (#2724).
+
+use egui::Shape;
+use gridcraft_engine::core::{CellRef, RangeRef, Value};
+use gridcraft_engine::model::Workbook;
+use gridcraft_engine::{DocState, Session, Trust};
+use gridcraft_ui_egui::grid::Geo;
+use serde_json::json;
+
+#[test]
+fn imported_hidden_header_keeps_data_visible_below_freeze_at_different_zooms() {
+    let mut wb = Workbook::new();
+    let wrapped = wb.styles.derive(Default::default(), |s| s.align.wrap = true);
+    let sh = wb.sheet_mut(0).unwrap();
+    for row in 0..40 {
+        for col in 0..47 {
+            sh.set_value(CellRef::new(row, col), Value::text(format!("R{}C{}", row + 1, col + 1)));
+        }
+    }
+    // Preserve the issue's 47-column A1:AU3968 used range without a binary fixture.
+    sh.set_value(CellRef::new(3967, 46), Value::text("last cell"));
+    sh.set_value(CellRef::new(7, 46), Value::text("Hidden wrapped budget heading"));
+    sh.set_style(CellRef::new(7, 46), wrapped);
+    sh.cols.entry(46).or_default().hidden = true;
+    sh.rows.entry(7).or_default().size = Some(70.0);
+    sh.freeze = Some((8, 10));
+    sh.zoom = 130;
+    sh.view_active = CellRef::new(7, 0);
+    sh.view_top_left = CellRef::new(8, 10);
+    let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
+    let (opened, _) = gridcraft_engine::io::open_bytes("freeze-zoom.xlsx", &bytes).unwrap();
+    let mut session = Session::new();
+    session.add_document(DocState::new(opened, None, "Freeze zoom".into(), Trust::FromFile));
+    let mut h = crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1896.0, 1014.0)), crate::common::app(session), 4);
+    for zoom in [50, 75, 100, 110, 125, 130, 150, 175, 200] {
+        for freeze in ["A1", "A9", "K1", "K9", "C4"] {
+            h.state_mut().session.execute("view.freezePanes", json!({"cell": freeze})).unwrap();
+            h.state_mut().session.execute("view.zoom", json!({"percent": zoom})).unwrap();
+            for scroll in [egui::Vec2::ZERO, egui::vec2(37.5, 205.25)] {
+                h.state_mut().view_mut().unwrap().scroll = scroll;
+                h.run_steps(4);
+                let app = h.state();
+                let sh = app.session.active().unwrap().wb.active().unwrap();
+                assert_eq!(sh.row_height(7), 70.0, "the frozen header keeps its saved height");
+                let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
+                let mut checked = 0;
+                for row in 8..40 {
+                    for col in [0, 9, 10, 11] {
+                        let cell = CellRef::new(row, col);
+                        let rect = geo.cell_rect(sh, cell);
+                        if !geo.cells.contains_rect(rect) || !geo.range_panes(RangeRef::cell(cell)).iter().any(|pane| pane.contains_rect(rect)) {
+                            continue;
+                        }
+                        let text = format!("R{}C{}", row + 1, col + 1);
+                        let painted = h.output().shapes.iter().any(|s| match &s.shape {
+                            Shape::Text(t) if t.galley.job.text == text => {
+                                s.clip_rect.intersect(t.galley.rect.translate(t.pos.to_vec2())).intersects(rect.shrink(1.0))
+                            }
+                            _ => false,
+                        });
+                        assert!(painted, "{} must paint at zoom {zoom}, freeze {freeze}, scroll {scroll:?}", cell.a1());
+                        checked += 1;
+                    }
+                }
+                assert!(checked > 0, "data rows are visible at zoom {zoom}, freeze {freeze}, scroll {scroll:?}");
+            }
+        }
+    }
+}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -46,6 +46,7 @@ mod formula_locale;
 mod formula_point_headers;
 mod formula_popup;
 mod freeze_handles;
+mod freeze_zoom;
 mod go_to_dialog;
 mod header_autofit;
 mod header_column_drag;
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 parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (hidden columns no longer inflate frozen header rows) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
 
 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
 **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -63,7 +63,7 @@ this logic, with one undo step.
 | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). |
 | Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; Data › Outline › Settings (`data.outlineSettings`) puts summary rows above and summary columns left of their detail, saved as `outlinePr` (the dialog's Automatic styles check box and Apply Styles button are missing), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog |
 | Reorder selected whole columns by dragging their headers | done (10-11) |
-| Freeze panes, split panes | freeze done; split to verify |
+| Freeze panes, split panes | freeze done; hidden wrapped columns do not inflate automatic header heights and hide the scrolling rows (#2724 synthetic repro); split to verify |
 | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
 | Copy as HTML table for other apps | done (#36, 10-10) |
 | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
@@ -187,6 +187,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Automatic row sizing ignores hidden and zero-width columns, preventing a hidden wrapped header from growing a frozen row to the height limit. Synthetic A1:AU3968 UI regression covers nine zoom levels, five freeze positions and fractional scroll offsets (#2724); the reported workbook and a live Excel comparison were unavailable |
 | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty |
 | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |
 | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |
