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}