1use gpui::{Hsla, WindowAppearance};
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct GridTheme {
25 pub bg: Hsla,
26 pub header_bg: Hsla,
27 pub filter_bg: Hsla,
28 pub filter_active_bg: Hsla,
29 pub row_header_bg: Hsla,
30 pub selection_bg: Hsla,
31 pub alt_row_bg: Hsla,
32 pub grid_line: Hsla,
33 pub header_fg: Hsla,
34 pub text_fg: Hsla,
35 pub negative_fg: Hsla,
36 pub sort_indicator: Hsla,
37 pub filter_cursor: Hsla,
38 pub menu_bg: Hsla,
40 pub menu_hover_bg: Hsla,
42 pub menu_fg: Hsla,
44 pub muted_text: Hsla,
48 pub null_fg: Hsla,
51 pub null_bg: Hsla,
54 pub pivot_group_bg: Hsla,
56 pub pivot_subtotal_bg: Hsla,
59 pub pivot_grand_total_bg: Hsla,
61 pub pivot_total_fg: Hsla,
63 pub pivot_drop_zone_bg: Hsla,
65 pub pivot_drop_zone_active_bg: Hsla,
67 pub pivot_chip_bg: Hsla,
69 pub pivot_chip_fg: Hsla,
71 pub scrollbar_thumb: Hsla,
74 pub overlay_scrim: Hsla,
78}
79
80impl Default for GridTheme {
81 fn default() -> Self {
82 Self::neutral_light()
83 }
84}
85
86fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
87 Hsla { h, s, l, a }
88}
89
90#[derive(Clone, Debug, PartialEq)]
94pub struct GridThemePair {
95 pub light: GridTheme,
96 pub dark: GridTheme,
97}
98
99impl Default for GridThemePair {
100 fn default() -> Self {
101 Self::neutral()
102 }
103}
104
105impl GridThemePair {
106 #[must_use]
108 pub fn neutral() -> Self {
109 Self {
110 light: GridTheme::neutral_light(),
111 dark: GridTheme::neutral_dark(),
112 }
113 }
114
115 #[must_use]
118 pub fn signature() -> Self {
119 Self {
120 light: GridTheme::signature_light(),
121 dark: GridTheme::signature_dark(),
122 }
123 }
124
125 #[must_use]
136 pub fn component() -> Self {
137 Self {
138 light: GridTheme::from_component_colors(&gpui_component::ThemeColor::light(), false),
139 dark: GridTheme::from_component_colors(&gpui_component::ThemeColor::dark(), true),
140 }
141 }
142
143 #[must_use]
146 pub fn for_appearance(&self, appearance: WindowAppearance) -> GridTheme {
147 match appearance {
148 WindowAppearance::Dark | WindowAppearance::VibrantDark => self.dark.clone(),
149 WindowAppearance::Light | WindowAppearance::VibrantLight => self.light.clone(),
150 }
151 }
152}
153
154impl GridTheme {
155 #[must_use]
174 pub fn from_component_theme(theme: &gpui_component::Theme) -> Self {
175 Self::from_component_colors(&theme.colors, theme.is_dark())
176 }
177
178 #[must_use]
183 pub fn from_component_colors(colors: &gpui_component::ThemeColor, is_dark: bool) -> Self {
184 Self {
185 bg: colors.table,
186 header_bg: colors.table_head,
187 filter_bg: colors.table_head,
188 filter_active_bg: colors.accent,
189 row_header_bg: colors.table_head,
190 selection_bg: colors.selection,
191 alt_row_bg: colors.table_even,
192 grid_line: colors.table_row_border,
193 header_fg: colors.table_head_foreground,
194 text_fg: colors.foreground,
195 negative_fg: colors.danger,
196 sort_indicator: colors.primary,
197 filter_cursor: colors.caret,
198 menu_bg: colors.popover,
199 menu_hover_bg: colors.list_hover,
200 menu_fg: colors.popover_foreground,
201 muted_text: colors.muted_foreground,
202 null_fg: colors.muted_foreground,
203 null_bg: colors.muted,
204 pivot_group_bg: colors.accent,
205 pivot_subtotal_bg: colors.secondary,
206 pivot_grand_total_bg: colors.selection,
207 pivot_total_fg: colors.foreground,
208 pivot_drop_zone_bg: colors.muted,
209 pivot_drop_zone_active_bg: colors.drop_target,
210 pivot_chip_bg: colors.secondary,
211 pivot_chip_fg: colors.secondary_foreground,
212 scrollbar_thumb: colors.scrollbar_thumb,
213 overlay_scrim: if is_dark {
214 hsla(0.0, 0.0, 0.0, 0.45)
215 } else {
216 hsla(0.0, 0.0, 0.0, 0.35)
217 },
218 }
219 }
220
221 #[must_use]
224 pub fn neutral_light() -> Self {
225 Self {
226 bg: hsla(0.0, 0.0, 1.0, 1.0), header_bg: hsla(0.0, 0.0, 0.941, 1.0), filter_bg: hsla(0.0, 0.0, 0.941, 1.0), filter_active_bg: hsla(0.5778, 1.0, 0.8778, 1.0), row_header_bg: hsla(0.0, 0.0, 0.9215, 1.0), selection_bg: hsla(0.5786, 1.0, 0.8972, 1.0), alt_row_bg: hsla(0.0, 0.0, 0.9306, 1.0), grid_line: hsla(0.0, 0.0, 0.8634, 1.0), header_fg: hsla(0.0, 0.0, 0.1599, 1.0), text_fg: hsla(0.0, 0.0, 0.0476, 1.0), negative_fg: hsla(0.9991, 0.7103, 0.4152, 1.0), sort_indicator: hsla(0.5763, 1.0, 0.3224, 1.0), filter_cursor: hsla(0.0, 0.0, 0.0476, 1.0), menu_bg: hsla(0.0, 0.0, 1.0, 1.0), menu_hover_bg: hsla(0.5798, 1.0, 0.9166, 1.0), menu_fg: hsla(0.0, 0.0, 0.0476, 1.0), muted_text: hsla(0.0, 0.0, 0.3447, 1.0), null_fg: hsla(0.1215, 0.0996, 0.3291, 1.0), null_bg: hsla(0.1324, 0.7485, 0.9213, 1.0), pivot_group_bg: hsla(0.5863, 0.7823, 0.9366, 1.0), pivot_subtotal_bg: hsla(0.5862, 0.724, 0.9011, 1.0), pivot_grand_total_bg: hsla(0.5858, 0.7776, 0.8434, 1.0), pivot_total_fg: hsla(0.585, 0.4177, 0.0724, 1.0), pivot_drop_zone_bg: hsla(0.0, 0.0, 0.954, 1.0), pivot_drop_zone_active_bg: hsla(0.5756, 1.0, 0.9004, 1.0), pivot_chip_bg: hsla(0.5798, 1.0, 0.9166, 1.0), pivot_chip_fg: hsla(0.5844, 0.4223, 0.1374, 1.0), scrollbar_thumb: hsla(0.0, 0.0, 0.5021, 1.0), overlay_scrim: hsla(0.0, 0.0, 0.0, 0.35),
255 }
256 }
257
258 #[must_use]
261 pub fn neutral_dark() -> Self {
262 Self {
263 bg: hsla(0.0, 0.0, 0.0817, 1.0), header_bg: hsla(0.0, 0.0, 0.1409, 1.0), filter_bg: hsla(0.0, 0.0, 0.1409, 1.0), filter_active_bg: hsla(0.583, 0.4915, 0.2685, 1.0), row_header_bg: hsla(0.0, 0.0, 0.1268, 1.0), selection_bg: hsla(0.5823, 0.5446, 0.2618, 1.0), alt_row_bg: hsla(0.0, 0.0, 0.1362, 1.0), grid_line: hsla(0.0, 0.0, 0.2089, 1.0), header_fg: hsla(0.0, 0.0, 0.806, 1.0), text_fg: hsla(0.0, 0.0, 0.9085, 1.0), negative_fg: hsla(0.0076, 0.8612, 0.6827, 1.0), sort_indicator: hsla(0.5751, 0.8029, 0.6722, 1.0), filter_cursor: hsla(0.0, 0.0, 0.9085, 1.0), menu_bg: hsla(0.0, 0.0, 0.1315, 1.0), menu_hover_bg: hsla(0.5834, 0.4685, 0.2583, 1.0), menu_fg: hsla(0.0, 0.0, 0.9085, 1.0), muted_text: hsla(0.0, 0.0, 0.6326, 1.0), null_fg: hsla(0.132, 0.2549, 0.6858, 1.0), null_bg: hsla(0.1318, 0.3896, 0.1459, 1.0), pivot_group_bg: hsla(0.5853, 0.2639, 0.1806, 1.0), pivot_subtotal_bg: hsla(0.5847, 0.3421, 0.2171, 1.0), pivot_grand_total_bg: hsla(0.584, 0.3998, 0.2911, 1.0), pivot_total_fg: hsla(0.0, 0.0, 0.941, 1.0), pivot_drop_zone_bg: hsla(0.0, 0.0, 0.1268, 1.0), pivot_drop_zone_active_bg: hsla(0.5835, 0.4628, 0.2375, 1.0), pivot_chip_bg: hsla(0.584, 0.4202, 0.2379, 1.0), pivot_chip_fg: hsla(0.0, 0.0, 0.9345, 1.0), scrollbar_thumb: hsla(0.0, 0.0, 0.4447, 1.0), overlay_scrim: hsla(0.0, 0.0, 0.0, 0.45),
292 }
293 }
294
295 #[must_use]
299 pub fn signature_light() -> Self {
300 Self {
301 bg: hsla(0.0, 0.0, 1.0, 1.0), header_bg: hsla(0.4955, 0.2665, 0.917, 1.0), filter_bg: hsla(0.4955, 0.2665, 0.917, 1.0), filter_active_bg: hsla(0.4978, 0.5936, 0.7861, 1.0), row_header_bg: hsla(0.4956, 0.2469, 0.8956, 1.0), selection_bg: hsla(0.497, 0.5728, 0.8399, 1.0), alt_row_bg: hsla(0.4953, 0.1854, 0.9207, 1.0), grid_line: hsla(0.4957, 0.1778, 0.8359, 1.0), header_fg: hsla(0.5, 0.6775, 0.1233, 1.0), text_fg: hsla(0.4982, 0.397, 0.0491, 1.0), negative_fg: hsla(0.015, 0.7339, 0.4008, 1.0), sort_indicator: hsla(0.502, 1.0, 0.217, 1.0), filter_cursor: hsla(0.4982, 0.397, 0.0491, 1.0), menu_bg: hsla(0.0, 0.0, 1.0, 1.0), menu_hover_bg: hsla(0.4968, 0.5969, 0.8663, 1.0), menu_fg: hsla(0.4982, 0.397, 0.0491, 1.0), muted_text: hsla(0.4975, 0.1546, 0.3177, 1.0), null_fg: hsla(0.1119, 0.1325, 0.3271, 1.0), null_bg: hsla(0.1176, 0.7869, 0.9237, 1.0), pivot_group_bg: hsla(0.4962, 0.5099, 0.8971, 1.0), pivot_subtotal_bg: hsla(0.4969, 0.5164, 0.8345, 1.0), pivot_grand_total_bg: hsla(0.4983, 0.5319, 0.7204, 1.0), pivot_total_fg: hsla(0.5035, 1.0, 0.0689, 1.0), pivot_drop_zone_bg: hsla(0.4953, 0.2775, 0.9468, 1.0), pivot_drop_zone_active_bg: hsla(0.4974, 0.6519, 0.8275, 1.0), pivot_chip_bg: hsla(0.4973, 0.5837, 0.8185, 1.0), pivot_chip_fg: hsla(0.503, 1.0, 0.1024, 1.0), scrollbar_thumb: hsla(0.4975, 0.1513, 0.4634, 1.0), overlay_scrim: hsla(0.5019, 1.0, 0.0092, 0.35), }
331 }
332
333 #[must_use]
336 pub fn signature_dark() -> Self {
337 Self {
338 bg: hsla(0.4974, 0.2284, 0.0687, 1.0), header_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), filter_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), filter_active_bg: hsla(0.5016, 1.0, 0.1547, 1.0), row_header_bg: hsla(0.4976, 0.2111, 0.1053, 1.0), selection_bg: hsla(0.5016, 1.0, 0.1547, 1.0), alt_row_bg: hsla(0.4974, 0.1857, 0.1210, 1.0), grid_line: hsla(0.4975, 0.1743, 0.1906, 1.0), header_fg: hsla(0.4959, 0.1709, 0.7876, 1.0), text_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), negative_fg: hsla(0.0092, 0.8161, 0.6929, 1.0), sort_indicator: hsla(0.5, 0.5555, 0.5112, 1.0), filter_cursor: hsla(0.4953, 0.1483, 0.9013, 1.0), menu_bg: hsla(0.4975, 0.1954, 0.1145, 1.0), menu_hover_bg: hsla(0.5011, 1.0, 0.1462, 1.0), menu_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), muted_text: hsla(0.4965, 0.1256, 0.6067, 1.0), null_fg: hsla(0.1175, 0.3234, 0.6993, 1.0), null_bg: hsla(0.1183, 0.3763, 0.1477, 1.0), pivot_group_bg: hsla(0.4992, 0.4103, 0.1392, 1.0), pivot_subtotal_bg: hsla(0.5, 0.6646, 0.1444, 1.0), pivot_grand_total_bg: hsla(0.5005, 1.0, 0.164, 1.0), pivot_total_fg: hsla(0.4952, 0.1907, 0.9421, 1.0), pivot_drop_zone_bg: hsla(0.4975, 0.2029, 0.1099, 1.0), pivot_drop_zone_active_bg: hsla(0.5009, 1.0, 0.1335, 1.0), pivot_chip_bg: hsla(0.5013, 1.0, 0.1383, 1.0), pivot_chip_fg: hsla(0.4954, 0.2469, 0.9254, 1.0), scrollbar_thumb: hsla(0.4967, 0.0942, 0.4236, 1.0), overlay_scrim: hsla(0.5019, 1.0, 0.0011, 0.45), }
368 }
369
370 #[must_use]
374 pub fn light() -> Self {
375 Self::neutral_light()
376 }
377
378 #[must_use]
380 pub fn dark() -> Self {
381 Self::neutral_dark()
382 }
383
384 #[must_use]
390 pub fn for_appearance(appearance: WindowAppearance) -> Self {
391 GridThemePair::neutral().for_appearance(appearance)
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn all_palettes() -> [(&'static str, GridTheme); 4] {
400 [
401 ("neutral_light", GridTheme::neutral_light()),
402 ("neutral_dark", GridTheme::neutral_dark()),
403 ("signature_light", GridTheme::signature_light()),
404 ("signature_dark", GridTheme::signature_dark()),
405 ]
406 }
407
408 #[test]
415 fn every_palette_exposes_distinct_menu_colors() {
416 for (name, t) in all_palettes() {
417 assert_eq!(t.menu_bg.a, 1.0, "{name}: menu background must be opaque");
419 assert_ne!(
420 t.menu_hover_bg, t.menu_bg,
421 "{name}: menu hover fill must differ from the menu background"
422 );
423 assert_ne!(
424 t.menu_fg, t.menu_bg,
425 "{name}: menu label color must contrast with the menu background"
426 );
427 }
428 }
429
430 #[test]
435 fn aliases_and_dark_variants_hold() {
436 assert_eq!(
437 GridTheme::light(),
438 GridTheme::default(),
439 "light() must alias the default palette"
440 );
441 assert_eq!(GridTheme::light(), GridTheme::neutral_light());
442 assert_eq!(GridTheme::dark(), GridTheme::neutral_dark());
443 for (name, t) in [
444 ("neutral_dark", GridTheme::neutral_dark()),
445 ("signature_dark", GridTheme::signature_dark()),
446 ] {
447 assert!(
448 t.bg.l < t.text_fg.l,
449 "{name} must be light text on a dark surface"
450 );
451 }
452 for (name, t) in all_palettes() {
453 assert_eq!(t.bg.a, 1.0, "{name}: grid background must be opaque");
454 assert_eq!(t.selection_bg.a, 1.0, "{name}: selection must be opaque");
455 }
456 }
457
458 #[test]
463 fn pivot_surfaces_are_distinct_in_every_palette() {
464 for (name, t) in all_palettes() {
465 assert_ne!(t.pivot_grand_total_bg, t.pivot_group_bg, "{name}");
466 assert_ne!(t.pivot_grand_total_bg, t.pivot_subtotal_bg, "{name}");
467 assert_ne!(t.pivot_total_fg, t.pivot_grand_total_bg, "{name}");
468 assert_ne!(t.pivot_drop_zone_active_bg, t.pivot_drop_zone_bg, "{name}");
469 assert_ne!(t.pivot_chip_fg, t.pivot_chip_bg, "{name}");
470 }
471 }
472
473 #[test]
477 fn for_appearance_maps_dark_and_light_variants() {
478 assert_eq!(
479 GridTheme::for_appearance(WindowAppearance::Dark).bg,
480 GridTheme::dark().bg
481 );
482 assert_eq!(
483 GridTheme::for_appearance(WindowAppearance::VibrantDark).bg,
484 GridTheme::dark().bg
485 );
486 assert_eq!(
487 GridTheme::for_appearance(WindowAppearance::Light).bg,
488 GridTheme::light().bg
489 );
490 assert_eq!(
491 GridTheme::for_appearance(WindowAppearance::VibrantLight).bg,
492 GridTheme::light().bg
493 );
494 let sig = GridThemePair::signature();
495 assert_eq!(
496 sig.for_appearance(WindowAppearance::Dark).bg,
497 GridTheme::signature_dark().bg
498 );
499 assert_eq!(
500 sig.for_appearance(WindowAppearance::VibrantLight).bg,
501 GridTheme::signature_light().bg
502 );
503 }
504
505 #[test]
509 fn component_bridge_maps_toolkit_roles_per_mode() {
510 let light_colors = gpui_component::ThemeColor::light();
511 let dark_colors = gpui_component::ThemeColor::dark();
512
513 let light = GridTheme::from_component_colors(&light_colors, false);
514 let dark = GridTheme::from_component_colors(&dark_colors, true);
515
516 assert_eq!(light.bg, light_colors.table);
518 assert_eq!(light.header_bg, light_colors.table_head);
519 assert_eq!(light.selection_bg, light_colors.selection);
520 assert_eq!(light.menu_bg, light_colors.popover);
521 assert_eq!(light.negative_fg, light_colors.danger);
522 assert_eq!(dark.bg, dark_colors.table);
523
524 assert_ne!(light.bg, dark.bg);
526 assert_ne!(light.text_fg, dark.text_fg);
527 assert!(dark.overlay_scrim.a > light.overlay_scrim.a);
529
530 let pair = GridThemePair::component();
532 assert_eq!(pair.light, light);
533 assert_eq!(pair.dark, dark);
534 }
535}
536
537#[cfg(test)]
542mod wcag {
543 use super::*;
544
545 fn relative_luminance(c: Hsla) -> f32 {
549 let (h, s, l) = (c.h, c.s, c.l);
551 let q = if l < 0.5 {
552 l * (1.0 + s)
553 } else {
554 l + s - l * s
555 };
556 let p = 2.0 * l - q;
557 let hue = |mut t: f32| -> f32 {
558 if t < 0.0 {
559 t += 1.0;
560 }
561 if t > 1.0 {
562 t -= 1.0;
563 }
564 if t < 1.0 / 6.0 {
565 p + (q - p) * 6.0 * t
566 } else if t < 0.5 {
567 q
568 } else if t < 2.0 / 3.0 {
569 p + (q - p) * (2.0 / 3.0 - t) * 6.0
570 } else {
571 p
572 }
573 };
574 let (r, g, b) = (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0));
575 let lin = |u: f32| -> f32 {
577 if u <= 0.04045 {
578 u / 12.92
579 } else {
580 ((u + 0.055) / 1.055).powf(2.4)
581 }
582 };
583 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
584 }
585
586 fn contrast(a: Hsla, b: Hsla) -> f32 {
587 let (la, lb) = (relative_luminance(a), relative_luminance(b));
588 let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
589 (hi + 0.05) / (lo + 0.05)
590 }
591
592 fn requirements(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
594 vec![
595 (t.text_fg, t.bg, 7.0, "body text on bg"),
596 (t.text_fg, t.alt_row_bg, 7.0, "body text on zebra row"),
597 (t.text_fg, t.selection_bg, 4.5, "body text on selection"),
598 (t.text_fg, t.row_header_bg, 4.5, "pivot leaf label"),
599 (t.text_fg, t.header_bg, 4.5, "status bar text"),
600 (t.text_fg, t.filter_active_bg, 4.5, "filter text"),
601 (t.header_fg, t.header_bg, 4.5, "column labels"),
602 (t.header_fg, t.row_header_bg, 4.5, "row numbers"),
603 (t.header_fg, t.selection_bg, 4.5, "selected header label"),
604 (
605 t.header_fg,
606 t.pivot_grand_total_bg,
607 4.5,
608 "pivot header on total bg",
609 ),
610 (t.muted_text, t.bg, 4.5, "placeholder text on bg"),
611 (t.muted_text, t.menu_bg, 4.5, "menu secondary text"),
612 (t.muted_text, t.header_bg, 4.5, "sidebar header hint"),
613 (t.muted_text, t.pivot_drop_zone_bg, 4.5, "drop zone hint"),
614 (t.negative_fg, t.bg, 4.5, "negative numbers on bg"),
615 (
616 t.negative_fg,
617 t.alt_row_bg,
618 4.5,
619 "negative numbers on zebra",
620 ),
621 (
622 t.negative_fg,
623 t.selection_bg,
624 3.0,
625 "negative on selection (parentheses carry the channel too)",
626 ),
627 (t.negative_fg, t.pivot_grand_total_bg, 3.0, "negative total"),
628 (t.menu_fg, t.menu_bg, 7.0, "menu labels"),
629 (t.menu_fg, t.menu_hover_bg, 4.5, "hovered menu label"),
630 (t.menu_fg, t.header_bg, 4.5, "source field chip label"),
631 (
632 t.menu_fg,
633 t.pivot_drop_zone_bg,
634 4.5,
635 "pivot menu on zone bg",
636 ),
637 (t.null_fg, t.bg, 4.5, "null placeholder on bg"),
638 (t.null_fg, t.null_bg, 4.5, "null placeholder on null bg"),
639 (t.null_fg, t.alt_row_bg, 4.5, "null placeholder on zebra"),
640 (
641 t.pivot_total_fg,
642 t.pivot_group_bg,
643 7.0,
644 "group header label",
645 ),
646 (
647 t.pivot_total_fg,
648 t.pivot_subtotal_bg,
649 4.5,
650 "subtotal values",
651 ),
652 (
653 t.pivot_total_fg,
654 t.pivot_grand_total_bg,
655 4.5,
656 "grand total values",
657 ),
658 (t.pivot_chip_fg, t.pivot_chip_bg, 4.5, "chip labels"),
659 (t.bg, t.sort_indicator, 3.0, "checkbox knockout check"),
660 (t.sort_indicator, t.header_bg, 3.0, "sort glyph"),
661 (t.sort_indicator, t.menu_bg, 4.5, "sidebar sort glyph"),
662 (t.sort_indicator, t.bg, 3.0, "grouped-column underline"),
663 (t.filter_cursor, t.filter_active_bg, 4.5, "filter cursor"),
664 (t.scrollbar_thumb, t.row_header_bg, 3.0, "thumb vs track"),
665 ]
666 }
667
668 fn distinctness(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
670 vec![
671 (t.selection_bg, t.bg, 1.1, "selection visible on bg"),
672 (t.alt_row_bg, t.bg, 1.12, "zebra perceptible"),
676 (t.menu_hover_bg, t.menu_bg, 1.1, "menu hover visible"),
677 (
678 t.pivot_drop_zone_active_bg,
679 t.pivot_drop_zone_bg,
680 1.1,
681 "drop-zone hover visible",
682 ),
683 (t.grid_line, t.bg, 1.1, "grid lines visible"),
684 (
685 t.pivot_grand_total_bg,
686 t.pivot_subtotal_bg,
687 1.05,
688 "totals hierarchy readable",
689 ),
690 ]
691 }
692
693 #[test]
694 fn every_palette_meets_wcag_contrast() {
695 for (name, theme) in [
696 ("neutral_light", GridTheme::neutral_light()),
697 ("neutral_dark", GridTheme::neutral_dark()),
698 ("signature_light", GridTheme::signature_light()),
699 ("signature_dark", GridTheme::signature_dark()),
700 ] {
701 for (fg, bg, min, what) in requirements(&theme).into_iter().chain(distinctness(&theme))
702 {
703 let ratio = contrast(fg, bg);
704 assert!(
705 ratio >= min,
706 "{name}: {what} — contrast {ratio:.2} below required {min}"
707 );
708 }
709 }
710 }
711}