Browse / github.com/storytold/craft-launcher / patch
Align Launcher dark UI roles with ArtCraft suite
Align Launcher dark UI roles with ArtCraft suite The Launcher already uses ArtCraft’s near-black window, warm white text and blue accent, but its header, drawers, notices, hover/pressed controls and text selection still use generic wells or translucent colors. This maps those UI roles to the suite’s standard dark palette. The light and system themes, inverted white primary buttons, app branding and gallery imagery stay intact. The same egui UI serves desktop and web. Validation: `cargo xtask ci` passed (formatting, Clippy, workspace tests and wasm). All 46 UI render scenarios passed; dark grid, settings and details plus the light table we
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- pourie-he (imported from pull request #365, open)
- Base
1579aa8c02acc5483f901109292b18c22d750559- Size
- +137 −27 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1ebe99de63dbe7858e6ed071
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/ui-egui/Cargo.toml crates/ui-egui/src/app.rs crates/ui-egui/src/details.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/views.rs crates/ui-egui/src/widgets.rs
@@ -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"@@ -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"] }@@ -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));@@ -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());@@ -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));@@ -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));+ }+}@@ -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() {@@ -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);