Skip to main content

frust_theme/
status.rs

1//! [`StatusPalette`]: success/warning/info color roles as a
2//! [`crate::extensions::ThemeExtensions`] consumer.
3//!
4//! [`crate::color::ColorScheme`]'s 46 roles carry no
5//! `success`/`warning`/`info` at all — only `error`. This extension is that
6//! gap's typed filler: [`crate::theme::Theme::neutral`] attaches
7//! [`StatusPalette::neutral`], and a design system attaches its own table the
8//! same way.
9//!
10//! **Community-approximate** (`neutral`'s values): they apply the same
11//! tone-relationship Material 3's `error` roles use (light:
12//! base/on/container/on-container ≈ tone 40/100/90/10; dark: ≈ tone
13//! 80/20/30/90) to green (success), amber (warning), and blue (info) seed
14//! hues, chosen for conventional semantic association and AA contrast against
15//! `surface`/`on_surface`, not measured against a specific published export —
16//! Material 3 has no fixed status-role table to cite (its Theme Builder
17//! covers the gap with per-seed HCT "custom colors" instead).
18
19use crate::color::Brightness;
20use peniko::Color;
21
22/// One brightness's worth of success/warning/info color roles, mirroring
23/// `ColorScheme`'s `error`/`on_error`/`error_container`/`on_error_container`
24/// shape for each of the three statuses.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct StatusColors {
27    pub success: Color,
28    pub on_success: Color,
29    pub success_container: Color,
30    pub on_success_container: Color,
31
32    pub warning: Color,
33    pub on_warning: Color,
34    pub warning_container: Color,
35    pub on_warning_container: Color,
36
37    pub info: Color,
38    pub on_info: Color,
39    pub info_container: Color,
40    pub on_info_container: Color,
41}
42
43/// The success/warning/info extension a [`crate::theme::Theme`] carries via
44/// [`crate::extensions::ThemeExtensions`] — recovered with
45/// `theme.extension::<StatusPalette>()`.
46///
47/// [`crate::theme::Theme::neutral`] attaches [`StatusPalette::neutral`], so
48/// `extension::<StatusPalette>()` is always `Some` on the framework's own
49/// baseline; an app or design system attaches its own table with
50/// `theme.extensions.insert(StatusPalette { .. })` (or
51/// [`ThemeBuilder::extension`](crate::builder::ThemeBuilder::extension)) to
52/// override it wholesale.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct StatusPalette {
55    pub light: StatusColors,
56    pub dark: StatusColors,
57}
58
59impl StatusPalette {
60    /// The `StatusColors` for `brightness` — mirrors
61    /// [`crate::theme::Theme::scheme`]'s light/dark selector.
62    pub fn colors(&self, brightness: Brightness) -> &StatusColors {
63        match brightness {
64            Brightness::Light => &self.light,
65            Brightness::Dark => &self.dark,
66        }
67    }
68
69    /// The language-free palette [`crate::theme::Theme::neutral`] composes.
70    ///
71    /// Success/warning/info are a *functional* signal, not a design-language
72    /// "look" — the same reasoning `ColorScheme::neutral_light`/`_dark` use to
73    /// keep `error` real red instead of grayscaling it too — so the neutral
74    /// baseline carries a real three-status table rather than going unset.
75    /// The values are the module docs' community-approximate tone
76    /// relationships, stated here in full rather than delegated, so this
77    /// constructor stands on its own with every design language out of tree.
78    pub const fn neutral() -> Self {
79        Self {
80            light: StatusColors {
81                // Green seed, ~tone 40/100/90/10 (mirrors `error`'s light
82                // tone relationship: base/on/container/on-container).
83                success: Color::from_rgb8(0x2E, 0x7D, 0x32),
84                on_success: Color::from_rgb8(0xFF, 0xFF, 0xFF),
85                success_container: Color::from_rgb8(0xC8, 0xE6, 0xC9),
86                on_success_container: Color::from_rgb8(0x1B, 0x5E, 0x20),
87
88                // Amber seed, tuned dark enough for AA-on-white at the base
89                // tone (a literal amber-400 like `#FFC107` fails AA on
90                // white).
91                warning: Color::from_rgb8(0x8A, 0x53, 0x00),
92                on_warning: Color::from_rgb8(0xFF, 0xFF, 0xFF),
93                warning_container: Color::from_rgb8(0xFF, 0xDD, 0xB0),
94                on_warning_container: Color::from_rgb8(0x2B, 0x17, 0x00),
95
96                // Blue seed, the conventional "info" blue.
97                info: Color::from_rgb8(0x00, 0x61, 0xA4),
98                on_info: Color::from_rgb8(0xFF, 0xFF, 0xFF),
99                info_container: Color::from_rgb8(0xD1, 0xE4, 0xFF),
100                on_info_container: Color::from_rgb8(0x00, 0x1D, 0x36),
101            },
102            dark: StatusColors {
103                // Dark tone relationship (~80/20/30/90), mirroring `error`'s
104                // dark tones (`F2B8B5`/`601410`/`8C1D18`/`F9DEDC`).
105                success: Color::from_rgb8(0xA6, 0xF1, 0xA1),
106                on_success: Color::from_rgb8(0x00, 0x39, 0x0F),
107                success_container: Color::from_rgb8(0x20, 0x57, 0x23),
108                on_success_container: Color::from_rgb8(0xC8, 0xE6, 0xC9),
109
110                warning: Color::from_rgb8(0xFF, 0xC4, 0x6B),
111                on_warning: Color::from_rgb8(0x45, 0x2B, 0x00),
112                warning_container: Color::from_rgb8(0x6F, 0x49, 0x00),
113                on_warning_container: Color::from_rgb8(0xFF, 0xDD, 0xB0),
114
115                info: Color::from_rgb8(0x9F, 0xCA, 0xFF),
116                on_info: Color::from_rgb8(0x00, 0x32, 0x50),
117                info_container: Color::from_rgb8(0x00, 0x4A, 0x76),
118                on_info_container: Color::from_rgb8(0xD1, 0xE4, 0xFF),
119            },
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn neutral_colors_selects_by_brightness() {
130        let palette = StatusPalette::neutral();
131        assert_eq!(palette.colors(Brightness::Light), &palette.light);
132        assert_eq!(palette.colors(Brightness::Dark), &palette.dark);
133    }
134
135    #[test]
136    fn neutral_light_and_dark_are_distinct() {
137        let palette = StatusPalette::neutral();
138        assert_ne!(palette.light.success, palette.dark.success);
139        assert_ne!(palette.light.warning, palette.dark.warning);
140        assert_ne!(palette.light.info, palette.dark.info);
141    }
142
143    #[test]
144    fn neutral_spot_check() {
145        let palette = StatusPalette::neutral();
146        assert_eq!(palette.light.success, Color::from_rgb8(0x2E, 0x7D, 0x32));
147        assert_eq!(palette.light.on_success, Color::from_rgb8(0xFF, 0xFF, 0xFF));
148        assert_eq!(palette.dark.warning, Color::from_rgb8(0xFF, 0xC4, 0x6B));
149        assert_eq!(palette.dark.info, Color::from_rgb8(0x9F, 0xCA, 0xFF));
150    }
151
152    #[test]
153    fn neutral_is_const_constructible() {
154        // Regression anchor: `StatusPalette::neutral()` must stay a `const fn`
155        // so a design system can name it in a const context; this binds it to
156        // a `const` and just uses it, which fails to compile if constness
157        // regresses.
158        const PALETTE: StatusPalette = StatusPalette::neutral();
159        assert_eq!(PALETTE.light.success, Color::from_rgb8(0x2E, 0x7D, 0x32));
160    }
161}