diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -239,6 +239,10 @@ pub const HUD_SPACING: f32 = HUD_SIZE * 0.15;
 /// Buttons: 11 px Geist Mono 700, upper case, 0.12 em apart.
 pub const BUTTON_SPACING: f32 = HUD_SIZE * 0.12;
 
+/// The room a table header keeps after its label for the sort arrow: the 4 px gap, the arrow and
+/// a little slack.
+pub const HEADER_ARROW_W: f32 = 12.0;
+
 /// Inter 500.
 pub fn medium() -> FontFamily {
     FontFamily::Name("medium".into())
diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs
--- a/crates/ui-egui/src/views.rs
+++ b/crates/ui-egui/src/views.rs
@@ -510,25 +510,37 @@ impl LauncherUi {
             ui.ctx().layer_painter(ui.layer_id()).with_clip_rect(clip).hline(span, y, t.hairline());
         };
         egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {
+            // The sort key each header toggles, its label in this language, and the width the
+            // table was designed with. A column is never narrower than that, whatever the
+            // language: a longer localized header (Polish "Zainstalowana") widens its column
+            // instead of being cut off.
+            let headers: [(Option<SortKey>, &str, f32); 6] = [
+                (Some(SortKey::Name), tl!("App"), 220.0),
+                (Some(SortKey::Status), tl!("Status"), 176.0),
+                (Some(SortKey::Installed), tr_ctx(current(), "column", "Installed"), 84.0),
+                (Some(SortKey::Latest), tr_ctx(current(), "column", "Latest"), 84.0),
+                (Some(SortKey::Released), tr_ctx(current(), "column", "Released"), 104.0),
+                (None, "", 206.0),
+            ];
+            // What a header needs in this language: its label plus the sort arrow. The table
+            // opens at this width, and each column then holds at least its widest cell — the
+            // header claiming the same width below, so the two always agree.
+            let header_width = |label: &str, min: f32| {
+                if label.is_empty() { min } else { (widgets::hud_galley(ui, label, Color32::PLACEHOLDER).size().x + theme::HEADER_ARROW_W).max(min) }
+            };
+            let widths: [f32; 6] = headers.map(|(_, label, min)| header_width(label, min));
             TableBuilder::new(ui)
                 .striped(false)
                 .vscroll(false)
                 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
-                .column(Column::remainder().at_least(220.0).clip(true))
-                .column(Column::exact(176.0))
-                .column(Column::exact(84.0))
-                .column(Column::exact(84.0))
-                .column(Column::exact(104.0))
-                .column(Column::exact(206.0))
+                .column(Column::remainder().at_least(headers[0].2).clip(true))
+                .column(Column::auto_with_initial_suggestion(widths[1]).at_least(headers[1].2))
+                .column(Column::auto_with_initial_suggestion(widths[2]).at_least(headers[2].2))
+                .column(Column::auto_with_initial_suggestion(widths[3]).at_least(headers[3].2))
+                .column(Column::auto_with_initial_suggestion(widths[4]).at_least(headers[4].2))
+                .column(Column::auto_with_initial_suggestion(widths[5]).at_least(headers[5].2))
                 .header(42.0, |mut header| {
-                    for (key, label) in [
-                        (Some(SortKey::Name), tl!("App")),
-                        (Some(SortKey::Status), tl!("Status")),
-                        (Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),
-                        (Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),
-                        (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),
-                        (None, ""),
-                    ] {
+                    for (key, label, _) in headers {
                         header.col(|ui| {
                             if key == Some(SortKey::Name) {
                                 rule(ui);
@@ -536,12 +548,17 @@ impl LauncherUi {
                             let Some(k) = key else { return };
                             let active = k == self.settings.sort;
                             let tip = if active { tl!("Click to reverse the order") } else { tl!("Click to sort by this column") };
-                            // Measure first so the whole header cell (label + arrow) is the target.
-                            let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click());
+                            // The whole cell is the sort target; the label paints at its left.
+                            let cell = ui.max_rect();
+                            let resp = ui.interact(cell, ui.id().with(("header-sort", label)), Sense::click());
                             let hovered = resp.hovered();
                             let color = if active || hovered { t.ink_strong } else { t.faint };
                             let galley = widgets::hud_galley(ui, label, color);
-                            let text_pos = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);
+                            // Claim only the label plus the sort arrow — never the whole cell: an
+                            // auto-sized column then measures exactly what the header draws, so a
+                            // localized header wider than the column widens it instead of clipping.
+                            let _ = ui.allocate_space(vec2(galley.size().x + theme::HEADER_ARROW_W, ui.available_height()));
+                            let text_pos = egui::pos2(cell.left(), cell.center().y - galley.size().y / 2.0);
                             let arrow_x = text_pos.x + galley.size().x + 4.0;
                             let p = ui.painter();
                             p.galley(text_pos, galley, color);
@@ -554,7 +571,7 @@ impl LauncherUi {
                                 None
                             };
                             if let Some(down) = arrow {
-                                let c = egui::pos2(arrow_x + 4.0, rect.center().y);
+                                let c = egui::pos2(arrow_x + 4.0, cell.center().y);
                                 let (tip_y, base_y) = if down { (c.y + 3.0, c.y - 2.0) } else { (c.y - 3.0, c.y + 2.0) };
                                 let pts = vec![egui::pos2(c.x - 3.5, base_y), egui::pos2(c.x + 3.5, base_y), egui::pos2(c.x, tip_y)];
                                 let arrow_color = if active { t.accent_ink } else { t.faint };
diff --git a/crates/ui-egui/tests/table_headers.rs b/crates/ui-egui/tests/table_headers.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/table_headers.rs
@@ -0,0 +1,151 @@
+//! Every table column header must be drawn in full, in every language: a localized label longer
+//! than the column ("Zainstalowana" - PL) widens its column instead of being cut off at the edge.
+//! One test: the UI language is process-wide. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves
+//! screenshots (run with `-- --test-threads=1`).
+
+use std::cell::Cell;
+use std::rc::Rc;
+
+use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
+use artcraft_launcher_core::release::{Asset, Release};
+use artcraft_launcher_core::status::InstallSource;
+use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
+use artcraft_launcher_ui_egui::i18n::{Lang, tr, tr_ctx};
+use artcraft_launcher_ui_egui::settings::ViewMode;
+use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings, theme};
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+
+const NOW: i64 = 1_791_500_000;
+
+struct Fake {
+    events: Vec<Event>,
+    clock: Rc<Cell<i64>>,
+}
+
+impl Backend for Fake {
+    fn send(&mut self, _command: Command) {}
+    fn poll(&mut self) -> Vec<Event> {
+        std::mem::take(&mut self.events)
+    }
+    fn capabilities(&self) -> Capabilities {
+        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
+    }
+    fn platform(&self) -> Option<Platform> {
+        Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
+    }
+    fn now(&self) -> i64 {
+        self.clock.get()
+    }
+}
+
+struct NoHost;
+impl Host for NoHost {}
+
+fn release(id: &str, v: &str, at: i64) -> Release {
+    let name = format!("{id}-{v}-macos-universal.dmg");
+    Release {
+        tag: format!("v{v}"),
+        version: version::parse(v),
+        name: format!("v{v}"),
+        published_at: Some(at),
+        html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
+        notes: String::new(),
+        prerelease: false,
+        assets: vec![Asset {
+            name: name.clone(),
+            size: 150 << 20,
+            url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
+            sha256: None,
+        }],
+    }
+}
+
+fn events() -> Vec<Event> {
+    let mut ev = Vec::new();
+    for (i, app) in SUITE.iter().enumerate() {
+        let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
+        ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
+        // One app with an update, one up to date: their rows carry the full set of badges and
+        // buttons, so the columns are measured against real content.
+        let installed = match app.id {
+            "photocraft" | "lightcraft" => {
+                let v = if app.id == "photocraft" { "0.4.1" } else { "0.5.0" };
+                Some(Installed {
+                    version: version::parse(v),
+                    source: InstallSource::MacApp,
+                    location: format!("/Applications/{}.app", app.name),
+                    package: None,
+                })
+            }
+            _ => None,
+        };
+        ev.push(Event::Detected { app: app.id.into(), installed });
+    }
+    ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
+    ev
+}
+
+fn harness(language: &str) -> Harness<'static, Option<LauncherUi>> {
+    let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
+    let settings = Settings { view: ViewMode::Table, notify: false, language: language.into(), ..Settings::default() };
+    Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
+        move |ui, state: &mut Option<LauncherUi>| {
+            if state.is_none()
+                && let Some(b) = backend.take()
+            {
+                *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
+            }
+            if let Some(l) = state.as_mut() {
+                l.logic(&ui.ctx().clone(), &mut NoHost);
+                l.ui(ui, &mut NoHost);
+            }
+        },
+        None,
+    )
+}
+
+fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
+    let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
+    let img = h.render().unwrap();
+    img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
+}
+
+/// What a table header needs in points: its label in the HUD face, upper case, plus the sort
+/// arrow. This mirrors what `views::table` gives the column as its floor and what the header
+/// cell claims as its width; the two must always agree.
+fn header_needs(ctx: &egui::Context, label: &str) -> f32 {
+    let format = egui::TextFormat {
+        font_id: egui::FontId::new(theme::HUD_SIZE, theme::mono()),
+        color: egui::Color32::PLACEHOLDER,
+        extra_letter_spacing: theme::HUD_SPACING,
+        ..Default::default()
+    };
+    let job = egui::text::LayoutJob::single_section(label.to_uppercase(), format);
+    ctx.fonts_mut(|f| f.layout_job(job)).size().x + theme::HEADER_ARROW_W
+}
+
+#[test]
+fn table_headers_fit_in_every_language() {
+    artcraft_launcher_ui_egui::i18n::set_test_lang(Lang::EN);
+    for lang in Lang::all() {
+        let mut h = harness(lang.code());
+        h.run_steps(8);
+        snapshot(&mut h, &format!("table-headers-{}", lang.code()));
+        // A CJK language is drawn in English where the system has no CJK font (`effective_lang`):
+        // the headers then read English too.
+        let shown = if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { Lang::EN } else { lang };
+        for label in [
+            tr(shown, "App"),
+            tr(shown, "Status"),
+            tr_ctx(shown, "column", "Installed"),
+            tr_ctx(shown, "column", "Latest"),
+            tr_ctx(shown, "column", "Released"),
+        ] {
+            let needs = header_needs(&h.ctx, label);
+            let node = h.get_by_label(label);
+            let has = node.rect().width();
+            assert!(has + 1.0 >= needs, "{}: \"{label}\" needs {needs:.1} points but its column is {has:.1}", lang.code());
+        }
+    }
+}
