1use 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 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 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}