Skip to main content

sqlly_datatable/grid/
theme.rs

1//! `GridTheme` — typed color set used by the widget, plus the two shipped
2//! theme families.
3//!
4//! Two complete families ship with the crate, each with a light and a dark
5//! variant that follows the OS window appearance:
6//!
7//! - **Neutral** ([`GridThemePair::neutral`]) — chroma-free surfaces with a
8//!   restrained azure accent. Blends into a host application; the default.
9//! - **Signature** ([`GridThemePair::signature`]) — the crate's own look,
10//!   built around a teal anchor (`oklch(0.47 0.115 195)`): tinted neutrals and
11//!   a committed accent carrying selection, chips, and totals.
12//!
13//! Every color is a public field, so downstream code can construct a fully
14//! custom theme (or derive one from a host app's palette) and pass it on the
15//! [`crate::grid::GridState`] or via the widget builder. All shipped palettes
16//! are designed in OKLCH (noted per field) and converted to `Hsla`; text
17//! roles meet WCAG AA contrast (≥ 4.5:1) against every surface they are
18//! painted on — see the `wcag` test module at the bottom of this file, which
19//! verifies the full matrix for all four palettes.
20
21use 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    /// Background fill of the right-click context menu / filter popup surface.
39    pub menu_bg: Hsla,
40    /// Fill drawn behind the menu item currently under the pointer (hover).
41    pub menu_hover_bg: Hsla,
42    /// Foreground color for menu item labels.
43    pub menu_fg: Hsla,
44    /// Muted text color for labels, placeholders, and secondary text inside
45    /// the filter panel and context menu. Chosen for legibility against
46    /// `menu_bg` / `bg` in both light and dark palettes.
47    pub muted_text: Hsla,
48    /// Foreground for the null-value placeholder (see
49    /// [`crate::config::NullFormat`]).
50    pub null_fg: Hsla,
51    /// Distinctive background painted behind null-value cells when the
52    /// column's [`crate::config::NullFormat::background`] is enabled.
53    pub null_bg: Hsla,
54    /// Background of pivot group-header rows (expanded groups).
55    pub pivot_group_bg: Hsla,
56    /// Background of pivot subtotal cells (collapsed groups, "Total"
57    /// columns).
58    pub pivot_subtotal_bg: Hsla,
59    /// Background of the pivot grand-total row/column.
60    pub pivot_grand_total_bg: Hsla,
61    /// Foreground for pivot subtotal / grand-total values and labels.
62    pub pivot_total_fg: Hsla,
63    /// Resting background of a sidebar drop zone.
64    pub pivot_drop_zone_bg: Hsla,
65    /// Background of a drop zone while a compatible chip hovers over it.
66    pub pivot_drop_zone_active_bg: Hsla,
67    /// Background of a field chip in the pivot sidebar.
68    pub pivot_chip_bg: Hsla,
69    /// Label color of a field chip in the pivot sidebar.
70    pub pivot_chip_fg: Hsla,
71    /// Fill of the scrollbar thumb (the track uses `row_header_bg`). Kept at
72    /// ≥ 3:1 contrast against the track in the shipped palettes.
73    pub scrollbar_thumb: Hsla,
74    /// Translucent scrim painted behind modal overlays (e.g. the pivot
75    /// format dialog) to dim the content beneath. The only intentionally
76    /// non-opaque color in the theme.
77    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/// A light/dark pair forming one theme family. [`GridThemePair::for_appearance`]
91/// picks the variant matching the OS window appearance, so a host app can
92/// supply its own pair and keep automatic light/dark following.
93#[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    /// The Neutral family: chroma-free surfaces, one restrained azure accent.
107    #[must_use]
108    pub fn neutral() -> Self {
109        Self {
110            light: GridTheme::neutral_light(),
111            dark: GridTheme::neutral_dark(),
112        }
113    }
114
115    /// The Signature family: teal-anchored tinted neutrals with a committed
116    /// accent.
117    #[must_use]
118    pub fn signature() -> Self {
119        Self {
120            light: GridTheme::signature_light(),
121            dark: GridTheme::signature_dark(),
122        }
123    }
124
125    /// Pick the variant that matches the OS window appearance. `Dark` and
126    /// `VibrantDark` resolve to `self.dark`; everything else to `self.light`.
127    #[must_use]
128    pub fn for_appearance(&self, appearance: WindowAppearance) -> GridTheme {
129        match appearance {
130            WindowAppearance::Dark | WindowAppearance::VibrantDark => self.dark.clone(),
131            WindowAppearance::Light | WindowAppearance::VibrantLight => self.light.clone(),
132        }
133    }
134}
135
136impl GridTheme {
137    /// Neutral light: pure-white canvas, gray ramp at zero chroma, azure
138    /// accent reserved for selection, sort, and filter state.
139    #[must_use]
140    pub fn neutral_light() -> Self {
141        Self {
142            bg: hsla(0.0, 0.0, 1.0, 1.0),                            // oklch(1 0 0)
143            header_bg: hsla(0.0, 0.0, 0.941, 1.0),                   // oklch(0.955 0 0)
144            filter_bg: hsla(0.0, 0.0, 0.941, 1.0),                   // oklch(0.955 0 0)
145            filter_active_bg: hsla(0.5778, 1.0, 0.8778, 1.0),        // oklch(0.90 0.06 250)
146            row_header_bg: hsla(0.0, 0.0, 0.9215, 1.0),              // oklch(0.94 0 0)
147            selection_bg: hsla(0.5786, 1.0, 0.8972, 1.0),            // oklch(0.915 0.05 250)
148            alt_row_bg: hsla(0.0, 0.0, 0.9632, 1.0),                 // oklch(0.972 0 0)
149            grid_line: hsla(0.0, 0.0, 0.8634, 1.0),                  // oklch(0.895 0 0)
150            header_fg: hsla(0.0, 0.0, 0.1599, 1.0),                  // oklch(0.28 0 0)
151            text_fg: hsla(0.0, 0.0, 0.0476, 1.0),                    // oklch(0.155 0 0)
152            negative_fg: hsla(0.9991, 0.7103, 0.4152, 1.0),          // oklch(0.50 0.185 27)
153            sort_indicator: hsla(0.5763, 1.0, 0.3224, 1.0),          // oklch(0.46 0.145 250)
154            filter_cursor: hsla(0.0, 0.0, 0.0476, 1.0),              // oklch(0.155 0 0)
155            menu_bg: hsla(0.0, 0.0, 1.0, 1.0),                       // oklch(1 0 0)
156            menu_hover_bg: hsla(0.5798, 1.0, 0.9166, 1.0),           // oklch(0.93 0.04 250)
157            menu_fg: hsla(0.0, 0.0, 0.0476, 1.0),                    // oklch(0.155 0 0)
158            muted_text: hsla(0.0, 0.0, 0.3447, 1.0),                 // oklch(0.46 0 0)
159            null_fg: hsla(0.1215, 0.0996, 0.3291, 1.0),              // oklch(0.46 0.02 90)
160            null_bg: hsla(0.1324, 0.7485, 0.9213, 1.0),              // oklch(0.965 0.032 95)
161            pivot_group_bg: hsla(0.5863, 0.7823, 0.9366, 1.0),       // oklch(0.945 0.022 250)
162            pivot_subtotal_bg: hsla(0.5862, 0.724, 0.9011, 1.0),     // oklch(0.915 0.032 250)
163            pivot_grand_total_bg: hsla(0.5858, 0.7776, 0.8434, 1.0), // oklch(0.865 0.055 250)
164            pivot_total_fg: hsla(0.585, 0.4177, 0.0724, 1.0),        // oklch(0.18 0.02 250)
165            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.954, 1.0),          // oklch(0.965 0 0)
166            pivot_drop_zone_active_bg: hsla(0.5756, 1.0, 0.9004, 1.0), // oklch(0.92 0.05 250)
167            pivot_chip_bg: hsla(0.5798, 1.0, 0.9166, 1.0),           // oklch(0.93 0.04 250)
168            pivot_chip_fg: hsla(0.5844, 0.4223, 0.1374, 1.0),        // oklch(0.25 0.035 250)
169            scrollbar_thumb: hsla(0.0, 0.0, 0.5021, 1.0),            // oklch(0.60 0 0)
170            overlay_scrim: hsla(0.0, 0.0, 0.0, 0.35),
171        }
172    }
173
174    /// Neutral dark: near-black gray ramp; depth comes from surface
175    /// lightness, accents are slightly desaturated to sit on dark.
176    #[must_use]
177    pub fn neutral_dark() -> Self {
178        Self {
179            bg: hsla(0.0, 0.0, 0.0817, 1.0),        // oklch(0.195 0 0)
180            header_bg: hsla(0.0, 0.0, 0.1409, 1.0), // oklch(0.26 0 0)
181            filter_bg: hsla(0.0, 0.0, 0.1409, 1.0), // oklch(0.26 0 0)
182            filter_active_bg: hsla(0.583, 0.4915, 0.2685, 1.0), // oklch(0.38 0.07 250)
183            row_header_bg: hsla(0.0, 0.0, 0.1268, 1.0), // oklch(0.245 0 0)
184            selection_bg: hsla(0.5823, 0.5446, 0.2618, 1.0), // oklch(0.375 0.075 250)
185            alt_row_bg: hsla(0.0, 0.0, 0.1085, 1.0), // oklch(0.225 0 0)
186            grid_line: hsla(0.0, 0.0, 0.2089, 1.0), // oklch(0.33 0 0)
187            header_fg: hsla(0.0, 0.0, 0.806, 1.0),  // oklch(0.85 0 0)
188            text_fg: hsla(0.0, 0.0, 0.9085, 1.0),   // oklch(0.93 0 0)
189            negative_fg: hsla(0.0076, 0.8612, 0.6827, 1.0), // oklch(0.70 0.165 25)
190            sort_indicator: hsla(0.5751, 0.8029, 0.6722, 1.0), // oklch(0.74 0.115 245)
191            filter_cursor: hsla(0.0, 0.0, 0.9085, 1.0), // oklch(0.93 0 0)
192            menu_bg: hsla(0.0, 0.0, 0.1315, 1.0),   // oklch(0.25 0 0)
193            menu_hover_bg: hsla(0.5834, 0.4685, 0.2583, 1.0), // oklch(0.37 0.065 250)
194            menu_fg: hsla(0.0, 0.0, 0.9085, 1.0),   // oklch(0.93 0 0)
195            muted_text: hsla(0.0, 0.0, 0.6326, 1.0), // oklch(0.71 0 0)
196            null_fg: hsla(0.132, 0.2549, 0.6858, 1.0), // oklch(0.79 0.045 95)
197            null_bg: hsla(0.1318, 0.3896, 0.1459, 1.0), // oklch(0.30 0.038 95)
198            pivot_group_bg: hsla(0.5853, 0.2639, 0.1806, 1.0), // oklch(0.295 0.028 250)
199            pivot_subtotal_bg: hsla(0.5847, 0.3421, 0.2171, 1.0), // oklch(0.33 0.042 250)
200            pivot_grand_total_bg: hsla(0.584, 0.3998, 0.2911, 1.0), // oklch(0.40 0.062 250)
201            pivot_total_fg: hsla(0.0, 0.0, 0.941, 1.0), // oklch(0.955 0 0)
202            pivot_drop_zone_bg: hsla(0.0, 0.0, 0.1268, 1.0), // oklch(0.245 0 0)
203            pivot_drop_zone_active_bg: hsla(0.5835, 0.4628, 0.2375, 1.0), // oklch(0.35 0.06 250)
204            pivot_chip_bg: hsla(0.584, 0.4202, 0.2379, 1.0), // oklch(0.35 0.055 250)
205            pivot_chip_fg: hsla(0.0, 0.0, 0.9345, 1.0), // oklch(0.95 0 0)
206            scrollbar_thumb: hsla(0.0, 0.0, 0.4447, 1.0), // oklch(0.55 0 0)
207            overlay_scrim: hsla(0.0, 0.0, 0.0, 0.45),
208        }
209    }
210
211    /// Signature light: pure-white canvas with teal-tinted neutrals; the
212    /// teal anchor (`oklch(0.47 0.115 195)` family) carries selection,
213    /// chips, and the totals hierarchy.
214    #[must_use]
215    pub fn signature_light() -> Self {
216        Self {
217            bg: hsla(0.0, 0.0, 1.0, 1.0),                            // oklch(1 0 0)
218            header_bg: hsla(0.4955, 0.2665, 0.917, 1.0),             // oklch(0.945 0.012 195)
219            filter_bg: hsla(0.4955, 0.2665, 0.917, 1.0),             // oklch(0.945 0.012 195)
220            filter_active_bg: hsla(0.4978, 0.5936, 0.7861, 1.0),     // oklch(0.89 0.065 195)
221            row_header_bg: hsla(0.4956, 0.2469, 0.8956, 1.0),        // oklch(0.93 0.014 195)
222            selection_bg: hsla(0.497, 0.5728, 0.8399, 1.0),          // oklch(0.915 0.048 195)
223            alt_row_bg: hsla(0.4952, 0.2997, 0.9568, 1.0),           // oklch(0.972 0.007 195)
224            grid_line: hsla(0.4957, 0.1778, 0.8359, 1.0),            // oklch(0.885 0.016 195)
225            header_fg: hsla(0.5, 0.6775, 0.1233, 1.0),               // oklch(0.30 0.045 195)
226            text_fg: hsla(0.4982, 0.397, 0.0491, 1.0),               // oklch(0.17 0.015 195)
227            negative_fg: hsla(0.015, 0.7339, 0.4008, 1.0),           // oklch(0.50 0.175 30)
228            sort_indicator: hsla(0.502, 1.0, 0.217, 1.0),            // oklch(0.47 0.115 195)
229            filter_cursor: hsla(0.4982, 0.397, 0.0491, 1.0),         // oklch(0.17 0.015 195)
230            menu_bg: hsla(0.0, 0.0, 1.0, 1.0),                       // oklch(1 0 0)
231            menu_hover_bg: hsla(0.4968, 0.5969, 0.8663, 1.0),        // oklch(0.93 0.042 195)
232            menu_fg: hsla(0.4982, 0.397, 0.0491, 1.0),               // oklch(0.17 0.015 195)
233            muted_text: hsla(0.4975, 0.1546, 0.3177, 1.0),           // oklch(0.46 0.03 195)
234            null_fg: hsla(0.1119, 0.1325, 0.3271, 1.0),              // oklch(0.46 0.025 85)
235            null_bg: hsla(0.1176, 0.7869, 0.9237, 1.0),              // oklch(0.962 0.03 88)
236            pivot_group_bg: hsla(0.4962, 0.5099, 0.8971, 1.0),       // oklch(0.942 0.028 195)
237            pivot_subtotal_bg: hsla(0.4969, 0.5164, 0.8345, 1.0),    // oklch(0.908 0.045 195)
238            pivot_grand_total_bg: hsla(0.4983, 0.5319, 0.7204, 1.0), // oklch(0.85 0.075 195)
239            pivot_total_fg: hsla(0.5035, 1.0, 0.0689, 1.0),          // oklch(0.22 0.06 195)
240            pivot_drop_zone_bg: hsla(0.4953, 0.2775, 0.9468, 1.0),   // oklch(0.965 0.008 195)
241            pivot_drop_zone_active_bg: hsla(0.4974, 0.6519, 0.8275, 1.0), // oklch(0.915 0.058 195)
242            pivot_chip_bg: hsla(0.4973, 0.5837, 0.8185, 1.0),        // oklch(0.905 0.055 195)
243            pivot_chip_fg: hsla(0.503, 1.0, 0.1024, 1.0),            // oklch(0.28 0.075 195)
244            scrollbar_thumb: hsla(0.4975, 0.1513, 0.4634, 1.0),      // oklch(0.60 0.04 195)
245            overlay_scrim: hsla(0.5019, 1.0, 0.0092, 0.35),          // oklch(0.10 0.02 195)
246        }
247    }
248
249    /// Signature dark: teal-tinted near-black; surfaces keep the anchor hue
250    /// at whisper chroma, and the accent brightens to hold on dark.
251    #[must_use]
252    pub fn signature_dark() -> Self {
253        Self {
254            bg: hsla(0.4974, 0.2284, 0.0687, 1.0), // oklch(0.19 0.012 195)
255            header_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), // oklch(0.255 0.018 195)
256            filter_bg: hsla(0.4977, 0.2102, 0.1219, 1.0), // oklch(0.255 0.018 195)
257            filter_active_bg: hsla(0.5016, 1.0, 0.1547, 1.0), // oklch(0.375 0.085 195)
258            row_header_bg: hsla(0.4976, 0.2111, 0.1053, 1.0), // oklch(0.235 0.016 195)
259            selection_bg: hsla(0.5016, 1.0, 0.1547, 1.0), // oklch(0.375 0.085 195)
260            alt_row_bg: hsla(0.4975, 0.207, 0.0917, 1.0), // oklch(0.218 0.014 195)
261            grid_line: hsla(0.4975, 0.1743, 0.1906, 1.0), // oklch(0.33 0.022 195)
262            header_fg: hsla(0.4959, 0.1709, 0.7876, 1.0), // oklch(0.85 0.02 195)
263            text_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
264            negative_fg: hsla(0.0092, 0.8161, 0.6929, 1.0), // oklch(0.71 0.15 25)
265            sort_indicator: hsla(0.5, 0.5555, 0.5112, 1.0), // oklch(0.76 0.115 195)
266            filter_cursor: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
267            menu_bg: hsla(0.4975, 0.1954, 0.1145, 1.0), // oklch(0.245 0.016 195)
268            menu_hover_bg: hsla(0.5011, 1.0, 0.1462, 1.0), // oklch(0.365 0.075 195)
269            menu_fg: hsla(0.4953, 0.1483, 0.9013, 1.0), // oklch(0.93 0.008 195)
270            muted_text: hsla(0.4965, 0.1256, 0.6067, 1.0), // oklch(0.71 0.028 195)
271            null_fg: hsla(0.1175, 0.3234, 0.6993, 1.0), // oklch(0.80 0.05 88)
272            null_bg: hsla(0.1183, 0.3763, 0.1477, 1.0), // oklch(0.295 0.035 88)
273            pivot_group_bg: hsla(0.4992, 0.4103, 0.1392, 1.0), // oklch(0.295 0.035 195)
274            pivot_subtotal_bg: hsla(0.5, 0.6646, 0.1444, 1.0), // oklch(0.33 0.05 195)
275            pivot_grand_total_bg: hsla(0.5005, 1.0, 0.164, 1.0), // oklch(0.40 0.072 195)
276            pivot_total_fg: hsla(0.4952, 0.1907, 0.9421, 1.0), // oklch(0.96 0.006 195)
277            pivot_drop_zone_bg: hsla(0.4975, 0.2029, 0.1099, 1.0), // oklch(0.24 0.016 195)
278            pivot_drop_zone_active_bg: hsla(0.5009, 1.0, 0.1335, 1.0), // oklch(0.345 0.068 195)
279            pivot_chip_bg: hsla(0.5013, 1.0, 0.1383, 1.0), // oklch(0.35 0.075 195)
280            pivot_chip_fg: hsla(0.4954, 0.2469, 0.9254, 1.0), // oklch(0.95 0.01 195)
281            scrollbar_thumb: hsla(0.4967, 0.0942, 0.4236, 1.0), // oklch(0.55 0.024 195)
282            overlay_scrim: hsla(0.5019, 1.0, 0.0011, 0.45), // oklch(0.05 0.01 195)
283        }
284    }
285
286    /// The Neutral light palette. Identical to [`GridTheme::default`];
287    /// provided as a named constructor so callers can be explicit about
288    /// intent.
289    #[must_use]
290    pub fn light() -> Self {
291        Self::neutral_light()
292    }
293
294    /// The Neutral dark palette, tuned to pair with [`GridTheme::light`].
295    #[must_use]
296    pub fn dark() -> Self {
297        Self::neutral_dark()
298    }
299
300    /// Pick the Neutral-family palette that matches the OS window
301    /// appearance. `Dark` and `VibrantDark` resolve to
302    /// [`GridTheme::neutral_dark`]; everything else to
303    /// [`GridTheme::neutral_light`]. For other families use
304    /// [`GridThemePair::for_appearance`].
305    #[must_use]
306    pub fn for_appearance(appearance: WindowAppearance) -> Self {
307        GridThemePair::neutral().for_appearance(appearance)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn all_palettes() -> [(&'static str, GridTheme); 4] {
316        [
317            ("neutral_light", GridTheme::neutral_light()),
318            ("neutral_dark", GridTheme::neutral_dark()),
319            ("signature_light", GridTheme::signature_light()),
320            ("signature_dark", GridTheme::signature_dark()),
321        ]
322    }
323
324    /// The context menu must be paintable from the theme (not a hardcoded
325    /// color), and its hover fill must be visually distinct from the menu
326    /// background so a mouse-over state is actually perceivable. The label
327    /// color must also contrast with the background. This guards the
328    /// dark/light theming + hover-state regression, for every shipped
329    /// palette.
330    #[test]
331    fn every_palette_exposes_distinct_menu_colors() {
332        for (name, t) in all_palettes() {
333            // Menu surface must be opaque so it fully covers content beneath.
334            assert_eq!(t.menu_bg.a, 1.0, "{name}: menu background must be opaque");
335            assert_ne!(
336                t.menu_hover_bg, t.menu_bg,
337                "{name}: menu hover fill must differ from the menu background"
338            );
339            assert_ne!(
340                t.menu_fg, t.menu_bg,
341                "{name}: menu label color must contrast with the menu background"
342            );
343        }
344    }
345
346    /// `light()`/`default()` must equal the Neutral light palette, `dark()`
347    /// the Neutral dark palette, and dark variants must be genuinely dark
348    /// (light text on a dark surface). This guards the OS light/dark
349    /// following and the back-compat aliases.
350    #[test]
351    fn aliases_and_dark_variants_hold() {
352        assert_eq!(
353            GridTheme::light(),
354            GridTheme::default(),
355            "light() must alias the default palette"
356        );
357        assert_eq!(GridTheme::light(), GridTheme::neutral_light());
358        assert_eq!(GridTheme::dark(), GridTheme::neutral_dark());
359        for (name, t) in [
360            ("neutral_dark", GridTheme::neutral_dark()),
361            ("signature_dark", GridTheme::signature_dark()),
362        ] {
363            assert!(
364                t.bg.l < t.text_fg.l,
365                "{name} must be light text on a dark surface"
366            );
367        }
368        for (name, t) in all_palettes() {
369            assert_eq!(t.bg.a, 1.0, "{name}: grid background must be opaque");
370            assert_eq!(t.selection_bg.a, 1.0, "{name}: selection must be opaque");
371        }
372    }
373
374    /// Pivot surfaces must be mutually distinguishable and legible in every
375    /// palette: totals must stand out from ordinary groups, drop-zone hover
376    /// must differ from its resting state, and total text must contrast
377    /// with the total background.
378    #[test]
379    fn pivot_surfaces_are_distinct_in_every_palette() {
380        for (name, t) in all_palettes() {
381            assert_ne!(t.pivot_grand_total_bg, t.pivot_group_bg, "{name}");
382            assert_ne!(t.pivot_grand_total_bg, t.pivot_subtotal_bg, "{name}");
383            assert_ne!(t.pivot_total_fg, t.pivot_grand_total_bg, "{name}");
384            assert_ne!(t.pivot_drop_zone_active_bg, t.pivot_drop_zone_bg, "{name}");
385            assert_ne!(t.pivot_chip_fg, t.pivot_chip_bg, "{name}");
386        }
387    }
388
389    /// `for_appearance` must map the two dark variants to the dark palette
390    /// and the two light variants to the light palette, on both the static
391    /// Neutral helper and an arbitrary pair.
392    #[test]
393    fn for_appearance_maps_dark_and_light_variants() {
394        assert_eq!(
395            GridTheme::for_appearance(WindowAppearance::Dark).bg,
396            GridTheme::dark().bg
397        );
398        assert_eq!(
399            GridTheme::for_appearance(WindowAppearance::VibrantDark).bg,
400            GridTheme::dark().bg
401        );
402        assert_eq!(
403            GridTheme::for_appearance(WindowAppearance::Light).bg,
404            GridTheme::light().bg
405        );
406        assert_eq!(
407            GridTheme::for_appearance(WindowAppearance::VibrantLight).bg,
408            GridTheme::light().bg
409        );
410        let sig = GridThemePair::signature();
411        assert_eq!(
412            sig.for_appearance(WindowAppearance::Dark).bg,
413            GridTheme::signature_dark().bg
414        );
415        assert_eq!(
416            sig.for_appearance(WindowAppearance::VibrantLight).bg,
417            GridTheme::signature_light().bg
418        );
419    }
420}
421
422/// WCAG contrast verification for the shipped palettes. Every text role is
423/// checked against every surface it is actually painted on in `paint.rs` /
424/// `sidebar.rs` / `widget.rs`, at AA thresholds (4.5:1 for text, 3:1 for
425/// UI indicators), plus perceivable-difference floors for state fills.
426#[cfg(test)]
427mod wcag {
428    use super::*;
429
430    /// Convert an `Hsla` (alpha ignored — all checked colors are opaque,
431    /// guarded by `aliases_and_dark_variants_hold`) to linear-light sRGB
432    /// relative luminance.
433    fn relative_luminance(c: Hsla) -> f32 {
434        // HSL -> sRGB
435        let (h, s, l) = (c.h, c.s, c.l);
436        let q = if l < 0.5 {
437            l * (1.0 + s)
438        } else {
439            l + s - l * s
440        };
441        let p = 2.0 * l - q;
442        let hue = |mut t: f32| -> f32 {
443            if t < 0.0 {
444                t += 1.0;
445            }
446            if t > 1.0 {
447                t -= 1.0;
448            }
449            if t < 1.0 / 6.0 {
450                p + (q - p) * 6.0 * t
451            } else if t < 0.5 {
452                q
453            } else if t < 2.0 / 3.0 {
454                p + (q - p) * (2.0 / 3.0 - t) * 6.0
455            } else {
456                p
457            }
458        };
459        let (r, g, b) = (hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0));
460        // gamma -> linear
461        let lin = |u: f32| -> f32 {
462            if u <= 0.04045 {
463                u / 12.92
464            } else {
465                ((u + 0.055) / 1.055).powf(2.4)
466            }
467        };
468        0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
469    }
470
471    fn contrast(a: Hsla, b: Hsla) -> f32 {
472        let (la, lb) = (relative_luminance(a), relative_luminance(b));
473        let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
474        (hi + 0.05) / (lo + 0.05)
475    }
476
477    /// (foreground field, background field, minimum ratio, painted where)
478    fn requirements(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
479        vec![
480            (t.text_fg, t.bg, 7.0, "body text on bg"),
481            (t.text_fg, t.alt_row_bg, 7.0, "body text on zebra row"),
482            (t.text_fg, t.selection_bg, 4.5, "body text on selection"),
483            (t.text_fg, t.row_header_bg, 4.5, "pivot leaf label"),
484            (t.text_fg, t.header_bg, 4.5, "status bar text"),
485            (t.text_fg, t.filter_active_bg, 4.5, "filter text"),
486            (t.header_fg, t.header_bg, 4.5, "column labels"),
487            (t.header_fg, t.row_header_bg, 4.5, "row numbers"),
488            (t.header_fg, t.selection_bg, 4.5, "selected header label"),
489            (
490                t.header_fg,
491                t.pivot_grand_total_bg,
492                4.5,
493                "pivot header on total bg",
494            ),
495            (t.muted_text, t.bg, 4.5, "placeholder text on bg"),
496            (t.muted_text, t.menu_bg, 4.5, "menu secondary text"),
497            (t.muted_text, t.header_bg, 4.5, "sidebar header hint"),
498            (t.muted_text, t.pivot_drop_zone_bg, 4.5, "drop zone hint"),
499            (t.negative_fg, t.bg, 4.5, "negative numbers on bg"),
500            (
501                t.negative_fg,
502                t.alt_row_bg,
503                4.5,
504                "negative numbers on zebra",
505            ),
506            (
507                t.negative_fg,
508                t.selection_bg,
509                3.0,
510                "negative on selection (parentheses carry the channel too)",
511            ),
512            (t.negative_fg, t.pivot_grand_total_bg, 3.0, "negative total"),
513            (t.menu_fg, t.menu_bg, 7.0, "menu labels"),
514            (t.menu_fg, t.menu_hover_bg, 4.5, "hovered menu label"),
515            (
516                t.menu_fg,
517                t.pivot_drop_zone_bg,
518                4.5,
519                "pivot menu on zone bg",
520            ),
521            (t.null_fg, t.bg, 4.5, "null placeholder on bg"),
522            (t.null_fg, t.null_bg, 4.5, "null placeholder on null bg"),
523            (t.null_fg, t.alt_row_bg, 4.5, "null placeholder on zebra"),
524            (
525                t.pivot_total_fg,
526                t.pivot_group_bg,
527                7.0,
528                "group header label",
529            ),
530            (
531                t.pivot_total_fg,
532                t.pivot_subtotal_bg,
533                4.5,
534                "subtotal values",
535            ),
536            (
537                t.pivot_total_fg,
538                t.pivot_grand_total_bg,
539                4.5,
540                "grand total values",
541            ),
542            (t.pivot_chip_fg, t.pivot_chip_bg, 4.5, "chip labels"),
543            (t.sort_indicator, t.header_bg, 3.0, "sort glyph"),
544            (t.sort_indicator, t.menu_bg, 4.5, "sidebar sort glyph"),
545            (t.sort_indicator, t.bg, 3.0, "grouped-column underline"),
546            (t.filter_cursor, t.filter_active_bg, 4.5, "filter cursor"),
547            (t.scrollbar_thumb, t.row_header_bg, 3.0, "thumb vs track"),
548        ]
549    }
550
551    /// State fills must be perceivably different from what they replace.
552    fn distinctness(t: &GridTheme) -> Vec<(Hsla, Hsla, f32, &'static str)> {
553        vec![
554            (t.selection_bg, t.bg, 1.1, "selection visible on bg"),
555            (t.alt_row_bg, t.bg, 1.02, "zebra visible"),
556            (t.menu_hover_bg, t.menu_bg, 1.1, "menu hover visible"),
557            (
558                t.pivot_drop_zone_active_bg,
559                t.pivot_drop_zone_bg,
560                1.1,
561                "drop-zone hover visible",
562            ),
563            (t.grid_line, t.bg, 1.1, "grid lines visible"),
564            (
565                t.pivot_grand_total_bg,
566                t.pivot_subtotal_bg,
567                1.05,
568                "totals hierarchy readable",
569            ),
570        ]
571    }
572
573    #[test]
574    fn every_palette_meets_wcag_contrast() {
575        for (name, theme) in [
576            ("neutral_light", GridTheme::neutral_light()),
577            ("neutral_dark", GridTheme::neutral_dark()),
578            ("signature_light", GridTheme::signature_light()),
579            ("signature_dark", GridTheme::signature_dark()),
580        ] {
581            for (fg, bg, min, what) in requirements(&theme).into_iter().chain(distinctness(&theme))
582            {
583                let ratio = contrast(fg, bg);
584                assert!(
585                    ratio >= min,
586                    "{name}: {what} — contrast {ratio:.2} below required {min}"
587                );
588            }
589        }
590    }
591}