diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -330,6 +330,7 @@ name = "artcraft-launcher-ui-egui"
 version = "0.4.1"
 dependencies = [
  "artcraft-launcher-core",
+ "craft-ui",
  "egui",
  "egui_extras",
  "egui_kittest",
@@ -935,6 +936,14 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=8134693a69e0fca8ec7913ad5a95dedc05daad74#8134693a69e0fca8ec7913ad5a95dedc05daad74"
+dependencies = [
+ "egui",
+]
+
 [[package]]
 name = "crc32fast"
 version = "1.5.2"
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -11,6 +11,7 @@ publish = false
 workspace = true
 
 [dependencies]
+craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8134693a69e0fca8ec7913ad5a95dedc05daad74" }
 artcraft-launcher-core = { workspace = true }
 egui = { workspace = true }
 egui_extras = { workspace = true, features = ["image", "svg"] }
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
@@ -522,12 +522,12 @@ impl LauncherUi {
 
         // The bars' separator lines are the hairlines under the header and over the footer.
         egui::Panel::top("header")
-            .frame(egui::Frame::new().fill(tokens.bg))
+            .frame(egui::Frame::new().fill(tokens.chrome))
             .exact_size(crate::views::HEADER_H)
             .resizable(false)
             .show(ui, |ui| self.header(ui, host.title_bar()));
         egui::Panel::bottom("footer")
-            .frame(egui::Frame::new().fill(tokens.bg).inner_margin(egui::Margin::symmetric(20, 0)))
+            .frame(egui::Frame::new().fill(tokens.chrome).inner_margin(egui::Margin::symmetric(20, 0)))
             .exact_size(crate::views::FOOTER_H)
             .resizable(false)
             .show(ui, |ui| self.footer(ui));
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
@@ -124,7 +124,7 @@ impl LauncherUi {
             let panel = Rect::from_min_size(pos2(left, area.top()), vec2(width, area.height()));
             let painter = ui.painter();
             painter.add(theme::shadow(&t).as_shape(panel, CornerRadius::ZERO));
-            painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
+            painter.rect_filled(panel, CornerRadius::ZERO, t.panel);
             painter.vline(panel.left() + 0.5, panel.y_range(), Stroke::new(1.0, t.line_strong));
 
             ui.set_min_size(panel.size());
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
@@ -20,7 +20,7 @@ impl LauncherUi {
         // An area is laid out in the size it had last frame, so dialogs can't share one id: the
         // uninstall prompt would open as tall as Settings and never shrink (#88).
         let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key()))
-            .frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
+            .frame(egui::Frame::new().fill(t.panel).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
         let mut close = false;
         let response = match &dialog {
             Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)),
@@ -38,7 +38,7 @@ impl LauncherUi {
         let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };
         let t = Tokens::get(ctx);
         let modal = egui::Modal::new(egui::Id::new("feed_alert").with(&alert.id))
-            .frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
+            .frame(egui::Frame::new().fill(t.panel).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
         let mut ok = false;
         modal.show(ctx, |ui| {
             ui.set_width(440.0f32.min(ctx.content_rect().width() - 80.0));
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
@@ -4,6 +4,7 @@
 
 use std::sync::Arc;
 
+use craft_ui::theme::SuitePalette;
 use egui::epaint::text::VariationCoords;
 use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
 use serde::{Deserialize, Serialize};
@@ -40,8 +41,17 @@ pub struct Tokens {
     pub dark: bool,
     /// Page and card surface.
     pub bg: Color32,
-    /// Badges, wells and row hovers.
+    /// Header and footer chrome.
+    pub chrome: Color32,
+    /// Drawers and dialogs.
+    pub panel: Color32,
+    /// Raised controls and notices.
+    pub raised: Color32,
+    /// Badges, wells and text fields.
     pub sunken: Color32,
+    /// Interactive hover and pressed surfaces.
+    pub hover: Color32,
+    pub pressed: Color32,
     /// Body text.
     pub ink: Color32,
     /// Headings.
@@ -58,6 +68,11 @@ pub struct Tokens {
     pub accent: Color32,
     /// The brand blue, for text and icons.
     pub accent_ink: Color32,
+    /// Foreground on a blue filled control.
+    pub on_accent: Color32,
+    /// Selected text and rows, distinct from the brighter brand accent.
+    pub selection: Color32,
+    pub selection_ink: Color32,
     /// Primary buttons and hover inversion.
     pub invert_bg: Color32,
     pub invert_fg: Color32,
@@ -79,18 +94,27 @@ const fn hex(rgb: u32) -> Color32 {
 impl Tokens {
     pub const DARK: Self = Self {
         dark: true,
-        bg: hex(0x121316),
-        sunken: hex(0x060607),
-        ink: hex(0xf2f1ee),
-        ink_strong: hex(0xffffff),
-        muted: ink_alpha(0xf2f1ee, 153),
-        faint: ink_alpha(0xf2f1ee, 97),
-        line: ink_alpha(0xf2f1ee, 38),
-        line_strong: ink_alpha(0xf2f1ee, 102),
-        accent: hex(0x2d81ff),
-        accent_ink: hex(0x74aaff),
-        invert_bg: hex(0xffffff),
-        invert_fg: hex(0x0b0b0c),
+        // ArtCraft's shared standard-dark role palette.
+        bg: SuitePalette::ARTCRAFT_DARK.window,
+        chrome: SuitePalette::ARTCRAFT_DARK.chrome,
+        panel: SuitePalette::ARTCRAFT_DARK.panel,
+        raised: SuitePalette::ARTCRAFT_DARK.raised,
+        sunken: SuitePalette::ARTCRAFT_DARK.input,
+        hover: SuitePalette::ARTCRAFT_DARK.hover,
+        pressed: SuitePalette::ARTCRAFT_DARK.pressed,
+        ink: SuitePalette::ARTCRAFT_DARK.text,
+        ink_strong: SuitePalette::ARTCRAFT_DARK.text_strong,
+        muted: SuitePalette::ARTCRAFT_DARK.text_secondary,
+        faint: SuitePalette::ARTCRAFT_DARK.text_disabled,
+        line: SuitePalette::ARTCRAFT_DARK.border,
+        line_strong: SuitePalette::ARTCRAFT_DARK.border_strong,
+        accent: SuitePalette::ARTCRAFT_DARK.accent,
+        accent_ink: SuitePalette::ARTCRAFT_DARK.focus,
+        on_accent: SuitePalette::ARTCRAFT_DARK.on_accent,
+        selection: SuitePalette::ARTCRAFT_DARK.selection,
+        selection_ink: SuitePalette::ARTCRAFT_DARK.selection_text,
+        invert_bg: SuitePalette::ARTCRAFT_DARK.primary,
+        invert_fg: SuitePalette::ARTCRAFT_DARK.on_primary,
         // PdfCraft's and LightCraft's inks, so status colours come from the site's palette.
         good: hex(0x3fd6b8),
         warn: hex(0xffc14d),
@@ -100,7 +124,12 @@ impl Tokens {
     pub const LIGHT: Self = Self {
         dark: false,
         bg: hex(0xf2f1ee),
+        chrome: hex(0xf2f1ee),
+        panel: hex(0xf2f1ee),
+        raised: hex(0xf2f1ee),
         sunken: hex(0xe9e8e4),
+        hover: hex(0xe9e8e4),
+        pressed: hex(0xe9e8e4),
         ink: hex(0x101014),
         ink_strong: hex(0x000000),
         muted: ink_alpha(0x101014, 158),
@@ -109,6 +138,9 @@ impl Tokens {
         line_strong: ink_alpha(0x101014, 102),
         accent: hex(0x2d81ff),
         accent_ink: hex(0x1659c4),
+        on_accent: hex(0xffffff),
+        selection: hex(0x2d81ff),
+        selection_ink: hex(0xffffff),
         invert_bg: hex(0x101014),
         invert_fg: hex(0xf2f1ee),
         good: hex(0x0a7563),
@@ -181,16 +213,16 @@ fn visuals(t: &Tokens) -> Visuals {
     v.popup_shadow = shadow(t);
     v.override_text_color = Some(t.ink);
     v.hyperlink_color = t.accent_ink;
-    v.selection.bg_fill = t.accent;
-    v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
+    v.selection.bg_fill = t.selection;
+    v.selection.stroke = Stroke::new(1.0, t.selection_ink);
     v.warn_fg_color = t.warn;
     v.error_fg_color = t.bad;
     for (w, fill, stroke, fg) in [
         (&mut v.widgets.noninteractive, t.bg, t.line, t.muted),
         (&mut v.widgets.inactive, t.bg, t.line, t.ink),
-        (&mut v.widgets.hovered, t.sunken, t.line_strong, t.ink_strong),
-        (&mut v.widgets.active, t.sunken, t.line_strong, t.ink_strong),
-        (&mut v.widgets.open, t.sunken, t.line_strong, t.ink_strong),
+        (&mut v.widgets.hovered, t.hover, t.line_strong, t.ink_strong),
+        (&mut v.widgets.active, t.pressed, t.line_strong, t.ink_strong),
+        (&mut v.widgets.open, t.pressed, t.line_strong, t.ink_strong),
     ] {
         w.bg_fill = fill;
         w.weak_bg_fill = fill;
@@ -352,3 +384,71 @@ pub fn cjk_font_loaded(lang: Lang) -> bool {
 pub fn cjk_font_available(lang: Lang) -> bool {
     cjk_font(lang).is_some()
 }
+
+#[cfg(test)]
+mod dark_theme_tests {
+    use super::*;
+
+    #[test]
+    fn shared_dark_roles_preserve_inverted_buttons_status_and_light_colors() {
+        let roles = SuitePalette::ARTCRAFT_DARK;
+        let dark = Tokens::DARK;
+        assert_eq!(dark.bg, roles.window);
+        assert_eq!(dark.chrome, roles.chrome);
+        assert_eq!(dark.panel, roles.panel);
+        assert_eq!(dark.raised, roles.raised);
+        assert_eq!(dark.sunken, roles.input);
+        assert_eq!(dark.hover, roles.hover);
+        assert_eq!(dark.pressed, roles.pressed);
+        assert_eq!(dark.ink, roles.text);
+        assert_eq!(dark.ink_strong, roles.text_strong);
+        assert_eq!(dark.muted, roles.text_secondary);
+        assert_eq!(dark.faint, roles.text_disabled);
+        assert_eq!(dark.line, roles.border);
+        assert_eq!(dark.line_strong, roles.border_strong);
+        assert_eq!(dark.accent, roles.accent);
+        assert_eq!(dark.accent_ink, roles.focus);
+        assert_eq!(dark.on_accent, roles.on_accent);
+        assert_eq!(dark.selection, roles.selection);
+        assert_eq!(dark.selection_ink, roles.selection_text);
+        assert_eq!(dark.invert_bg, roles.primary);
+        assert_eq!(dark.invert_fg, roles.on_primary);
+        assert_eq!((dark.good, dark.warn, dark.bad), (hex(0x3fd6b8), hex(0xffc14d), hex(0xff6259)));
+        let light = Tokens::LIGHT;
+        assert_eq!(light.bg, hex(0xf2f1ee));
+        assert_eq!(light.on_accent, Color32::WHITE);
+        assert_eq!(light.invert_bg, hex(0x101014));
+        assert_eq!(light.invert_fg, hex(0xf2f1ee));
+        assert_eq!(backdrop(), Color32::from_black_alpha(232));
+        let ctx = egui::Context::default();
+        apply(&ctx, ThemeChoice::System);
+        assert_eq!(ctx.options(|o| o.theme_preference), egui::ThemePreference::System);
+        assert_eq!(ctx.style_of(egui::Theme::Dark).visuals, visuals(&dark));
+        assert_eq!(ctx.style_of(egui::Theme::Light).visuals, visuals(&light));
+        for choice in ThemeChoice::ALL {
+            let json = serde_json::to_string(&choice).unwrap();
+            assert_eq!(serde_json::from_str::<ThemeChoice>(&json).unwrap(), choice);
+        }
+    }
+
+    #[test]
+    fn standard_dark_roles_reach_egui_without_changing_light_preference() {
+        let ctx = egui::Context::default();
+        apply(&ctx, ThemeChoice::Dark);
+        assert_eq!(ctx.theme(), egui::Theme::Dark);
+        let dark = ctx.style_of(egui::Theme::Dark);
+        assert_eq!(dark.visuals.panel_fill, hex(0x121316));
+        assert_eq!(dark.visuals.extreme_bg_color, hex(0x060607));
+        assert_eq!(dark.visuals.widgets.hovered.bg_fill, hex(0x323337));
+        assert_eq!(dark.visuals.widgets.active.bg_fill, hex(0x414246));
+        assert_eq!(dark.visuals.selection.bg_fill, hex(0x203b65));
+        assert_eq!(dark.visuals.selection.stroke.color, hex(0xf2f1ee));
+
+        apply(&ctx, ThemeChoice::Light);
+        assert_eq!(ctx.theme(), egui::Theme::Light);
+        let light = ctx.style_of(egui::Theme::Light);
+        assert_eq!(light.visuals.panel_fill, hex(0xf2f1ee));
+        assert_eq!(light.visuals.widgets.hovered.bg_fill, hex(0xe9e8e4));
+        assert_eq!(light.visuals.selection.bg_fill, hex(0x2d81ff));
+    }
+}
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
@@ -140,7 +140,7 @@ impl LauncherUi {
                 .inner_margin(egui::Margin::symmetric(16, 10))
                 .show(ui, |ui| {
                     ui.horizontal(|ui| {
-                        widgets::tag(ui, tl!("Update"), t.accent, Color32::WHITE);
+                        widgets::tag(ui, tl!("Update"), t.accent, t.on_accent);
                         ui.add_space(4.0);
                         ui.label(
                             RichText::new(fmt(tl!("ArtCraft Launcher {version} is available."), &[("version", &r.version_label())]))
@@ -265,7 +265,7 @@ impl LauncherUi {
         let shown: Vec<artcraft_launcher_core::feed::Notice> =
             self.announcements.iter().filter(|n| !self.settings.dismissed_notices.contains(&n.id)).take(3).cloned().collect();
         for notice in shown {
-            egui::Frame::new().fill(t.sunken).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
+            egui::Frame::new().fill(t.raised).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(14, 10)).show(ui, |ui| {
                 ui.horizontal(|ui| {
                     ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
                         if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Dismiss")).clicked() {
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
@@ -189,7 +189,7 @@ fn window_button(ui: &mut Ui, button: WindowButton, height: f32) -> Response {
     let resp = ui.interact(hit, ui.id().with(("window-button", button as u8)), Sense::click());
     let (fill, ink) = match (button, resp.hovered()) {
         (WindowButton::Close, true) => (Color32::from_rgb(0xc4, 0x2b, 0x1c), Color32::WHITE),
-        (_, true) => (t.line, t.ink_strong),
+        (_, true) => (t.hover, t.ink_strong),
         (_, false) => (Color32::TRANSPARENT, t.muted),
     };
     let p = ui.painter();
@@ -370,7 +370,7 @@ pub fn cells<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, Cel
             let hovered = resp.hovered();
             let (fill, ink) = match (on, hovered) {
                 (true, _) => (t.invert_bg, t.invert_fg),
-                (false, true) => (t.sunken, t.ink_strong),
+                (false, true) => (t.hover, t.ink_strong),
                 (false, false) => (Color32::TRANSPARENT, t.muted),
             };
             ui.painter().rect_filled(rect, CornerRadius::ZERO, fill);
