//! Palette contrast tests, mirroring bezel's `tests/theme.rs`: each light text //! token must land within ~0.5 of its dark counterpart's contrast ratio //! against its own background, and the primary text must keep the ratio the //! palette was tuned for. use gpui::Hsla; use theme::{Brand, ThemeColors, ThemeFamily, ThemeMode, Tint, contrast_ratio, flatten}; // The shipped palette's measured floors (AppKit-style ink, per bezel): // text/bg is 14.4:1 dark and 15.1:1 light, muted lands near 6.3 dark and // 4.0 light, and faint sits below 3:1 in both appearances by design. const TEXT_RATIO: f32 = 13.9; const MUTED_RATIO: f32 = 3.9; const FAINT_RATIO: f32 = 1.8; const TEXT_PARITY: f32 = 0.7; fn ratio(fg: Hsla, bg: Hsla) -> f32 { contrast_ratio(flatten(fg, bg), bg) } #[test] fn text_keeps_its_contrast() { for appearance in [ThemeMode::Dark, ThemeMode::Light] { let colors = ThemeColors::for_appearance(appearance); assert!( ratio(colors.text, colors.bg) >= TEXT_RATIO, "{appearance:?}: text on bg = {}", ratio(colors.text, colors.bg) ); assert!( ratio(colors.text, colors.surface) >= TEXT_RATIO, "{appearance:?}: text on surface" ); assert!( ratio(colors.text_muted, colors.bg) >= MUTED_RATIO, "{appearance:?}: muted on bg = {}", ratio(colors.text_muted, colors.bg) ); assert!( ratio(colors.text_faint, colors.bg) >= FAINT_RATIO, "{appearance:?}: faint on bg = {}", ratio(colors.text_faint, colors.bg) ); } } #[test] fn light_matches_dark_contrast() { let dark = ThemeColors::for_appearance(ThemeMode::Dark); let light = ThemeColors::for_appearance(ThemeMode::Light); // Primary text keeps near-parity across appearances. Muted and faint do // not (the light values sit further from the background by design, see // Ink::APPKIT); their floors are asserted in `text_keeps_its_contrast`. let pairs = [ ("text/bg", dark.text, dark.bg, light.text, light.bg), ( "text/surface", dark.text, dark.surface, light.text, light.surface, ), ]; for (name, dark_fg, dark_bg, light_fg, light_bg) in pairs { let dark_ratio = ratio(dark_fg, dark_bg); let light_ratio = ratio(light_fg, light_bg); let delta = (dark_ratio - light_ratio).abs(); assert!( delta <= TEXT_PARITY, "{name}: light deviates by {delta} (dark {dark_ratio:.3}, light {light_ratio:.3})" ); } } #[test] fn default_brand_is_a_no_op() { let brand = Brand::default(); assert!(brand.is_default()); for appearance in [ThemeMode::Dark, ThemeMode::Light] { let base = ThemeColors::for_appearance(appearance); let branded = brand.branded(appearance); assert_eq!( base, branded, "{appearance:?}: default brand must not move the palette" ); } } #[test] fn brand_tint_rotates_greys_and_keeps_semantics() { let brand = Brand { tint: Tint::new(257.417, 0.046), accent: Tint::new(276.935, 0.182), ..Brand::default() }; let base = ThemeColors::for_appearance(ThemeMode::Dark); let branded = brand.branded(ThemeMode::Dark); assert_ne!(branded.bg, base.bg, "bg takes the tint"); assert_ne!(branded.accent, base.accent, "accent takes the accent hue"); assert_eq!( branded.danger, base.danger, "danger is semantic and keeps its hue" ); assert_eq!( branded.warning, base.warning, "warning is semantic and keeps its hue" ); } #[test] fn warning_wash_carries_its_label() { // warning_muted is a translucent amber wash, so the label pairing is // measured against the plate flattened onto the content plane. for appearance in [ThemeMode::Dark, ThemeMode::Light] { let colors = ThemeColors::for_appearance(appearance); let plate = flatten(colors.warning_muted, colors.bg); assert!( ratio(colors.warning, plate) >= 3.0, "{appearance:?}: warning on warning_muted = {}", ratio(colors.warning, plate) ); } } #[test] fn legacy_aliases_track_tokens() { for appearance in [ThemeMode::Dark, ThemeMode::Light] { let colors = ThemeColors::for_appearance(appearance); assert_eq!(colors.background, colors.bg); assert_eq!(colors.surface_background, colors.surface); assert_eq!(colors.text_placeholder, colors.text_faint); assert_eq!(colors.element_background, colors.solid); assert_eq!(colors.secondary_background, colors.surface_raised); assert_eq!(colors.tab_active_background, colors.element_active); } } #[test] fn rederive_round_trips_a_legacy_theme() { let json = br##"{ "id": "test", "name": "Test", "author": "coop", "url": "", "light": { "background": "#ffffff", "surface_background": "#fafafa", "elevated_surface_background": "#f5f5f5", "panel_background": "#ffffff", "overlay": "#0000001a", "title_bar": "#00000000", "title_bar_inactive": "#ffffff", "window_border": "#c7c7cf", "border": "#dbdbdb", "border_variant": "#d1d1d1", "border_focused": "#3366cc", "border_selected": "#3366cc", "border_transparent": "#00000000", "border_disabled": "#e6e6e6", "ring": "#4d79d6", "text": "#1a1a1a", "text_muted": "#4d4d4d", "text_placeholder": "#808080", "text_accent": "#3366cc", "text_danger": "#cc3333", "text_warning": "#cc9933", "icon": "#4d4d4d", "icon_muted": "#808080", "icon_accent": "#3366cc", "element_foreground": "#ffffff", "element_background": "#3366cc", "element_hover": "#3366cce6", "element_active": "#2e5cb8", "element_selected": "#2952a3", "element_disabled": "#3366cc4d", "secondary_foreground": "#2952a3", "secondary_background": "#e6ecf5", "secondary_hover": "#3366cc1a", "secondary_active": "#d9e2f0", "secondary_selected": "#d9e2f0", "secondary_disabled": "#3366cc4d", "danger_foreground": "#ffffff", "danger_background": "#f5e6e6", "danger_hover": "#cc33331a", "danger_active": "#f0d9d9", "danger_selected": "#f0d9d9", "danger_disabled": "#cc33334d", "warning_foreground": "#1a1a1a", "warning_background": "#f5f0e6", "warning_hover": "#cc99331a", "warning_active": "#f0ead9", "warning_selected": "#f0ead9", "warning_disabled": "#cc99334d", "ghost_element_background": "#00000000", "ghost_element_background_alt": "#e6e6e6", "ghost_element_hover": "#0000001a", "ghost_element_active": "#d9d9d9", "ghost_element_selected": "#d9d9d9", "ghost_element_disabled": "#0000000d", "tab_inactive_background": "#e6e6e6", "tab_hover_background": "#e0e0e0", "tab_active_background": "#d9d9d9", "scrollbar_thumb_background": "#00000033", "scrollbar_thumb_hover_background": "#0000004d", "scrollbar_thumb_border": "#00000000", "scrollbar_track_background": "#00000000", "scrollbar_track_border": "#d9d9d9", "drop_target_background": "#3366cc1a", "cursor": "#3399ff", "selection": "#3399ff40" }, "dark": { "background": "#1a1a1a", "surface_background": "#1f1f1f", "elevated_surface_background": "#242424", "panel_background": "#262626", "overlay": "#ffffff1a", "title_bar": "#00000000", "title_bar_inactive": "#1a1a1a", "window_border": "#404046", "border": "#404040", "border_variant": "#383838", "border_focused": "#4d79d6", "border_selected": "#4d79d6", "border_transparent": "#00000000", "border_disabled": "#2e2e2e", "ring": "#668cdf", "text": "#f2f2f2", "text_muted": "#b3b3b3", "text_placeholder": "#808080", "text_accent": "#668cdf", "text_danger": "#d64d4d", "text_warning": "#d6b34d", "icon": "#b3b3b3", "icon_muted": "#808080", "icon_accent": "#668cdf", "element_foreground": "#ffffff", "element_background": "#4d79d6", "element_hover": "#4d79d6e6", "element_active": "#456dc1", "element_selected": "#3e62ac", "element_disabled": "#4d79d64d", "secondary_foreground": "#3e62ac", "secondary_background": "#2a3652", "secondary_hover": "#4d79d61a", "secondary_active": "#303d5c", "secondary_selected": "#303d5c", "secondary_disabled": "#4d79d64d", "danger_foreground": "#ffffff", "danger_background": "#522a2a", "danger_hover": "#d64d4d1a", "danger_active": "#5c3030", "danger_selected": "#5c3030", "danger_disabled": "#d64d4d4d", "warning_foreground": "#f2f2f2", "warning_background": "#52482a", "warning_hover": "#d6b34d1a", "warning_active": "#5c5430", "warning_selected": "#5c5430", "warning_disabled": "#d6b34d4d", "ghost_element_background": "#00000000", "ghost_element_background_alt": "#2e2e2e", "ghost_element_hover": "#ffffff1a", "ghost_element_active": "#383838", "ghost_element_selected": "#383838", "ghost_element_disabled": "#ffffff0d", "tab_inactive_background": "#2e2e2e", "tab_hover_background": "#333333", "tab_active_background": "#383838", "scrollbar_thumb_background": "#ffffff33", "scrollbar_thumb_hover_background": "#ffffff4d", "scrollbar_thumb_border": "#00000000", "scrollbar_track_background": "#00000000", "scrollbar_track_border": "#383838", "drop_target_background": "#4d79d61a", "cursor": "#4db3ff", "selection": "#4db3ff40" } }"##; let family: ThemeFamily = serde_json::from_slice(json).expect("parse"); let mut light = family.light; light.rederive_tokens(ThemeMode::Light); assert_eq!(light.bg, light.background, "bg tracks the JSON background"); assert_eq!(light.surface, light.surface_background); assert_eq!(light.solid, light.element_background); // Fields with no legacy source keep the built-in palette's value. assert_eq!( light.success, ThemeColors::for_appearance(ThemeMode::Light).success ); }