返回 CodeWhale
palette_audit.rs
根目录 / crates / tui / tests / palette_audit.rs
1 //! Palette audit tests to prevent color drift.
2 //!
3 //! These tests ensure that deprecated colors are not used directly in
4 //! user-visible code. Backward-compatible DeepSeek aliases should point
5 //! at the current Codewhale semantic tokens instead of stale brand RGBs.
6
7 use ratatui::style::Color;
8
9 #[path = "../src/palette/mod.rs"]
10 #[allow(dead_code)]
11 mod palette;
12
13 // Local stand-in for the binary crate's `src/test_support.rs`.
14 //
15 // `palette_audit` `#[path]`-includes `src/palette/mod.rs`, which transitively
16 // compiles `src/palette/user_theme.rs`. That module's inline tests call
17 // `crate::test_support::{lock_test_env, EnvVarGuard}`. In the binary crate
18 // those resolve to the real helpers; in this separate integration-test crate
19 // there is no such module, so this minimal shim provides the same surface and
20 // keeps the transitive tests serialized within this process.
21 #[allow(dead_code)]
22 mod test_support {
23 use std::ffi::{OsStr, OsString};
24 use std::sync::{Mutex, MutexGuard, OnceLock};
25
26 fn lock() -> &'static Mutex<()> {
27 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
28 LOCK.get_or_init(Mutex::default)
29 }
30
31 pub(super) struct TestEnvLock {
32 _guard: MutexGuard<'static, ()>,
33 }
34
35 pub(super) fn lock_test_env() -> TestEnvLock {
36 let guard = match lock().lock() {
37 Ok(guard) => guard,
38 Err(poisoned) => poisoned.into_inner(),
39 };
40 TestEnvLock { _guard: guard }
41 }
42
43 pub(super) struct EnvVarGuard {
44 key: &'static str,
45 previous: Option<OsString>,
46 }
47
48 impl EnvVarGuard {
49 pub(super) fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
50 let previous = std::env::var_os(key);
51 // SAFETY: callers hold the process-wide test env mutex.
52 unsafe { std::env::set_var(key, value) };
53 Self { key, previous }
54 }
55 }
56
57 impl Drop for EnvVarGuard {
58 fn drop(&mut self) {
59 // SAFETY: callers hold the process-wide test env mutex until drop.
60 unsafe {
61 if let Some(value) = self.previous.take() {
62 std::env::set_var(self.key, value);
63 } else {
64 std::env::remove_var(self.key);
65 }
66 }
67 }
68 }
69 }
70
71 fn color_to_rgb(color: Color) -> (u8, u8, u8) {
72 match color {
73 Color::Rgb(r, g, b) => (r, g, b),
74 Color::Black => (0, 0, 0),
75 Color::White => (255, 255, 255),
76 Color::Gray => (128, 128, 128),
77 Color::DarkGray => (169, 169, 169),
78 Color::Red => (255, 0, 0),
79 Color::LightRed => (255, 102, 102),
80 Color::Green => (0, 255, 0),
81 Color::LightGreen => (102, 255, 102),
82 Color::Yellow => (255, 255, 0),
83 Color::LightYellow => (255, 255, 153),
84 Color::Blue => (0, 0, 255),
85 Color::LightBlue => (102, 153, 255),
86 Color::Magenta => (255, 0, 255),
87 Color::LightMagenta => (255, 153, 255),
88 Color::Cyan => (0, 255, 255),
89 Color::LightCyan => (153, 255, 255),
90 _ => panic!("unsupported color variant for contrast test: {color:?}"),
91 }
92 }
93
94 fn linearize_srgb(component: u8) -> f64 {
95 let srgb = f64::from(component) / 255.0;
96 if srgb <= 0.04045 {
97 srgb / 12.92
98 } else {
99 ((srgb + 0.055) / 1.055).powf(2.4)
100 }
101 }
102
103 fn relative_luminance(color: Color) -> f64 {
104 let (r, g, b) = color_to_rgb(color);
105 0.2126 * linearize_srgb(r) + 0.7152 * linearize_srgb(g) + 0.0722 * linearize_srgb(b)
106 }
107
108 fn contrast_ratio(foreground: Color, background: Color) -> f64 {
109 let fg = relative_luminance(foreground);
110 let bg = relative_luminance(background);
111 if fg >= bg {
112 (fg + 0.05) / (bg + 0.05)
113 } else {
114 (bg + 0.05) / (fg + 0.05)
115 }
116 }
117
118 fn assert_min_contrast(label: &str, foreground: Color, background: Color, min_ratio: f64) {
119 let ratio = contrast_ratio(foreground, background);
120 assert!(
121 ratio >= min_ratio,
122 "{label} contrast {ratio:.2} is below minimum {min_ratio:.2}"
123 );
124 }
125
126 // NOTE: The deprecated color audit (DEEPSEEK_AQUA) was removed because
127 // the deprecated constant no longer exists in the palette.
128
129 #[test]
130 fn verify_status_success_uses_success_token() {
131 assert_eq!(
132 palette::STATUS_SUCCESS,
133 Color::Rgb(
134 palette::WHALE_SUCCESS_RGB.0,
135 palette::WHALE_SUCCESS_RGB.1,
136 palette::WHALE_SUCCESS_RGB.2
137 ),
138 "STATUS_SUCCESS should use the current success token"
139 );
140 assert_ne!(
141 palette::STATUS_SUCCESS,
142 palette::WHALE_ACCENT_PRIMARY,
143 "STATUS_SUCCESS should not regress to the primary accent"
144 );
145 }
146
147 #[test]
148 fn whale_roles_are_pinned_and_non_colliding() {
149 assert_eq!(palette::WHALE_BG_RGB, (3, 7, 13));
150 assert_eq!(palette::WHALE_PANEL_RGB, (14, 23, 41));
151 assert_eq!(palette::WHALE_ELEVATED_RGB, (24, 39, 66));
152 assert_eq!(palette::WHALE_ACTION_RGB, (106, 174, 242));
153 assert_eq!(palette::WHALE_ACCENT_SECONDARY_RGB, (79, 209, 197));
154 assert_eq!(palette::WHALE_HUMAN_RGB, (246, 196, 83));
155 assert_eq!(palette::WHALE_WARNING_RGB, (255, 122, 89));
156 assert_eq!(palette::WHALE_ERROR_RGB, (255, 134, 178));
157 assert_eq!(palette::WHALE_MODE_AGENT_RGB, (118, 181, 245));
158 assert_eq!(palette::WHALE_MODE_YOLO_RGB, (255, 112, 160));
159 assert_eq!(palette::WHALE_MODE_PLAN_RGB, (185, 220, 236));
160 assert_eq!(palette::WHALE_MODE_OPERATE_RGB, (173, 136, 255));
161 assert_eq!(palette::LIGHT_SUCCESS_FG_RGB, (20, 118, 61));
162 assert_eq!(palette::LIGHT_MODE_AGENT_RGB, (50, 95, 216));
163 assert_eq!(palette::LIGHT_MODE_PLAN_RGB, (52, 92, 128));
164 assert_eq!(palette::LIGHT_OPERATE_RGB, (112, 71, 184));
165 assert_eq!(palette::LIGHT_MODE_YOLO_RGB, (181, 35, 90));
166 assert_eq!(palette::LIGHT_USER_BODY, palette::LIGHT_SUCCESS_FG);
167
168 let ui = palette::UI_THEME;
169 assert_eq!(ui.accent_primary, palette::WHALE_ACTION);
170 assert_eq!(ui.info, palette::WHALE_ACTION);
171 assert_eq!(ui.status_working, palette::WHALE_LIVE);
172 assert_eq!(ui.accent_action, palette::WHALE_HUMAN);
173 assert_eq!(ui.warning, palette::STATUS_WARNING);
174 assert_eq!(ui.error_fg, palette::WHALE_ERROR);
175 assert_eq!(ui.mode_operate, palette::MODE_OPERATE);
176 assert_ne!(
177 ui.mode_plan, ui.accent_action,
178 "Plan is structural; Signal Gold is reserved for human attention"
179 );
180 assert_ne!(
181 ui.status_working, ui.success,
182 "live and done need separate ink"
183 );
184 assert_ne!(ui.accent_action, ui.warning, "human asks are not warnings");
185 assert_ne!(
186 ui.warning, ui.error_fg,
187 "warning and danger must not collapse"
188 );
189
190 let foreground_domains = [
191 ("action", palette::WHALE_ACTION),
192 ("live", palette::WHALE_LIVE),
193 ("human", palette::WHALE_HUMAN),
194 ("success", palette::STATUS_SUCCESS),
195 ("warning", palette::STATUS_WARNING),
196 ("danger", palette::WHALE_ERROR),
197 ("agent mode", palette::MODE_AGENT),
198 ("full-access mode", palette::MODE_YOLO),
199 ("plan mode", palette::MODE_PLAN),
200 ("operate mode", palette::MODE_OPERATE),
201 ("reasoning", palette::TEXT_REASONING),
202 ("diff added", palette::DIFF_ADDED),
203 ];
204 for (index, (left_name, left)) in foreground_domains.iter().enumerate() {
205 for (right_name, right) in foreground_domains.iter().skip(index + 1) {
206 assert_ne!(
207 left, right,
208 "raw foreground adaptation domains '{left_name}' and '{right_name}' collide"
209 );
210 }
211 }
212
213 let background_domains = [
214 ("base", palette::WHALE_BG),
215 ("panel", palette::WHALE_PANEL),
216 ("composer", palette::WHALE_COMPOSER),
217 ("elevated", palette::SURFACE_ELEVATED),
218 ("tool", palette::SURFACE_TOOL),
219 ("tool active", palette::SURFACE_TOOL_ACTIVE),
220 ("reasoning", palette::SURFACE_REASONING),
221 ("reasoning tint", palette::SURFACE_REASONING_TINT),
222 ("reasoning active", palette::SURFACE_REASONING_ACTIVE),
223 ("success", palette::SURFACE_SUCCESS),
224 ("error", palette::SURFACE_ERROR),
225 ("selection", palette::SELECTION_BG),
226 ("diff added", palette::DIFF_ADDED_BG),
227 ("diff deleted", palette::DIFF_DELETED_BG),
228 ];
229 for (index, (left_name, left)) in background_domains.iter().enumerate() {
230 for (right_name, right) in background_domains.iter().skip(index + 1) {
231 assert_ne!(
232 left, right,
233 "raw background adaptation domains '{left_name}' and '{right_name}' collide"
234 );
235 }
236 }
237 }
238
239 #[test]
240 fn contrast_guardrails_for_key_ui_pairs() {
241 let min_readable = 4.5;
242
243 assert_min_contrast(
244 "TEXT_BODY on WHALE_BG",
245 palette::TEXT_BODY,
246 palette::WHALE_BG,
247 min_readable,
248 );
249 assert_min_contrast(
250 "TEXT_SECONDARY on WHALE_BG",
251 palette::TEXT_SECONDARY,
252 palette::WHALE_BG,
253 min_readable,
254 );
255 assert_min_contrast(
256 "TEXT_HINT on WHALE_BG",
257 palette::TEXT_HINT,
258 palette::WHALE_BG,
259 min_readable,
260 );
261 assert_min_contrast(
262 "STATUS_WARNING on WHALE_BG",
263 palette::STATUS_WARNING,
264 palette::WHALE_BG,
265 min_readable,
266 );
267 assert_min_contrast(
268 "STATUS_ERROR on WHALE_BG",
269 palette::STATUS_ERROR,
270 palette::WHALE_BG,
271 min_readable,
272 );
273 assert_min_contrast(
274 "SELECTION_TEXT on SELECTION_BG",
275 palette::SELECTION_TEXT,
276 palette::SELECTION_BG,
277 min_readable,
278 );
279 assert_min_contrast(
280 "TEXT_PRIMARY on SURFACE_ELEVATED",
281 palette::TEXT_PRIMARY,
282 palette::SURFACE_ELEVATED,
283 min_readable,
284 );
285 for (label, foreground) in [
286 ("action", palette::UI_THEME.accent_primary),
287 ("live", palette::UI_THEME.status_working),
288 ("human", palette::UI_THEME.accent_action),
289 ("warning", palette::UI_THEME.warning),
290 ("danger", palette::UI_THEME.error_fg),
291 ("act mode", palette::UI_THEME.mode_agent),
292 ("plan mode", palette::UI_THEME.mode_plan),
293 ("operate", palette::UI_THEME.mode_operate),
294 ("full-access mode", palette::UI_THEME.mode_yolo),
295 ("success", palette::UI_THEME.success),
296 ] {
297 assert_min_contrast(label, foreground, palette::SURFACE_ELEVATED, min_readable);
298 }
299 let light_foregrounds = [
300 ("body", palette::LIGHT_UI_THEME.text_body),
301 ("soft", palette::LIGHT_UI_THEME.text_soft),
302 ("muted", palette::LIGHT_UI_THEME.text_muted),
303 ("hint", palette::LIGHT_UI_THEME.text_hint),
304 ("action", palette::LIGHT_UI_THEME.accent_primary),
305 ("live", palette::LIGHT_UI_THEME.status_working),
306 ("human", palette::LIGHT_UI_THEME.accent_action),
307 ("warning", palette::LIGHT_UI_THEME.warning),
308 ("danger", palette::LIGHT_UI_THEME.error_fg),
309 ("act mode", palette::LIGHT_UI_THEME.mode_agent),
310 ("plan mode", palette::LIGHT_UI_THEME.mode_plan),
311 ("operate", palette::LIGHT_UI_THEME.mode_operate),
312 ("full-access mode", palette::LIGHT_UI_THEME.mode_yolo),
313 ("success", palette::LIGHT_UI_THEME.success),
314 ("user", palette::LIGHT_USER_BODY),
315 ];
316 for (background_name, background) in [
317 ("surface", palette::LIGHT_SURFACE),
318 ("panel", palette::LIGHT_PANEL),
319 ("raised", palette::LIGHT_ELEVATED),
320 ("selection", palette::LIGHT_SELECTION_BG),
321 ("reasoning", palette::LIGHT_REASONING),
322 ("success tint", palette::LIGHT_SUCCESS),
323 ("error tint", palette::LIGHT_ERROR),
324 ] {
325 for (foreground_name, foreground) in light_foregrounds {
326 assert_min_contrast(
327 &format!("light {foreground_name} on {background_name}"),
328 foreground,
329 background,
330 min_readable,
331 );
332 }
333 }
334 assert_min_contrast(
335 "light user row on raised",
336 palette::LIGHT_USER_BODY,
337 palette::LIGHT_ELEVATED,
338 min_readable,
339 );
340 assert_min_contrast(
341 "light work-surface success hover on raised",
342 palette::LIGHT_UI_THEME.success,
343 palette::LIGHT_UI_THEME.elevated_bg,
344 min_readable,
345 );
346 }
347
347 lines RUST