diff --git a/apps/artcraft-launcher/src/settings_file.rs b/apps/artcraft-launcher/src/settings_file.rs
--- a/apps/artcraft-launcher/src/settings_file.rs
+++ b/apps/artcraft-launcher/src/settings_file.rs
@@ -110,7 +110,12 @@ mod tests {
     #[test]
     fn settings_saved_with_no_file_read_back() {
         let dir = Scratch::new("settings-round-trip");
-        let settings = Settings { last_check: Some(1_791_500_000), notify: false, ..Settings::default() };
+        let settings = Settings {
+            last_check: Some(1_791_500_000),
+            notify: false,
+            pinned: ["photocraft".to_string(), "gridcraft".to_string()].into(),
+            ..Settings::default()
+        };
         assert_eq!(save(&dir.file(), "settings", &settings), Ok(()));
         assert_eq!(load::<Settings>(&dir.file(), "settings"), Some(settings));
     }
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -93,6 +93,7 @@ pub(crate) enum UiAction {
     Launch(String),
     /// Re-run the manual update check, clearing the auto-update back-off.
     CheckNow,
+    TogglePin(String),
     /// A message a UI interaction asked to show.
     Toast {
         text: String,
@@ -372,6 +373,11 @@ impl LauncherUi {
                     *opened = opened.saturating_add(1);
                     self.backend.send(Command::Launch { app: id.clone() });
                 }
+                UiAction::TogglePin(id) => {
+                    if !self.settings.pinned.remove(id) {
+                        self.settings.pinned.insert(id.clone());
+                    }
+                }
                 UiAction::CheckNow => {
                     self.auto_update_attempted.clear();
                     self.backend.send(Command::CheckForUpdates { force: true });
@@ -384,6 +390,10 @@ impl LauncherUi {
         }
     }
 
+    pub(crate) fn toggle_pin(&mut self, id: &str) {
+        self.pending.push(UiAction::TogglePin(id.to_string()));
+    }
+
     /// Queues a message for the next pass that can actually draw. Its age is deliberately not
     /// part of the queue: a job that finished while the window was hidden is still news when the
     /// window first opens, so the countdown starts on display (see [`Self::draw_toasts`]).
@@ -797,6 +807,7 @@ impl LauncherUi {
                     ViewMode::Table => egui::ScrollArea::both(),
                 };
                 scroll.auto_shrink(false).show(ui, |ui| {
+                    self.quick_launch(ui);
                     if ids.is_empty() {
                         ui.add_space(40.0);
                         ui.vertical_centered(|ui| {
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -349,3 +349,7 @@ filter	Installed	Nainstalováno
 	Show {n} more changes	Zobrazit dalších změn: {n}
 	Last updated: {date}	Naposledy aktualizováno: {date}
 column	Version	Verze
+
+	Quick Launch	Rychlé spuštění
+	Add to Quick Launch	Přidat do rychlého spuštění
+	Remove from Quick Launch	Odebrat z rychlého spuštění
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -381,3 +381,7 @@ filter	Installed	Installiert
 	Show {n} more changes	{n} weitere Änderungen anzeigen
 	Last updated: {date}	Zuletzt aktualisiert: {date}
 column	Version	Version
+
+	Quick Launch	Schnellstart
+	Add to Quick Launch	Zum Schnellstart hinzufügen
+	Remove from Quick Launch	Aus Schnellstart entfernen
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -362,3 +362,7 @@ filter	Installed	Instaladas
 	Show {n} more changes	Mostrar {n} cambios más
 	Last updated: {date}	Última actualización: {date}
 column	Version	Versión
+
+	Quick Launch	Inicio rápido
+	Add to Quick Launch	Añadir a Inicio rápido
+	Remove from Quick Launch	Quitar de Inicio rápido
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -379,3 +379,7 @@ filter	Installed	Installées
 	Show {n} more changes	Afficher {n} modifications de plus
 	Last updated: {date}	Dernière mise à jour : {date}
 column	Version	Version
+
+	Quick Launch	Lancement rapide
+	Add to Quick Launch	Ajouter au lancement rapide
+	Remove from Quick Launch	Retirer du lancement rapide
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -382,3 +382,7 @@ filter	Installed	Telepítve
 	Show {n} more changes	{n} további változás megjelenítése
 	Last updated: {date}	Utoljára frissítve: {date}
 column	Version	Verzió
+
+	Quick Launch	Gyorsindítás
+	Add to Quick Launch	Hozzáadás a gyorsindításhoz
+	Remove from Quick Launch	Eltávolítás a gyorsindításból
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -384,3 +384,7 @@ filter	Installed	インストール済み
 	Show {n} more changes	さらに{n}件の変更を表示
 	Last updated: {date}	最終更新: {date}
 column	Version	バージョン
+
+	Quick Launch	クイック起動
+	Add to Quick Launch	クイック起動に追加
+	Remove from Quick Launch	クイック起動から削除
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -383,3 +383,7 @@ filter	Installed	Zainstalowane
 	Show {n} more changes	Pokaż {n} więcej zmian
 	Last updated: {date}	Ostatnia aktualizacja: {date}
 column	Version	Wersja
+
+	Quick Launch	Szybkie uruchamianie
+	Add to Quick Launch	Dodaj do szybkiego uruchamiania
+	Remove from Quick Launch	Usuń z szybkiego uruchamiania
diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv
--- a/crates/ui-egui/src/i18n/pt-BR.tsv
+++ b/crates/ui-egui/src/i18n/pt-BR.tsv
@@ -369,3 +369,7 @@ filter	Installed	Instalados
 	Show {n} more changes	Mostrar mais {n} alterações
 	Last updated: {date}	Última atualização: {date}
 column	Version	Versão
+
+	Quick Launch	Inicialização rápida
+	Add to Quick Launch	Adicionar à Inicialização rápida
+	Remove from Quick Launch	Remover da Inicialização rápida
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -377,3 +377,7 @@ filter	Installed	Установлены
 	Show {n} more changes	Показать ещё изменений: {n}
 	Last updated: {date}	Последнее обновление: {date}
 column	Version	Версия
+
+	Quick Launch	Быстрый запуск
+	Add to Quick Launch	Добавить в быстрый запуск
+	Remove from Quick Launch	Убрать из быстрого запуска
diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv
--- a/crates/ui-egui/src/i18n/zh-CN.tsv
+++ b/crates/ui-egui/src/i18n/zh-CN.tsv
@@ -349,3 +349,7 @@ figcraft	Interface design and prototyping	界面设计与原型制作
 	Show {n} more changes	显示另外 {n} 项更改
 	Last updated: {date}	最后更新：{date}
 column	Version	版本
+
+	Quick Launch	快速启动
+	Add to Quick Launch	添加到快速启动
+	Remove from Quick Launch	从快速启动移除
diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs
--- a/crates/ui-egui/src/settings.rs
+++ b/crates/ui-egui/src/settings.rs
@@ -122,6 +122,12 @@ pub struct Settings {
     pub tray_hint_shown: bool,
     /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used".
     pub launches: BTreeMap<String, u32>,
+    /// Catalog ids pinned to Quick Launch.
+    pub pinned: std::collections::BTreeSet<String>,
+    /// Whether the installed apps section is expanded in the grid.
+    pub installed_section_open: bool,
+    /// Whether the not installed apps section is expanded in the grid.
+    pub not_installed_section_open: bool,
 }
 
 impl Settings {
@@ -204,6 +210,9 @@ impl Default for Settings {
             hidden: std::collections::BTreeSet::new(),
             tray_hint_shown: false,
             launches: BTreeMap::new(),
+            pinned: std::collections::BTreeSet::new(),
+            installed_section_open: true,
+            not_installed_section_open: true,
         }
     }
 }
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
@@ -63,6 +63,62 @@ const CARD_PAD: f32 = 20.0;
 const TRAFFIC_LIGHTS_W: f32 = 72.0;
 
 impl LauncherUi {
+    fn pin_button(&mut self, ui: &mut Ui, id: &str) {
+        self.pin_button_with_offset(ui, id, egui::Vec2::ZERO);
+    }
+
+    fn pin_button_with_offset(&mut self, ui: &mut Ui, id: &str, offset: egui::Vec2) {
+        let pinned = self.settings.pinned.contains(id);
+        let label = if pinned { tl!("Remove from Quick Launch") } else { tl!("Add to Quick Launch") };
+        if widgets::glyph_button_with_offset(ui, widgets::Glyph::Star(pinned), label, offset).clicked() {
+            self.toggle_pin(id);
+        }
+    }
+
+    pub(crate) fn quick_launch(&mut self, ui: &mut Ui) {
+        let entries: Vec<_> =
+            self.entries.iter().filter(|e| self.settings.pinned.contains(e.app.id) && !self.settings.hidden.contains(e.app.id)).cloned().collect();
+        if entries.is_empty() {
+            return;
+        }
+        let t = Tokens::get(ui.ctx());
+        widgets::hud(ui, tl!("Quick Launch"), t.muted);
+        ui.add_space(8.0);
+        ui.push_id("quick-launch", |ui| {
+            ui.horizontal_wrapped(|ui| {
+                for entry in entries {
+                    ui.push_id(entry.app.id, |ui| {
+                        egui::Frame::new().stroke(t.hairline()).inner_margin(8).show(ui, |ui| {
+                            ui.horizontal(|ui| {
+                                self.pin_button(ui, entry.app.id);
+                                let status = entry.status(self.platform, self.caps.manage_apps);
+                                let installed = status.is_installed();
+                                if let Some(icon) = crate::icons::app_image(ui.ctx(), entry.app.id, installed) {
+                                    ui.add(icon.fit_to_exact_size(vec2(24.0, 24.0)));
+                                }
+                                widgets::wordmark_in(ui, entry.app, 16.0, installed);
+                                if let Some(phase) = entry.job {
+                                    let label = match phase {
+                                        JobPhase::Downloading { .. } => tl!("Downloading…"),
+                                        JobPhase::Reconnecting { .. } => tl!("Reconnecting…"),
+                                        JobPhase::Verifying => tl!("Verifying checksum…"),
+                                        JobPhase::Waiting => tl!("Waiting for another app…"),
+                                        JobPhase::Installing => tl!("Installing…"),
+                                        JobPhase::Removing => tl!("Removing…"),
+                                    };
+                                    ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, 100.0));
+                                } else {
+                                    self.primary_button(ui, &entry, entry.app.id, &status, 100.0);
+                                }
+                            });
+                        });
+                    });
+                }
+            });
+        });
+        ui.add_space(18.0);
+    }
+
     /// The header bar; with a custom or macOS title bar it is also the window's title bar.
     pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {
         let t = Tokens::get(ui.ctx());
@@ -396,18 +452,33 @@ impl LauncherUi {
 
     // ---- grid ----------------------------------------------------------------------------
 
-    /// Installed apps first (`ids` arrives that way), then the rest under a "Not installed" header.
+    /// Installed apps first (`ids` arrives that way), then the rest. Each group can be collapsed.
     pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) {
         let installed = ids.iter().take_while(|id| self.is_installed(id)).count();
         let (top, rest) = ids.split_at(installed);
-        self.grid_block(ui, top);
+
+        if !top.is_empty() {
+            if widgets::disclosure_header(ui, tl!("Installed"), self.settings.installed_section_open).clicked() {
+                self.settings.installed_section_open = !self.settings.installed_section_open;
+            }
+            if self.settings.installed_section_open {
+                ui.add_space(8.0);
+                self.grid_block(ui, top);
+            }
+        }
+
         if !top.is_empty() && !rest.is_empty() {
-            let t = Tokens::get(ui.ctx());
             ui.add_space(28.0);
-            widgets::hud(ui, tl!("Not installed"), t.faint);
-            ui.add_space(12.0);
         }
-        self.grid_block(ui, rest);
+        if !rest.is_empty() {
+            if widgets::disclosure_header(ui, tl!("Not installed"), self.settings.not_installed_section_open).clicked() {
+                self.settings.not_installed_section_open = !self.settings.not_installed_section_open;
+            }
+            if self.settings.not_installed_section_open {
+                ui.add_space(8.0);
+                self.grid_block(ui, rest);
+            }
+        }
         ui.add_space(24.0);
     }
 
@@ -477,11 +548,12 @@ impl LauncherUi {
             );
             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);
+                let room = (strip_ui.available_width() - github_w - theme::CONTROL_H - 32.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;
+                self.pin_button_with_offset(ui, id, vec2(4.0, 0.0));
                 widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());
             });
         }
@@ -574,7 +646,7 @@ impl LauncherUi {
                 .column(Column::exact(176.0))
                 .column(Column::exact(150.0))
                 .column(Column::exact(104.0))
-                .column(Column::exact(206.0))
+                .column(Column::exact(220.0))
                 .header(42.0, |header| self.table_header(header, &mut sort_clicked, &t, span, clip))
                 .body(|body| self.table_rows(body, ids, &t, (title_h, tagline_h), now, (span, clip)));
         });
@@ -689,7 +761,10 @@ impl LauncherUi {
                 row.col(|ui| self.table_status_cell(ui, &entry, &status, t));
                 row.col(|ui| self.table_version_cell(ui, &entry, &status, t));
                 row.col(|ui| self.table_released_cell(ui, &entry, now, t));
-                row.col(|ui| self.actions(ui, &entry, &status, 204.0));
+                row.col(|ui| {
+                    self.pin_button(ui, id);
+                    self.actions(ui, &entry, &status, 168.0);
+                });
             });
         }
     }
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -51,6 +51,32 @@ pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
     resp
 }
 
+/// A full-width disclosure header styled like the launcher's mono section labels.
+pub fn disclosure_header(ui: &mut Ui, text: &str, open: bool) -> Response {
+    let t = Tokens::get(ui.ctx());
+    let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
+    let hovered = resp.hovered();
+    if hovered {
+        ui.painter().rect_filled(rect, CornerRadius::ZERO, t.line);
+    }
+
+    let center = egui::pos2(rect.left() + 11.0, rect.center().y);
+    let arrow = if open {
+        vec![center + vec2(-3.0, -1.5), center + vec2(3.0, -1.5), center + vec2(0.0, 2.5)]
+    } else {
+        vec![center + vec2(-1.5, -3.0), center + vec2(-1.5, 3.0), center + vec2(2.5, 0.0)]
+    };
+    let color = if hovered { t.ink_strong } else { t.faint };
+    ui.painter().add(egui::Shape::convex_polygon(arrow, color, Stroke::NONE));
+
+    let label_color = if hovered { t.ink_strong } else { t.faint };
+    let galley = hud_galley(ui, text, label_color);
+    let label_rect = Rect::from_min_size(egui::pos2(rect.left() + 26.0, rect.top()), vec2(galley.size().x, rect.height()));
+    paint_left(ui, label_rect, galley, label_color);
+    resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, text));
+    resp.on_hover_cursor(egui::CursorIcon::PointingHand)
+}
+
 /// A mono label cut to one line with "…" where it is wider than `max_width`; the full text shows
 /// on hover and is what screen readers read.
 pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Response {
@@ -503,6 +529,7 @@ pub enum Glyph {
     Close,
     Settings,
     Refresh,
+    Star(bool),
 }
 
 /// Paints one of the launcher's symbols with the painter, centred in `rect`.
@@ -522,6 +549,21 @@ pub fn paint_glyph(p: &egui::Painter, rect: Rect, glyph: Glyph, color: Color32)
 fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec<egui::Shape> {
     let m = rect.center();
     match glyph {
+        Glyph::Star(selected) => {
+            let points: Vec<_> = (0..10)
+                .map(|i| {
+                    let angle = std::f32::consts::TAU * i as f32 / 10.0 - std::f32::consts::FRAC_PI_2;
+                    let radius = if i % 2 == 0 { 7.0 } else { 3.2 };
+                    m + vec2(angle.cos(), angle.sin()) * radius
+                })
+                .collect();
+            vec![egui::Shape::Path(egui::epaint::PathShape {
+                points,
+                closed: true,
+                fill: if selected { color } else { Color32::TRANSPARENT },
+                stroke: Stroke::new(1.2, color).into(),
+            })]
+        }
         Glyph::Pause => [-3.0, 3.0]
             .into_iter()
             .map(|dx| egui::Shape::rect_filled(Rect::from_center_size(m + vec2(dx, 0.0), vec2(2.5, 10.0)), CornerRadius::ZERO, color))
@@ -562,19 +604,38 @@ fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec<egui::Shape> {
     }
 }
 
-/// A small outlined square button with a drawn glyph; inverts on hover.
+/// A square button with a drawn glyph; stars are borderless and other glyphs invert on hover.
 pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response {
+    glyph_button_with_offset(ui, glyph, tip, egui::Vec2::ZERO)
+}
+
+/// A glyph button whose painted control is nudged without moving its click target.
+pub fn glyph_button_with_offset(ui: &mut Ui, glyph: Glyph, tip: &str, offset: egui::Vec2) -> Response {
     let t = Tokens::get(ui.ctx());
     let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::click());
+    let paint_rect = rect.translate(offset);
     let hovered = resp.hovered();
     let p = ui.painter();
-    if hovered {
-        p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg);
+    let is_star = matches!(glyph, Glyph::Star(_));
+    if is_star {
+        // Stars sit beside text links on app cards, so keep their full click target but omit the
+        // square control outline. A quiet hover wash still makes the affordance discoverable.
+        if hovered {
+            p.rect_filled(paint_rect, CornerRadius::ZERO, t.line);
+        }
+    } else if hovered {
+        p.rect_filled(paint_rect, CornerRadius::ZERO, t.invert_bg);
     } else {
-        p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
+        p.rect_stroke(paint_rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
     }
-    let c = if hovered { t.invert_fg } else { t.muted };
-    paint_glyph(p, rect, glyph, c);
+    let c = match glyph {
+        Glyph::Star(true) => t.accent_ink,
+        Glyph::Star(false) if hovered => t.ink_strong,
+        Glyph::Star(false) => t.faint,
+        _ if hovered => t.invert_fg,
+        _ => t.muted,
+    };
+    paint_glyph(p, paint_rect, glyph, c);
     resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
     resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
 }
