1use peniko::Color;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum Brightness {
28 #[default]
30 Light,
31 Dark,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct ColorScheme {
48 pub primary: Color,
49 pub on_primary: Color,
50 pub primary_container: Color,
51 pub on_primary_container: Color,
52 pub primary_fixed: Color,
53 pub primary_fixed_dim: Color,
54 pub on_primary_fixed: Color,
55 pub on_primary_fixed_variant: Color,
56
57 pub secondary: Color,
58 pub on_secondary: Color,
59 pub secondary_container: Color,
60 pub on_secondary_container: Color,
61 pub secondary_fixed: Color,
62 pub secondary_fixed_dim: Color,
63 pub on_secondary_fixed: Color,
64 pub on_secondary_fixed_variant: Color,
65
66 pub tertiary: Color,
67 pub on_tertiary: Color,
68 pub tertiary_container: Color,
69 pub on_tertiary_container: Color,
70 pub tertiary_fixed: Color,
71 pub tertiary_fixed_dim: Color,
72 pub on_tertiary_fixed: Color,
73 pub on_tertiary_fixed_variant: Color,
74
75 pub error: Color,
76 pub on_error: Color,
77 pub error_container: Color,
78 pub on_error_container: Color,
79
80 pub surface: Color,
81 pub on_surface: Color,
82 pub on_surface_variant: Color,
83 pub surface_dim: Color,
84 pub surface_bright: Color,
85 pub surface_container_lowest: Color,
86 pub surface_container_low: Color,
87 pub surface_container: Color,
88 pub surface_container_high: Color,
89 pub surface_container_highest: Color,
90
91 pub outline: Color,
92 pub outline_variant: Color,
93 pub shadow: Color,
94 pub scrim: Color,
95 pub inverse_surface: Color,
96 pub inverse_on_surface: Color,
97 pub inverse_primary: Color,
98 pub surface_tint: Color,
99}
100
101impl ColorScheme {
102 pub fn with_accent(self, accent: Color) -> Self {
147 let on_primary = select_ink_by_luminance(accent);
148 let primary_container = blend_accent_over_surface(accent, self.surface);
149
150 Self {
151 primary: accent,
152 on_primary,
153 primary_container,
154 on_primary_container: accent,
155 ..self
156 }
157 }
158
159 pub const fn neutral_light() -> Self {
180 const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
182 const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
183
184 Self {
185 primary: ACCENT_LIGHT,
186 on_primary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
187 primary_container: Color::from_rgb8(0xE3, 0xE9, 0xEE),
188 on_primary_container: Color::from_rgb8(0x1B, 0x2A, 0x35),
189 primary_fixed: ACCENT_LIGHT,
192 primary_fixed_dim: ACCENT_DARK,
193 on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
194 on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
195
196 secondary: Color::from_rgb8(0x6B, 0x6B, 0x6B),
199 on_secondary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
200 secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
201 on_secondary_container: Color::from_rgb8(0x2B, 0x2B, 0x2B),
202 secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
203 secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
204 on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
205 on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
206
207 tertiary: Color::from_rgb8(0x4A, 0x4A, 0x4A),
209 on_tertiary: Color::from_rgb8(0xFF, 0xFF, 0xFF),
210 tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
211 on_tertiary_container: Color::from_rgb8(0x26, 0x26, 0x26),
212 tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
213 tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
214 on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
215 on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
216
217 error: Color::from_rgb8(0xB3, 0x26, 0x1E),
220 on_error: Color::from_rgb8(0xFF, 0xFF, 0xFF),
221 error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
222 on_error_container: Color::from_rgb8(0x41, 0x0E, 0x0B),
223
224 surface: Color::from_rgb8(0xFA, 0xFA, 0xFA),
225 on_surface: Color::from_rgb8(0x1A, 0x1A, 0x1A),
226 on_surface_variant: Color::from_rgb8(0x5C, 0x5C, 0x5C),
227 surface_dim: Color::from_rgb8(0xE8, 0xE8, 0xE8),
228 surface_bright: Color::from_rgb8(0xFF, 0xFF, 0xFF),
229 surface_container_lowest: Color::from_rgb8(0xFF, 0xFF, 0xFF),
230 surface_container_low: Color::from_rgb8(0xF5, 0xF5, 0xF5),
231 surface_container: Color::from_rgb8(0xEF, 0xEF, 0xEF),
232 surface_container_high: Color::from_rgb8(0xE7, 0xE7, 0xE7),
233 surface_container_highest: Color::from_rgb8(0xDF, 0xDF, 0xDF),
234
235 outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
236 outline_variant: Color::from_rgb8(0xD0, 0xD0, 0xD0),
237 shadow: Color::from_rgb8(0x00, 0x00, 0x00),
238 scrim: Color::from_rgb8(0x00, 0x00, 0x00),
239 inverse_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
240 inverse_on_surface: Color::from_rgb8(0xF5, 0xF5, 0xF5),
241 inverse_primary: ACCENT_DARK,
242 surface_tint: ACCENT_LIGHT,
244 }
245 }
246
247 pub const fn neutral_dark() -> Self {
256 const ACCENT_LIGHT: Color = Color::from_rgb8(0x3D, 0x5A, 0x73);
257 const ACCENT_DARK: Color = Color::from_rgb8(0x8F, 0xB4, 0xD1);
258
259 Self {
260 primary: ACCENT_DARK,
261 on_primary: Color::from_rgb8(0x16, 0x23, 0x2C),
262 primary_container: Color::from_rgb8(0x22, 0x34, 0x41),
263 on_primary_container: Color::from_rgb8(0xC7, 0xDC, 0xEA),
264 primary_fixed: ACCENT_LIGHT,
265 primary_fixed_dim: ACCENT_DARK,
266 on_primary_fixed: Color::from_rgb8(0xFF, 0xFF, 0xFF),
267 on_primary_fixed_variant: Color::from_rgb8(0x2E, 0x47, 0x59),
268
269 secondary: Color::from_rgb8(0xB0, 0xB0, 0xB0),
270 on_secondary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
271 secondary_container: Color::from_rgb8(0x3A, 0x3A, 0x3A),
272 on_secondary_container: Color::from_rgb8(0xE4, 0xE4, 0xE4),
273 secondary_fixed: Color::from_rgb8(0xE4, 0xE4, 0xE4),
274 secondary_fixed_dim: Color::from_rgb8(0xC7, 0xC7, 0xC7),
275 on_secondary_fixed: Color::from_rgb8(0x2B, 0x2B, 0x2B),
276 on_secondary_fixed_variant: Color::from_rgb8(0x4A, 0x4A, 0x4A),
277
278 tertiary: Color::from_rgb8(0xB8, 0xB8, 0xB8),
279 on_tertiary: Color::from_rgb8(0x1E, 0x1E, 0x1E),
280 tertiary_container: Color::from_rgb8(0x38, 0x38, 0x38),
281 on_tertiary_container: Color::from_rgb8(0xD6, 0xD6, 0xD6),
282 tertiary_fixed: Color::from_rgb8(0xD6, 0xD6, 0xD6),
283 tertiary_fixed_dim: Color::from_rgb8(0xB8, 0xB8, 0xB8),
284 on_tertiary_fixed: Color::from_rgb8(0x26, 0x26, 0x26),
285 on_tertiary_fixed_variant: Color::from_rgb8(0x3D, 0x3D, 0x3D),
286
287 error: Color::from_rgb8(0xF2, 0xB8, 0xB5),
290 on_error: Color::from_rgb8(0x60, 0x14, 0x10),
291 error_container: Color::from_rgb8(0x8C, 0x1D, 0x18),
292 on_error_container: Color::from_rgb8(0xF9, 0xDE, 0xDC),
293
294 surface: Color::from_rgb8(0x12, 0x12, 0x12),
295 on_surface: Color::from_rgb8(0xF2, 0xF2, 0xF2),
296 on_surface_variant: Color::from_rgb8(0xB0, 0xB0, 0xB0),
297 surface_dim: Color::from_rgb8(0x12, 0x12, 0x12),
298 surface_bright: Color::from_rgb8(0x38, 0x38, 0x38),
299 surface_container_lowest: Color::from_rgb8(0x0B, 0x0B, 0x0B),
300 surface_container_low: Color::from_rgb8(0x1C, 0x1C, 0x1C),
301 surface_container: Color::from_rgb8(0x20, 0x20, 0x20),
302 surface_container_high: Color::from_rgb8(0x2A, 0x2A, 0x2A),
303 surface_container_highest: Color::from_rgb8(0x35, 0x35, 0x35),
304
305 outline: Color::from_rgb8(0x8A, 0x8A, 0x8A),
306 outline_variant: Color::from_rgb8(0x47, 0x47, 0x47),
307 shadow: Color::from_rgb8(0x00, 0x00, 0x00),
308 scrim: Color::from_rgb8(0x00, 0x00, 0x00),
309 inverse_surface: Color::from_rgb8(0xE6, 0xE6, 0xE6),
310 inverse_on_surface: Color::from_rgb8(0x2B, 0x2B, 0x2B),
311 inverse_primary: ACCENT_LIGHT,
312 surface_tint: ACCENT_DARK,
313 }
314 }
315}
316
317fn relative_luminance(color: Color) -> f64 {
322 let [r, g, b, _] = color.to_rgba8().to_u8_array();
323 let r = linearize_srgb_channel(r as f64 / 255.0);
324 let g = linearize_srgb_channel(g as f64 / 255.0);
325 let b = linearize_srgb_channel(b as f64 / 255.0);
326 0.2126 * r + 0.7152 * g + 0.0722 * b
327}
328
329fn linearize_srgb_channel(c: f64) -> f64 {
334 if c <= 0.03928 {
335 c / 12.92
336 } else {
337 ((c + 0.055) / 1.055).powf(2.4)
338 }
339}
340
341fn select_ink_by_luminance(color: Color) -> Color {
347 if relative_luminance(color) < 0.5 {
348 Color::from_rgb8(0xFF, 0xFF, 0xFF)
350 } else {
351 Color::from_rgb8(0x1D, 0x1B, 0x20)
353 }
354}
355
356fn blend_accent_over_surface(accent: Color, surface: Color) -> Color {
361 const ACCENT_ALPHA: f64 = 0.10;
362
363 let [a_r, a_g, a_b, _] = accent.to_rgba8().to_u8_array();
364 let [s_r, s_g, s_b, _] = surface.to_rgba8().to_u8_array();
365
366 let a_r = a_r as f64 / 255.0;
367 let a_g = a_g as f64 / 255.0;
368 let a_b = a_b as f64 / 255.0;
369 let s_r = s_r as f64 / 255.0;
370 let s_g = s_g as f64 / 255.0;
371 let s_b = s_b as f64 / 255.0;
372
373 let blend_channel = |a: f64, s: f64| -> u8 {
374 let result = a * ACCENT_ALPHA + s * (1.0 - ACCENT_ALPHA);
375 (result * 255.0).round() as u8
376 };
377
378 Color::from_rgb8(
379 blend_channel(a_r, s_r),
380 blend_channel(a_g, s_g),
381 blend_channel(a_b, s_b),
382 )
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
392 fn hex_round_trip() {
393 let c = Color::from_rgb8(0x67, 0x50, 0xA4);
394 let [r, g, b, a] = c.to_rgba8().to_u8_array();
395 assert_eq!((r, g, b, a), (0x67, 0x50, 0xA4, 0xFF));
396 }
397
398 #[test]
399 fn deprecated_roles_absent() {
400 let _ = ColorScheme::neutral_light();
405 }
406
407 #[test]
408 fn with_accent_changes_exactly_four_roles() {
409 let base = ColorScheme::neutral_dark();
412 let red = Color::from_rgb8(0xFF, 0x00, 0x00);
413 let modified = base.with_accent(red);
414
415 assert_eq!(modified.primary, red);
417 assert_eq!(modified.on_primary_container, red);
418 assert_ne!(modified.on_primary, base.on_primary);
421 assert_ne!(modified.primary_container, base.primary_container);
422
423 assert_eq!(modified.secondary, base.secondary);
425 assert_eq!(modified.on_secondary, base.on_secondary);
426 assert_eq!(modified.secondary_container, base.secondary_container);
427 assert_eq!(modified.on_secondary_container, base.on_secondary_container);
428 assert_eq!(modified.secondary_fixed, base.secondary_fixed);
429 assert_eq!(modified.secondary_fixed_dim, base.secondary_fixed_dim);
430 assert_eq!(modified.on_secondary_fixed, base.on_secondary_fixed);
431 assert_eq!(
432 modified.on_secondary_fixed_variant,
433 base.on_secondary_fixed_variant
434 );
435
436 assert_eq!(modified.tertiary, base.tertiary);
437 assert_eq!(modified.on_tertiary, base.on_tertiary);
438 assert_eq!(modified.tertiary_container, base.tertiary_container);
439 assert_eq!(modified.on_tertiary_container, base.on_tertiary_container);
440 assert_eq!(modified.tertiary_fixed, base.tertiary_fixed);
441 assert_eq!(modified.tertiary_fixed_dim, base.tertiary_fixed_dim);
442 assert_eq!(modified.on_tertiary_fixed, base.on_tertiary_fixed);
443 assert_eq!(
444 modified.on_tertiary_fixed_variant,
445 base.on_tertiary_fixed_variant
446 );
447
448 assert_eq!(modified.error, base.error);
449 assert_eq!(modified.on_error, base.on_error);
450 assert_eq!(modified.error_container, base.error_container);
451 assert_eq!(modified.on_error_container, base.on_error_container);
452
453 assert_eq!(modified.surface, base.surface);
454 assert_eq!(modified.on_surface, base.on_surface);
455 assert_eq!(modified.on_surface_variant, base.on_surface_variant);
456 assert_eq!(modified.surface_dim, base.surface_dim);
457 assert_eq!(modified.surface_bright, base.surface_bright);
458 assert_eq!(
459 modified.surface_container_lowest,
460 base.surface_container_lowest
461 );
462 assert_eq!(modified.surface_container_low, base.surface_container_low);
463 assert_eq!(modified.surface_container, base.surface_container);
464 assert_eq!(modified.surface_container_high, base.surface_container_high);
465 assert_eq!(
466 modified.surface_container_highest,
467 base.surface_container_highest
468 );
469
470 assert_eq!(modified.outline, base.outline);
471 assert_eq!(modified.outline_variant, base.outline_variant);
472 assert_eq!(modified.shadow, base.shadow);
473 assert_eq!(modified.scrim, base.scrim);
474 assert_eq!(modified.inverse_surface, base.inverse_surface);
475 assert_eq!(modified.inverse_on_surface, base.inverse_on_surface);
476 assert_eq!(modified.inverse_primary, base.inverse_primary);
477 assert_eq!(modified.surface_tint, base.surface_tint);
478
479 assert_eq!(modified.primary_fixed, base.primary_fixed);
480 assert_eq!(modified.primary_fixed_dim, base.primary_fixed_dim);
481 assert_eq!(modified.on_primary_fixed, base.on_primary_fixed);
482 assert_eq!(
483 modified.on_primary_fixed_variant,
484 base.on_primary_fixed_variant
485 );
486 }
487
488 #[test]
489 fn on_primary_contrast_for_dark_and_light_accents() {
490 let base = ColorScheme::neutral_dark();
493
494 let dark_accent = Color::from_rgb8(0x80, 0x00, 0x00);
496 let dark_scheme = base.with_accent(dark_accent);
497 assert_eq!(
498 dark_scheme.on_primary,
499 Color::from_rgb8(0xFF, 0xFF, 0xFF),
500 "Dark accent should pair with white ink"
501 );
502
503 let light_accent = Color::from_rgb8(0xFF, 0xFF, 0x00);
505 let light_scheme = base.with_accent(light_accent);
506 assert_eq!(
507 light_scheme.on_primary,
508 Color::from_rgb8(0x1D, 0x1B, 0x20),
509 "Light accent should pair with dark ink"
510 );
511 }
512
513 fn contrast_ratio(a: Color, b: Color) -> f64 {
518 let la = relative_luminance(a);
519 let lb = relative_luminance(b);
520 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
521 (hi + 0.05) / (lo + 0.05)
522 }
523
524 #[test]
525 fn neutral_surface_on_surface_contrast_is_legible() {
526 const AA_NORMAL_TEXT: f64 = 4.5;
529
530 let light = ColorScheme::neutral_light();
531 assert!(
532 contrast_ratio(light.surface, light.on_surface) >= AA_NORMAL_TEXT,
533 "neutral_light surface/on_surface contrast too low"
534 );
535
536 let dark = ColorScheme::neutral_dark();
537 assert!(
538 contrast_ratio(dark.surface, dark.on_surface) >= AA_NORMAL_TEXT,
539 "neutral_dark surface/on_surface contrast too low"
540 );
541 }
542
543 #[test]
544 fn neutral_light_and_dark_are_distinct() {
545 let light = ColorScheme::neutral_light();
546 let dark = ColorScheme::neutral_dark();
547 assert_ne!(light.surface, dark.surface);
548 assert_ne!(light.on_surface, dark.on_surface);
549 assert_ne!(light.primary, dark.primary);
550 }
551
552 #[test]
553 fn neutral_carries_exactly_one_accent_family() {
554 fn is_achromatic(c: Color) -> bool {
557 let [r, g, b, _] = c.to_rgba8().to_u8_array();
558 r == g && g == b
559 }
560
561 for scheme in [ColorScheme::neutral_light(), ColorScheme::neutral_dark()] {
562 assert!(!is_achromatic(scheme.primary), "primary must be the accent");
563 assert!(is_achromatic(scheme.secondary));
564 assert!(is_achromatic(scheme.tertiary));
565 assert!(is_achromatic(scheme.surface));
566 assert!(is_achromatic(scheme.on_surface));
567 }
568 }
569
570 #[test]
571 fn neutral_fixed_roles_are_brightness_invariant() {
572 let light = ColorScheme::neutral_light();
573 let dark = ColorScheme::neutral_dark();
574 assert_eq!(light.primary_fixed, dark.primary_fixed);
575 assert_eq!(light.primary_fixed_dim, dark.primary_fixed_dim);
576 assert_eq!(
577 light.on_primary_fixed_variant,
578 dark.on_primary_fixed_variant
579 );
580 assert_eq!(light.secondary_fixed, dark.secondary_fixed);
581 assert_eq!(light.tertiary_fixed, dark.tertiary_fixed);
582 }
583
584 #[test]
585 fn neutral_is_const_constructible() {
586 const LIGHT: ColorScheme = ColorScheme::neutral_light();
587 const DARK: ColorScheme = ColorScheme::neutral_dark();
588 assert_ne!(LIGHT.surface, DARK.surface);
589 }
590}