Skip to main content

gpui_kit_material_color/themes/
mod.rs

1//! Exact preset colors, including medium/high contrast overrides.
2//!
3//! Reference priority is Figma Material 3 Design Kit > md3.io >
4//! m3e.kyralabs.dev, per value. The kit's `M3` variable collection
5//! (`Schemes/*`, file key `ozddDB2Z1v51f1IFZlUuJ2`, collection
6//! `VariableCollectionId:54778:406`, modes Light/Dark × standard/medium/high
7//! contrast) defines the baseline scheme, so [`ThemeId::Baseline`] takes every
8//! role the kit defines from it (fixture
9//! `tests/fixtures/figma-baseline-colors.json`). The kit defines no other md3.io
10//! theme and no info/success/warning roles; those come from md3.io's
11//! browser-resolved Sass at c910072f3fc5b660276731d8a935d7186f288364 (fixture
12//! `tests/fixtures/md3-colors.json`). These presets are not a dynamic HCT color
13//! generator. See LICENSE-material.
14use crate::{ColorRole, Scheme};
15mod data;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
20pub enum ThemeMode {
21    #[default]
22    Light,
23    Dark,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
29pub enum Contrast {
30    #[default]
31    Standard,
32    Medium,
33    High,
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
39pub enum ThemeId {
40    Autumn,
41    #[default]
42    Baseline,
43    Brownbeige,
44    Content,
45    Desert,
46    Expressive,
47    Fidelity,
48    Forest,
49    FruitSalad,
50    Highcontrast,
51    Monochrome,
52    Neutral,
53    Ocean,
54    Rainbow,
55    Sageivory,
56    Spring,
57    Summer,
58    Sunset,
59    Tealcaramel,
60    Vibrant,
61}
62pub const ALL: [ThemeId; 20] = [
63    ThemeId::Autumn,
64    ThemeId::Baseline,
65    ThemeId::Brownbeige,
66    ThemeId::Content,
67    ThemeId::Desert,
68    ThemeId::Expressive,
69    ThemeId::Fidelity,
70    ThemeId::Forest,
71    ThemeId::FruitSalad,
72    ThemeId::Highcontrast,
73    ThemeId::Monochrome,
74    ThemeId::Neutral,
75    ThemeId::Ocean,
76    ThemeId::Rainbow,
77    ThemeId::Sageivory,
78    ThemeId::Spring,
79    ThemeId::Summer,
80    ThemeId::Sunset,
81    ThemeId::Tealcaramel,
82    ThemeId::Vibrant,
83];
84pub const REFERENCE_ROLES: [ColorRole; 52] = [
85    ColorRole::ERROR,
86    ColorRole::ERROR_CONTAINER,
87    ColorRole::INFO,
88    ColorRole::INVERSE_ON_SURFACE,
89    ColorRole::INVERSE_PRIMARY,
90    ColorRole::INVERSE_SURFACE,
91    ColorRole::ON_ERROR,
92    ColorRole::ON_ERROR_CONTAINER,
93    ColorRole::ON_INFO,
94    ColorRole::ON_PRIMARY,
95    ColorRole::ON_PRIMARY_CONTAINER,
96    ColorRole::ON_PRIMARY_FIXED,
97    ColorRole::ON_PRIMARY_FIXED_VARIANT,
98    ColorRole::ON_SECONDARY,
99    ColorRole::ON_SECONDARY_CONTAINER,
100    ColorRole::ON_SECONDARY_FIXED,
101    ColorRole::ON_SECONDARY_FIXED_VARIANT,
102    ColorRole::ON_SUCCESS,
103    ColorRole::ON_SURFACE,
104    ColorRole::ON_SURFACE_VARIANT,
105    ColorRole::ON_TERTIARY,
106    ColorRole::ON_TERTIARY_CONTAINER,
107    ColorRole::ON_TERTIARY_FIXED,
108    ColorRole::ON_TERTIARY_FIXED_VARIANT,
109    ColorRole::ON_WARNING,
110    ColorRole::OUTLINE,
111    ColorRole::OUTLINE_VARIANT,
112    ColorRole::PRIMARY,
113    ColorRole::PRIMARY_CONTAINER,
114    ColorRole::PRIMARY_FIXED,
115    ColorRole::PRIMARY_FIXED_DIM,
116    ColorRole::SCRIM,
117    ColorRole::SECONDARY,
118    ColorRole::SECONDARY_CONTAINER,
119    ColorRole::SECONDARY_FIXED,
120    ColorRole::SECONDARY_FIXED_DIM,
121    ColorRole::SHADOW,
122    ColorRole::SUCCESS,
123    ColorRole::SURFACE,
124    ColorRole::SURFACE_BRIGHT,
125    ColorRole::SURFACE_CONTAINER,
126    ColorRole::SURFACE_CONTAINER_HIGH,
127    ColorRole::SURFACE_CONTAINER_HIGHEST,
128    ColorRole::SURFACE_CONTAINER_LOW,
129    ColorRole::SURFACE_CONTAINER_LOWEST,
130    ColorRole::SURFACE_DIM,
131    ColorRole::SURFACE_VARIANT,
132    ColorRole::TERTIARY,
133    ColorRole::TERTIARY_CONTAINER,
134    ColorRole::TERTIARY_FIXED,
135    ColorRole::TERTIARY_FIXED_DIM,
136    ColorRole::WARNING,
137];
138pub const REFERENCE_ROLE_NAMES: [&str; 52] = [
139    "error",
140    "error-container",
141    "info",
142    "inverse-on-surface",
143    "inverse-primary",
144    "inverse-surface",
145    "on-error",
146    "on-error-container",
147    "on-info",
148    "on-primary",
149    "on-primary-container",
150    "on-primary-fixed",
151    "on-primary-fixed-variant",
152    "on-secondary",
153    "on-secondary-container",
154    "on-secondary-fixed",
155    "on-secondary-fixed-variant",
156    "on-success",
157    "on-surface",
158    "on-surface-variant",
159    "on-tertiary",
160    "on-tertiary-container",
161    "on-tertiary-fixed",
162    "on-tertiary-fixed-variant",
163    "on-warning",
164    "outline",
165    "outline-variant",
166    "primary",
167    "primary-container",
168    "primary-fixed",
169    "primary-fixed-dim",
170    "scrim",
171    "secondary",
172    "secondary-container",
173    "secondary-fixed",
174    "secondary-fixed-dim",
175    "shadow",
176    "success",
177    "surface",
178    "surface-bright",
179    "surface-container",
180    "surface-container-high",
181    "surface-container-highest",
182    "surface-container-low",
183    "surface-container-lowest",
184    "surface-dim",
185    "surface-variant",
186    "tertiary",
187    "tertiary-container",
188    "tertiary-fixed",
189    "tertiary-fixed-dim",
190    "warning",
191];
192impl ThemeId {
193    pub fn name(self) -> &'static str {
194        match self {
195            Self::Autumn => "autumn",
196            Self::Baseline => "baseline",
197            Self::Brownbeige => "brownbeige",
198            Self::Content => "content",
199            Self::Desert => "desert",
200            Self::Expressive => "expressive",
201            Self::Fidelity => "fidelity",
202            Self::Forest => "forest",
203            Self::FruitSalad => "fruit-salad",
204            Self::Highcontrast => "highcontrast",
205            Self::Monochrome => "monochrome",
206            Self::Neutral => "neutral",
207            Self::Ocean => "ocean",
208            Self::Rainbow => "rainbow",
209            Self::Sageivory => "sageivory",
210            Self::Spring => "spring",
211            Self::Summer => "summer",
212            Self::Sunset => "sunset",
213            Self::Tealcaramel => "tealcaramel",
214            Self::Vibrant => "vibrant",
215        }
216    }
217    pub fn scheme(self, mode: ThemeMode, contrast: Contrast) -> Scheme {
218        let mode_index = match mode {
219            ThemeMode::Light => 0,
220            ThemeMode::Dark => 1,
221        };
222        let contrast_index = match contrast {
223            Contrast::Standard => 0,
224            Contrast::Medium => 1,
225            Contrast::High => 2,
226        };
227        Scheme::from_reference(
228            &data::COLORS[self as usize][mode_index][contrast_index],
229            mode == ThemeMode::Dark,
230        )
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::{Color, scheme::ROLE_COUNT};
238
239    fn mode_and_contrast(row: &serde_json::Value) -> (ThemeMode, Contrast) {
240        let mode = match row["mode"].as_str().unwrap() {
241            "light" => ThemeMode::Light,
242            "dark" => ThemeMode::Dark,
243            _ => panic!("invalid mode"),
244        };
245        let contrast = match row["contrast"].as_str().unwrap() {
246            "standard" => Contrast::Standard,
247            "medium" => Contrast::Medium,
248            "high" => Contrast::High,
249            _ => panic!("invalid contrast"),
250        };
251        (mode, contrast)
252    }
253
254    fn hex(value: &serde_json::Value) -> Color {
255        Color::hex(u32::from_str_radix(&value.as_str().unwrap()[1..], 16).unwrap())
256    }
257
258    fn figma_baseline() -> serde_json::Value {
259        serde_json::from_str(include_str!(
260            "../../tests/fixtures/figma-baseline-colors.json"
261        ))
262        .unwrap()
263    }
264
265    #[test]
266    fn baseline_matches_the_figma_kit_variables_in_every_mode() {
267        let reference = figma_baseline();
268        let rows = reference["rows"].as_array().unwrap();
269        assert_eq!(rows.len(), 6);
270        let mut defined = 0;
271        for row in rows {
272            assert_eq!(row["theme"], "baseline");
273            let (mode, contrast) = mode_and_contrast(row);
274            let scheme = ThemeId::Baseline.scheme(mode, contrast);
275            assert_eq!(scheme.is_dark(), mode == ThemeMode::Dark);
276            for (role, name) in REFERENCE_ROLES.into_iter().zip(REFERENCE_ROLE_NAMES) {
277                let Some(expected) = row["colors"].get(name) else {
278                    continue;
279                };
280                defined += 1;
281                assert_eq!(
282                    scheme.get(role),
283                    hex(expected),
284                    "Figma {} {role:?}",
285                    row["figma_mode"]
286                );
287            }
288        }
289        // Every reference role except info/success/warning and their on-roles.
290        assert_eq!(defined, 6 * 46);
291    }
292
293    #[test]
294    fn every_reference_configuration_matches_browser_resolved_css() {
295        let reference: serde_json::Value =
296            serde_json::from_str(include_str!("../../tests/fixtures/md3-colors.json")).unwrap();
297        let rows = reference["rows"].as_array().unwrap();
298        assert_eq!(rows.len(), 120);
299        assert_eq!(REFERENCE_ROLES.len(), 52);
300        let figma = figma_baseline();
301        let mut seen = std::collections::BTreeSet::new();
302        for row in rows {
303            let name = row["theme"].as_str().unwrap();
304            let mode_name = row["mode"].as_str().unwrap();
305            let contrast_name = row["contrast"].as_str().unwrap();
306            assert!(seen.insert((name, mode_name, contrast_name)));
307            let id = ALL.into_iter().find(|id| id.name() == name).unwrap();
308            let (mode, contrast) = mode_and_contrast(row);
309            let scheme = id.scheme(mode, contrast);
310            assert_eq!(scheme.is_dark(), mode == ThemeMode::Dark);
311            assert_eq!(row["colors"].as_object().unwrap().len(), 52);
312            // The Figma kit outranks md3.io for every baseline role it defines.
313            let kit = (id == ThemeId::Baseline).then(|| {
314                figma["rows"]
315                    .as_array()
316                    .unwrap()
317                    .iter()
318                    .find(|kit| kit["mode"] == mode_name && kit["contrast"] == contrast_name)
319                    .unwrap()
320            });
321            for (role, name) in REFERENCE_ROLES.into_iter().zip(REFERENCE_ROLE_NAMES) {
322                if kit.is_some_and(|kit| kit["colors"].get(name).is_some()) {
323                    continue;
324                }
325                let expected = hex(&row["colors"][name]);
326                assert_eq!(
327                    scheme.get(role),
328                    expected,
329                    "{id:?} {mode:?} {contrast:?} {role:?}"
330                );
331            }
332        }
333    }
334
335    #[test]
336    #[allow(
337        deprecated,
338        reason = "checks that the deprecated aliases keep sharing storage with their roles"
339    )]
340    fn aliases_share_storage_and_reference_roles_are_not_duplicated() {
341        let unique = REFERENCE_ROLES
342            .into_iter()
343            .map(|r| r as usize)
344            .collect::<std::collections::BTreeSet<_>>();
345        assert_eq!(unique.len(), 52);
346        assert_eq!(
347            ROLE_COUNT,
348            52 + 10,
349            "52 reference roles plus ten documented application extensions"
350        );
351        for (old, new) in [
352            (ColorRole::ACCENT, ColorRole::PRIMARY),
353            (ColorRole::INK, ColorRole::ON_SURFACE),
354            (ColorRole::CANVAS, ColorRole::SURFACE_CONTAINER),
355            (
356                ColorRole::SURFACE_HIGH,
357                ColorRole::SURFACE_CONTAINER_HIGHEST,
358            ),
359            (ColorRole::GRAY, ColorRole::ON_SURFACE_VARIANT),
360            (ColorRole::CONTROL, ColorRole::OUTLINE),
361            (ColorRole::HAIRLINE, ColorRole::OUTLINE_VARIANT),
362            (ColorRole::DANGER, ColorRole::ERROR),
363            (ColorRole::DANGER_BG, ColorRole::ERROR_CONTAINER),
364        ] {
365            assert_eq!(old, new);
366            let scheme = ThemeId::Baseline
367                .scheme(ThemeMode::Light, Contrast::Standard)
368                .with_role(old, Color::hex(0x123456));
369            assert_eq!(scheme.get(new), Color::hex(0x123456));
370        }
371    }
372
373    #[test]
374    fn standard_fixed_roles_are_mode_independent_except_the_reference_highcontrast_preset() {
375        for theme in ALL.into_iter().filter(|id| *id != ThemeId::Highcontrast) {
376            let light = theme.scheme(ThemeMode::Light, Contrast::Standard);
377            let dark = theme.scheme(ThemeMode::Dark, Contrast::Standard);
378            for (role, name) in REFERENCE_ROLES
379                .into_iter()
380                .zip(REFERENCE_ROLE_NAMES)
381                .filter(|(_, name)| name.contains("fixed"))
382            {
383                assert_eq!(light.get(role), dark.get(role), "{theme:?}: {name}");
384            }
385        }
386    }
387
388    #[cfg(feature = "serde")]
389    #[test]
390    fn reference_theme_ids_roundtrip_with_the_site_names() {
391        for id in ALL {
392            let json = serde_json::to_string(&id).unwrap();
393            assert_eq!(json, format!("\"{}\"", id.name()));
394            assert_eq!(serde_json::from_str::<ThemeId>(&json).unwrap(), id);
395        }
396    }
397}