diff --git a/crates/ui-egui/src/details.rs b/crates/ui-egui/src/details.rs
--- a/crates/ui-egui/src/details.rs
+++ b/crates/ui-egui/src/details.rs
@@ -309,10 +309,7 @@ impl LauncherUi {
         let status = entry.status(self.platform, self.caps.manage_apps);
         let hero = egui::Frame::new().fill(tint(app_color, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
             ui.set_width(ui.available_width());
-            ui.horizontal(|ui| {
-                widgets::hud(ui, tl!("App details"), t.faint);
-                self.hero_top_row(ui, around, t);
-            });
+            egui::Sides::new().show(ui, |ui| widgets::hud(ui, tl!("App details"), t.faint), |ui| self.hero_top_row(ui, around, t));
             self.hero_identity(ui, entry, details, &status, t, wide);
             ui.add_space(14.0);
             // The menu leaves out the links: they are the buttons right below.
@@ -329,22 +326,21 @@ impl LauncherUi {
 
     /// The close button and the previous / next pair, right-aligned in the hero's top row.
     fn hero_top_row(&mut self, ui: &mut Ui, around: (Option<&str>, Option<&str>), t: &Tokens) {
-        // Right to left: next, then previous, so they read as a pair before the close.
-        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-            if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
-                self.detail = None;
-            }
-            if let (_, Some(next)) = around
-                && step_button(ui, false, tl!("Next app"), t)
-            {
-                self.detail = Some(next.to_string());
-            }
-            if let (Some(previous), _) = around
-                && step_button(ui, true, tl!("Previous app"), t)
-            {
-                self.detail = Some(previous.to_string());
-            }
-        });
+        // Drawn into the right side of `egui::Sides`, which lays out right to left: next, then
+        // previous, so they read as a pair before the close.
+        if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
+            self.detail = None;
+        }
+        if let (_, Some(next)) = around
+            && step_button(ui, false, tl!("Next app"), t)
+        {
+            self.detail = Some(next.to_string());
+        }
+        if let (Some(previous), _) = around
+            && step_button(ui, true, tl!("Previous app"), t)
+        {
+            self.detail = Some(previous.to_string());
+        }
     }
 
     /// The icon, name, headline and version line, with the main action button beside them when
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
@@ -129,12 +129,11 @@ impl LauncherUi {
         let mut close = false;
         egui::Frame::new().inner_margin(egui::Margin::symmetric(theme::PAD as i8, 16)).show(ui, |ui| {
             ui.set_width(ui.available_width());
-            ui.horizontal(|ui| {
-                widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url);
-                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                    close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked();
-                });
-            });
+            egui::Sides::new().show(
+                ui,
+                |ui| widgets::hud_link(ui, tl!("Open on GitHub"), &release.html_url),
+                |ui| close = widgets::button(ui, tl!("Close"), Kind::Secondary, 90.0).clicked(),
+            );
         });
         close
     }
@@ -155,9 +154,10 @@ impl LauncherUi {
         ui.label(RichText::new(message).color(t.muted));
         ui.add_space(14.0);
         let mut close = false;
-        ui.horizontal(|ui| {
-            widgets::hud_link(ui, tl!("What's new"), &r.html_url);
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+        egui::Sides::new().show(
+            ui,
+            |ui| widgets::hud_link(ui, tl!("What's new"), &r.html_url),
+            |ui| {
                 if self.caps.self_update {
                     if widgets::button(ui, tl!("Update and restart"), Kind::Primary, 0.0)
                         .on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
@@ -179,8 +179,8 @@ impl LauncherUi {
                 if widgets::button(ui, tl!("Later"), Kind::Secondary, 80.0).clicked() {
                     close = true;
                 }
-            });
-        });
+            },
+        );
         close
     }
 
diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs
--- a/crates/ui-egui/src/settings_dialog.rs
+++ b/crates/ui-egui/src/settings_dialog.rs
@@ -45,6 +45,8 @@ fn note(ui: &mut Ui, text: &str) {
 }
 
 impl LauncherUi {
+    /// The Settings sheet: the groups in a scrolling body, Quit and Done in the footer; true when
+    /// it should close.
     pub(crate) fn settings_dialog(&mut self, ui: &mut Ui, host: &mut dyn Host) -> bool {
         let t = Tokens::get(ui.ctx());
         let size = sheet::size(ui.ctx(), vec2(1000.0, 820.0));
@@ -90,12 +92,7 @@ impl LauncherUi {
                     close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
                 });
             } else {
-                ui.horizontal(|ui| {
-                    quit(ui, host);
-                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                        close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
-                    });
-                });
+                egui::Sides::new().show(ui, |ui| quit(ui, host), |ui| close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked());
             }
         });
         close
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
@@ -70,34 +70,41 @@ impl LauncherUi {
         if bar != TitleBar::Native {
             widgets::title_bar_drag(ui);
         }
-        ui.horizontal(|ui| {
-            ui.spacing_mut().item_spacing.x = 0.0;
-            if bar == TitleBar::MacOs {
-                ui.add_space(TRAFFIC_LIGHTS_W);
-            }
-            // The ArtCraft wordmark exactly as supplied, in its own cell.
-            let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
-            let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
-            egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text(tl!("ArtCraft")).paint_at(ui, Rect::from_center_size(cell.center(), logo));
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+        // The controls on the right are laid out first and keep their widths; the left side gets
+        // what is left, and drops the secondary text when it doesn't fit there.
+        egui::Sides::new().shrink_left().height(h).spacing(0.0).show(
+            ui,
+            |ui| {
+                ui.spacing_mut().item_spacing.x = 0.0;
+                if bar == TitleBar::MacOs {
+                    ui.add_space(TRAFFIC_LIGHTS_W);
+                }
+                // The ArtCraft wordmark exactly as supplied, in its own cell.
+                let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
+                let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
+                egui::Image::new(crate::icons::artcraft_logo(t.dark))
+                    .alt_text(tl!("ArtCraft"))
+                    .paint_at(ui, Rect::from_center_size(cell.center(), logo));
+                if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
+                    widgets::rule(ui, h);
+                    ui.add_space(16.0);
+                    widgets::hud(ui, tl!("Craft App Suite"), t.muted);
+                }
+            },
+            |ui| {
+                ui.spacing_mut().item_spacing.x = 0.0;
                 if bar == TitleBar::Custom {
                     widgets::window_buttons(ui, h);
                 }
                 if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {
                     self.dialog = Some(Dialog::Settings);
                 }
-                // The controls reserve their actual widths before the secondary text gets space.
-                ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
-                    if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
-                        widgets::rule(ui, h);
-                        ui.add_space(16.0);
-                        widgets::hud(ui, tl!("Craft App Suite"), t.muted);
-                    }
-                });
-            });
-        });
+            },
+        );
     }
 
+    /// The launcher-update banner and announcements, then the row of refresh, the hidden apps,
+    /// "Update all", sort, search and the view switch (sort and "Update all" on a second row when narrow).
     pub(crate) fn toolbar(&mut self, ui: &mut Ui) {
         if self.settings.filter == Filter::Hidden && self.settings.hidden.is_empty() {
             self.settings.filter = Filter::All;
@@ -125,65 +132,81 @@ impl LauncherUi {
             .stroke(Stroke::new(1.0, tint(t.accent, 0.6)))
             .inner_margin(egui::Margin::symmetric(16, 10))
             .show(ui, |ui| {
-                ui.horizontal(|ui| {
-                    widgets::tag(ui, tl!("Update"), t.accent, Color32::WHITE);
-                    ui.add_space(4.0);
-                    ui.label(
-                        RichText::new(fmt(tl!("ArtCraft Launcher {version} is available."), &[("version", &r.version_label())]))
+                // A narrow banner moves "What's new" under the message, leaving it the room
+                // the link took beside the buttons.
+                let link_left = ui.available_width() < theme::TOOLBAR_COMPACT_BELOW;
+                let busy = self.launcher_job.is_some();
+                // The buttons keep their widths; the message wraps in what they leave.
+                egui::Sides::new().shrink_left().wrap().show(
+                    ui,
+                    |ui| {
+                        widgets::tag(ui, tl!("Update"), t.accent, Color32::WHITE);
+                        ui.add_space(4.0);
+                        let message = RichText::new(fmt(tl!("ArtCraft Launcher {version} is available."), &[("version", &r.version_label())]))
                             .family(theme::medium())
-                            .color(t.ink_strong),
-                    );
-                    self.banner_actions(ui, t, r);
-                });
+                            .color(t.ink_strong);
+                        if link_left && !busy {
+                            ui.vertical(|ui| {
+                                ui.spacing_mut().item_spacing.y = 4.0;
+                                ui.label(message);
+                                widgets::hud_link(ui, tl!("What's new"), &r.html_url);
+                            });
+                        } else {
+                            ui.label(message);
+                        }
+                    },
+                    |ui| self.banner_actions(ui, t, r, link_left),
+                );
             });
     }
 
     /// The banner's right side: the download's progress and cancel while it runs, otherwise the
-    /// update (or download) buttons plus the release link.
-    fn banner_actions(&mut self, ui: &mut Ui, t: &Tokens, r: &artcraft_launcher_core::Release) {
-        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-            if let Some(phase) = self.launcher_job {
-                let (fraction, caption) = match phase {
-                    JobPhase::Downloading { done, total } if total > 0 => (
-                        Some(done as f32 / total as f32),
-                        fmt(tl!("Downloading {done} of {total}"), &[("done", &widgets::human_bytes(done)), ("total", &widgets::human_bytes(total))]),
-                    ),
-                    JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, tl!("Downloading…").to_string()),
-                    JobPhase::Verifying => (None, tl!("Verifying…").to_string()),
-                    JobPhase::Waiting => (None, tl!("Waiting for other installs…").to_string()),
-                    JobPhase::Installing | JobPhase::Removing => (None, tl!("Installing, then restarting…").to_string()),
-                };
-                // Another app's install can hang on a password prompt: the wait can be called off.
-                if phase == JobPhase::Waiting
-                    && widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Cancel update"))
-                        .on_hover_text(tl!("Stop waiting; the update can be started again later"))
-                        .clicked()
-                {
-                    self.act_command(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
-                }
-                widgets::progress(ui, fraction, &caption, t.accent, 220.0);
-                return;
+    /// update (or download) buttons plus the release link, unless `link_left` put it under the
+    /// message.
+    fn banner_actions(&mut self, ui: &mut Ui, t: &Tokens, r: &artcraft_launcher_core::Release, link_left: bool) {
+        if let Some(phase) = self.launcher_job {
+            let (fraction, caption) = match phase {
+                JobPhase::Downloading { done, total } if total > 0 => (
+                    Some(done as f32 / total as f32),
+                    fmt(tl!("Downloading {done} of {total}"), &[("done", &widgets::human_bytes(done)), ("total", &widgets::human_bytes(total))]),
+                ),
+                JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, tl!("Downloading…").to_string()),
+                JobPhase::Verifying => (None, tl!("Verifying…").to_string()),
+                JobPhase::Waiting => (None, tl!("Waiting for other installs…").to_string()),
+                JobPhase::Installing | JobPhase::Removing => (None, tl!("Installing, then restarting…").to_string()),
+            };
+            // Another app's install can hang on a password prompt: the wait can be called off.
+            if phase == JobPhase::Waiting
+                && widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Cancel update"))
+                    .on_hover_text(tl!("Stop waiting; the update can be started again later"))
+                    .clicked()
+            {
+                self.act_command(Command::Cancel { app: artcraft_launcher_core::catalog::LAUNCHER_ID.to_string() });
             }
-            if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Dismiss")).clicked() {
-                self.launcher_update = None;
+            widgets::progress(ui, fraction, &caption, t.accent, 220.0);
+            return;
+        }
+        if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Dismiss")).clicked() {
+            self.launcher_update = None;
+        }
+        if self.caps.self_update {
+            if widgets::button(ui, tl!("Update and restart"), Kind::Primary, 0.0)
+                .on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
+                .clicked()
+            {
+                self.act_command(Command::UpdateLauncher);
             }
-            if self.caps.self_update {
-                if widgets::button(ui, tl!("Update and restart"), Kind::Primary, 0.0)
-                    .on_hover_text(tl!("Downloads the new version, replaces this one and opens it again"))
-                    .clicked()
-                {
-                    self.act_command(Command::UpdateLauncher);
-                }
-            } else {
-                let url =
-                    self.platform.and_then(|p| launcher_download(r, p, crate::settings::is_flatpak_sandbox())).unwrap_or_else(|| r.html_url.clone());
-                if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
-                    widgets::open_external_link(ui.ctx(), &url);
-                }
+        } else {
+            let url =
+                self.platform.and_then(|p| launcher_download(r, p, crate::settings::is_flatpak_sandbox())).unwrap_or_else(|| r.html_url.clone());
+            if widgets::button(ui, tl!("Download"), Kind::Primary, 0.0).clicked() {
+                widgets::open_external_link(ui.ctx(), &url);
             }
+        }
+        if !link_left {
             ui.add_space(8.0);
             widgets::hud_link(ui, tl!("What's new"), &r.html_url);
-        });
+        }
     }
 
     /// The toolbar's main row: the refresh button, the hidden-apps toggle, the view switch, the
@@ -321,6 +344,8 @@ impl LauncherUi {
         }
     }
 
+    /// The status bar: when apps were last checked (or GitHub's rate limit) at the left, the
+    /// website, GitHub, Discord and social links at the right.
     pub(crate) fn footer(&mut self, ui: &mut Ui) {
         let t = Tokens::get(ui.ctx());
         let now = self.backend.now();
@@ -366,10 +391,9 @@ impl LauncherUi {
         let spacing = ui.spacing().item_spacing.x;
         // The icons on the right keep their size; the status on the left gets what is left and
         // is cut short with "…", never drawn under them.
-        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-            widgets::icon_links(ui, icon, &links);
-            ui.add_space(8.0);
-            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
+        egui::Sides::new().shrink_left().height(ui.available_height()).spacing(spacing + 8.0).show(
+            ui,
+            |ui| {
                 // While GitHub is rate limited, saying so matters more than when we last checked.
                 let avail = ui.available_width();
                 let text = |s: &str| RichText::new(s).size(12.0).color(t.warn);
@@ -390,8 +414,9 @@ impl LauncherUi {
                         resp.on_hover_text(full);
                     }
                 }
-            });
-        });
+            },
+            |ui| widgets::icon_links(ui, icon, &links),
+        );
     }
 
     // ---- grid ----------------------------------------------------------------------------
@@ -475,15 +500,18 @@ impl LauncherUi {
             let mut strip_ui = ui.new_child(
                 egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),
             );
-            if !version.is_empty() {
-                let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x;
-                let room = (strip_ui.available_width() - github_w - 16.0).max(0.0);
-                widgets::hud_elided(&mut strip_ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room);
-            }
-            strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                ui.spacing_mut().item_spacing.x = 16.0;
-                widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());
-            });
+            // The link is laid out first; the version gets the room left and is elided to it.
+            let height = strip_ui.available_height();
+            egui::Sides::new().shrink_left().height(height).spacing(16.0).show(
+                &mut strip_ui,
+                |ui| {
+                    if !version.is_empty() {
+                        let room = ui.available_width();
+                        widgets::hud_elided(ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room);
+                    }
+                },
+                |ui| widgets::hud_link(ui, tl!("GitHub"), &app.repo_url()),
+            );
         }
 
         let inner = Rect::from_min_max(egui::pos2(rect.left() + CARD_PAD, strip.bottom() + 18.0), rect.max - vec2(CARD_PAD, CARD_PAD));
