1use gpui::Hsla;
11use herogpui_core::{mix_oklab, oklch, soft_mix, with_alpha, Color};
12
13pub fn white() -> Hsla {
19 oklch(1.0, 0.0, 0.0)
20}
21pub fn black() -> Hsla {
23 oklch(0.0, 0.0, 0.0)
24}
25pub fn snow() -> Hsla {
27 oklch(0.9911, 0.0, 0.0)
28}
29pub fn eclipse() -> Hsla {
31 oklch(0.2103, 0.0059, 285.89)
32}
33
34#[derive(Clone, Copy, Debug)]
36pub enum SoftForeground {
37 RoleForeground,
39 Mix {
42 color: f32,
44 foreground: f32,
46 },
47}
48
49#[derive(Clone, Copy, Debug)]
54pub struct RoleColor {
55 pub color: Hsla,
57 pub foreground: Hsla,
59 pub hover_mix: f32,
62 hover_override: Option<Hsla>,
70 soft_mix: f32,
72 soft_hover_mix: f32,
74 soft_foreground: SoftForeground,
76 vibrant_palette: bool,
82}
83
84impl RoleColor {
85 pub fn new(color: Hsla, foreground: Hsla) -> Self {
87 Self {
88 color,
89 foreground,
90 hover_mix: 0.10,
91 hover_override: None,
92 soft_mix: 0.15,
93 soft_hover_mix: 0.20,
94 soft_foreground: SoftForeground::Mix {
95 color: 70.0,
96 foreground: 30.0,
97 },
98 vibrant_palette: false,
99 }
100 }
101
102 pub fn with_hover_mix(mut self, hover_mix: f32) -> Self {
106 self.hover_mix = hover_mix;
107 self
108 }
109
110 pub fn with_hover(mut self, hover: Hsla) -> Self {
123 self.hover_override = Some(hover);
124 self
125 }
126
127 pub fn hover_override(&self) -> Option<Hsla> {
129 self.hover_override
130 }
131
132 pub fn with_soft_mix(mut self, soft: f32, soft_hover: f32) -> Self {
136 self.soft_mix = soft;
137 self.soft_hover_mix = soft_hover;
138 self
139 }
140
141 pub fn with_soft_foreground_mix(mut self, color: f32, foreground: f32) -> Self {
143 self.soft_foreground = SoftForeground::Mix { color, foreground };
144 self
145 }
146
147 pub fn with_vibrant_palette(mut self, vibrant: bool) -> Self {
151 self.vibrant_palette = vibrant;
152 self
153 }
154
155 pub fn is_vibrant_palette(&self) -> bool {
157 self.vibrant_palette
158 }
159
160 pub fn with_soft_foreground_role(mut self) -> Self {
162 self.soft_foreground = SoftForeground::RoleForeground;
163 self
164 }
165
166 pub fn hover(&self) -> Hsla {
170 self.hover_override
171 .unwrap_or_else(|| mix_oklab(self.color, self.foreground, self.hover_mix))
172 }
173
174 pub fn soft(&self) -> Hsla {
176 soft_mix(self.color, self.soft_mix)
177 }
178
179 pub fn soft_hover(&self) -> Hsla {
181 soft_mix(self.color, self.soft_hover_mix)
182 }
183
184 pub fn soft_foreground(&self, page_foreground: Hsla) -> Hsla {
190 match self.soft_foreground {
191 SoftForeground::RoleForeground => self.foreground,
192 SoftForeground::Mix { .. } if self.vibrant_palette => {
195 mix_oklab(self.color, page_foreground, 8.0 / 100.0)
196 }
197 SoftForeground::Mix { color, foreground } => {
198 mix_oklab(
202 self.color,
203 page_foreground,
204 foreground / (color + foreground),
205 )
206 }
207 }
208 }
209
210 pub fn with_alpha(&self, alpha: f32) -> Hsla {
212 with_alpha(self.color, alpha)
213 }
214}
215
216#[derive(Clone, Copy, Debug)]
218pub struct SurfaceColor {
219 pub background: Hsla,
221 pub foreground: Hsla,
223}
224
225impl SurfaceColor {
226 pub fn hover(&self) -> Hsla {
228 mix_oklab(self.background, self.foreground, 0.08)
229 }
230}
231
232#[derive(Clone, Copy, Debug)]
235#[non_exhaustive]
236pub struct FieldColors {
237 pub background: Hsla,
239 pub foreground: Hsla,
241 pub placeholder: Hsla,
243 pub border: Hsla,
245}
246
247impl FieldColors {
248 pub fn hover(&self) -> Hsla {
252 mix_oklab(self.background, self.foreground, 2.0 / 92.0)
253 }
254
255 pub fn focus(&self) -> Hsla {
257 self.background
258 }
259
260 pub fn border_hover(&self) -> Hsla {
266 mix_oklab(self.border, self.foreground, 10.0 / 98.0)
267 }
268
269 pub fn border_focus(&self) -> Hsla {
271 mix_oklab(self.border, self.foreground, 22.0 / 96.0)
272 }
273}
274
275#[derive(Clone, Debug)]
277#[non_exhaustive]
278pub struct ThemeColors {
279 pub background: Hsla,
282 pub foreground: Hsla,
284 pub muted: Hsla,
286 pub scrollbar: Hsla,
288
289 pub surface: SurfaceColor,
293 pub surface_secondary: Hsla,
295 pub surface_tertiary: Hsla,
297 pub overlay: SurfaceColor,
300 pub segment: SurfaceColor,
303
304 pub default: RoleColor,
307 pub accent: RoleColor,
309 pub success: RoleColor,
311 pub warning: RoleColor,
313 pub danger: RoleColor,
315
316 pub field: FieldColors,
319
320 pub border: Hsla,
323 pub separator: Hsla,
325 pub focus: Hsla,
327 pub link: Hsla,
329 pub backdrop: Hsla,
331}
332
333impl ThemeColors {
334 pub fn vibrant_palette(&self) -> bool {
339 self.accent.is_vibrant_palette()
340 }
341
342 pub fn set_vibrant_palette(&mut self, vibrant: bool) {
346 self.accent = self.accent.with_vibrant_palette(vibrant);
347 self.success = self.success.with_vibrant_palette(vibrant);
348 self.warning = self.warning.with_vibrant_palette(vibrant);
349 self.danger = self.danger.with_vibrant_palette(vibrant);
350 }
351
352 pub fn background_secondary(&self) -> Hsla {
356 mix_oklab(self.background, self.foreground, 0.04)
357 }
358
359 pub fn background_tertiary(&self) -> Hsla {
361 mix_oklab(self.background, self.foreground, 0.08)
362 }
363
364 pub fn background_inverse(&self) -> Hsla {
366 self.foreground
367 }
368
369 pub fn separator_secondary(&self) -> Hsla {
373 mix_oklab(self.surface.background, self.surface.foreground, 0.15)
374 }
375
376 pub fn separator_tertiary(&self) -> Hsla {
378 mix_oklab(self.surface.background, self.surface.foreground, 0.19)
379 }
380
381 pub fn border_secondary(&self) -> Hsla {
385 mix_oklab(self.surface.background, self.surface.foreground, 0.22)
386 }
387
388 pub fn border_tertiary(&self) -> Hsla {
390 mix_oklab(self.surface.background, self.surface.foreground, 0.34)
391 }
392
393 pub fn surface_secondary_foreground(&self) -> Hsla {
399 self.foreground
400 }
401
402 pub fn surface_tertiary_foreground(&self) -> Hsla {
404 self.foreground
405 }
406
407 pub fn role(&self, role: Color) -> &RoleColor {
409 match role {
410 Color::Default => &self.default,
411 Color::Accent => &self.accent,
412 Color::Success => &self.success,
413 Color::Warning => &self.warning,
414 Color::Danger => &self.danger,
415 }
416 }
417
418 pub(crate) fn role_mut(&mut self, role: Color) -> &mut RoleColor {
420 match role {
421 Color::Default => &mut self.default,
422 Color::Accent => &mut self.accent,
423 Color::Success => &mut self.success,
424 Color::Warning => &mut self.warning,
425 Color::Danger => &mut self.danger,
426 }
427 }
428
429 pub fn light() -> Self {
433 let foreground = eclipse();
434 let muted = oklch(0.5517, 0.0138, 285.94);
435 let accent = RoleColor::new(oklch(0.6204, 0.195, 253.83), snow())
436 .with_soft_mix(0.15, 0.20)
437 .with_soft_foreground_mix(70.0, 30.0);
438 Self {
439 background: oklch(0.9702, 0.0, 0.0),
440 foreground,
441 muted,
442 scrollbar: with_alpha(foreground, 0.15),
443
444 surface: SurfaceColor {
445 background: white(),
446 foreground,
447 },
448 surface_secondary: oklch(0.9524, 0.0013, 286.37),
449 surface_tertiary: oklch(0.9373, 0.0013, 286.37),
450 overlay: SurfaceColor {
451 background: white(),
452 foreground,
453 },
454 segment: SurfaceColor {
455 background: white(),
456 foreground: eclipse(),
457 },
458
459 default: RoleColor::new(oklch(0.94, 0.001, 286.375), eclipse())
460 .with_hover_mix(0.04)
461 .with_soft_mix(0.50, 0.60)
462 .with_soft_foreground_role(),
463 accent,
464 success: RoleColor::new(oklch(0.7329, 0.1935, 150.81), eclipse())
465 .with_soft_foreground_mix(80.0, 60.0),
466 warning: RoleColor::new(oklch(0.7819, 0.1585, 72.33), eclipse())
467 .with_soft_foreground_mix(80.0, 70.0),
468 danger: RoleColor::new(oklch(0.6532, 0.2328, 25.74), snow())
469 .with_soft_foreground_mix(70.0, 40.0),
470
471 field: FieldColors {
472 background: white(),
473 foreground: oklch(0.2103, 0.0059, 285.89),
474 placeholder: muted,
475 border: with_alpha(black(), 0.0),
476 },
477
478 border: oklch(0.9, 0.004, 286.32),
481 separator: oklch(0.92, 0.004, 286.32),
482 focus: accent.color,
483 link: foreground,
484 backdrop: with_alpha(black(), 0.5),
485 }
486 }
487
488 pub fn dark() -> Self {
492 let foreground = snow();
493 let muted = oklch(0.705, 0.015, 286.067);
494 let accent = RoleColor::new(oklch(0.6204, 0.195, 253.83), snow())
495 .with_soft_mix(0.12, 0.16)
496 .with_soft_foreground_mix(80.0, 30.0);
497 let default = RoleColor::new(oklch(0.274, 0.006, 286.033), snow())
498 .with_hover_mix(0.04)
499 .with_soft_mix(0.50, 0.60)
500 .with_soft_foreground_role();
501 Self {
502 background: oklch(0.12, 0.005, 285.823),
503 foreground,
504 muted,
505 scrollbar: with_alpha(foreground, 0.15),
506
507 surface: SurfaceColor {
508 background: oklch(0.2103, 0.0059, 285.89),
509 foreground,
510 },
511 surface_secondary: oklch(0.257, 0.0037, 286.14),
512 surface_tertiary: oklch(0.2721, 0.0024, 247.91),
513 overlay: SurfaceColor {
518 background: oklch(0.2103, 0.0059, 285.89),
519 foreground,
520 },
521 segment: SurfaceColor {
522 background: oklch(0.3964, 0.01, 285.93),
523 foreground,
524 },
525
526 default,
527 accent,
528 success: RoleColor::new(oklch(0.7329, 0.1935, 150.81), eclipse())
531 .with_soft_mix(0.12, 0.16)
532 .with_soft_foreground_mix(80.0, 30.0),
533 warning: RoleColor::new(oklch(0.8203, 0.1388, 76.34), eclipse())
534 .with_soft_mix(0.12, 0.16)
535 .with_soft_foreground_mix(80.0, 30.0),
536 danger: RoleColor::new(oklch(0.594, 0.1967, 24.63), snow())
537 .with_soft_foreground_mix(80.0, 30.0),
538
539 field: FieldColors {
540 background: oklch(0.2103, 0.0059, 285.89),
543 foreground,
544 placeholder: muted,
545 border: with_alpha(black(), 0.0),
546 },
547
548 border: oklch(0.28, 0.006, 286.033),
551 separator: oklch(0.25, 0.006, 286.033),
552 focus: accent.color,
553 link: foreground,
554 backdrop: with_alpha(black(), 0.6),
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn rgb8(c: Hsla) -> (u8, u8, u8) {
564 let r = gpui::Rgba::from(c);
565 (
566 (r.r * 255.0).round() as u8,
567 (r.g * 255.0).round() as u8,
568 (r.b * 255.0).round() as u8,
569 )
570 }
571
572 #[test]
573 fn an_explicit_role_hover_replaces_the_derived_mix() {
574 let stock = ThemeColors::light();
575 let derived = stock.accent.hover();
576 let named = gpui::hsla(0.6, 0.9, 0.3, 1.0);
579 let role = stock.accent.with_hover(named);
580 assert_ne!(rgb8(derived), rgb8(named), "the fixture must differ");
581 assert_eq!(rgb8(role.hover()), rgb8(named));
582 assert_eq!(role.hover_override(), Some(named));
583 assert_eq!(rgb8(role.soft()), rgb8(stock.accent.soft()));
586 assert_eq!(rgb8(role.soft_hover()), rgb8(stock.accent.soft_hover()));
587 assert_eq!(rgb8(role.color), rgb8(stock.accent.color));
588 }
589
590 #[test]
591 fn a_named_role_hover_outranks_the_hover_mix_whichever_order_they_are_set() {
592 let base = ThemeColors::light().accent;
593 let named = gpui::hsla(0.1, 0.8, 0.4, 1.0);
594 assert_eq!(
595 rgb8(base.with_hover(named).with_hover_mix(0.5).hover()),
596 rgb8(named)
597 );
598 assert_eq!(
599 rgb8(base.with_hover_mix(0.5).with_hover(named).hover()),
600 rgb8(named)
601 );
602 }
603
604 #[test]
605 fn accent_soft_is_fifteen_percent_accent() {
606 let c = ThemeColors::light();
607 assert!((c.accent.soft().a - 0.15).abs() < 1e-4);
608 assert!((c.accent.soft_hover().a - 0.20).abs() < 1e-4);
609 }
610
611 #[test]
612 fn light_scrollbar_thumb_is_fifteen_percent_foreground() {
613 let c = ThemeColors::light();
614 assert_eq!(c.scrollbar, with_alpha(c.foreground, 0.15));
615 }
616
617 #[test]
618 fn dark_scrollbar_thumb_is_fifteen_percent_foreground() {
619 let c = ThemeColors::dark();
620 assert_eq!(c.scrollbar, with_alpha(c.foreground, 0.15));
621 }
622
623 #[test]
624 fn the_soft_matrix_matches_the_pinned_stylesheet() {
625 let light = ThemeColors::light();
629 let dark = ThemeColors::dark();
630 for (colors, role_name, soft, soft_hover, foreground) in [
631 (&light, "default", 0.50, 0.60, None),
632 (&light, "accent", 0.15, 0.20, Some((70.0, 30.0))),
633 (&light, "success", 0.15, 0.20, Some((80.0, 60.0))),
634 (&light, "warning", 0.15, 0.20, Some((80.0, 70.0))),
635 (&light, "danger", 0.15, 0.20, Some((70.0, 40.0))),
636 (&dark, "default", 0.50, 0.60, None),
637 (&dark, "accent", 0.12, 0.16, Some((80.0, 30.0))),
638 (&dark, "success", 0.12, 0.16, Some((80.0, 30.0))),
639 (&dark, "warning", 0.12, 0.16, Some((80.0, 30.0))),
640 (&dark, "danger", 0.15, 0.20, Some((80.0, 30.0))),
641 ] {
642 let role = colors.role(Color::from_token(role_name).unwrap());
643 assert!(
644 (role.soft().a - soft).abs() < 1e-4,
645 "{role_name} soft alpha"
646 );
647 assert!(
648 (role.soft_hover().a - soft_hover).abs() < 1e-4,
649 "{role_name} soft-hover alpha"
650 );
651 match foreground {
652 Some((color, page)) => assert_eq!(
653 role.soft_foreground(colors.foreground),
654 mix_oklab(role.color, colors.foreground, page / (color + page)),
655 "{role_name} soft-foreground mix"
656 ),
657 None => assert_eq!(
658 role.soft_foreground(colors.foreground),
659 role.foreground,
660 "{role_name} soft-foreground follows the role"
661 ),
662 }
663 }
664 }
665
666 #[test]
671 fn every_token_resolves_to_the_stylesheets_own_pixels() {
672 type Pick = fn(&ThemeColors) -> Hsla;
673 type Row = (&'static str, Pick, (u8, u8, u8, f32), (u8, u8, u8, f32));
675 #[rustfmt::skip]
676 let table: &[Row] = &[
677 ("accent", |c| c.accent.color, (4, 133, 247, 1.00), (4, 133, 247, 1.00)),
678 ("accent_foreground", |c| c.accent.foreground, (252, 252, 252, 1.00), (252, 252, 252, 1.00)),
679 ("accent_hover", |c| c.accent.hover(), (53, 146, 249, 1.00), (53, 146, 249, 1.00)),
680 ("accent_soft", |c| c.accent.soft(), (4, 133, 247, 0.15), (4, 133, 247, 0.12)),
681 ("accent_soft_foreground", |c| c.accent.soft_foreground(c.foreground), (30, 99, 174, 1.00), (97, 168, 251, 1.00)),
682 ("backdrop", |c| c.backdrop, (0, 0, 0, 0.50), (0, 0, 0, 0.60)),
683 ("background", |c| c.background, (245, 245, 245, 1.00), (6, 6, 7, 1.00)),
684 ("background_inverse", |c| c.background_inverse(), (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
685 ("background_secondary", |c| c.background_secondary(), (235, 235, 235, 1.00), (12, 12, 14, 1.00)),
686 ("background_tertiary", |c| c.background_tertiary(), (225, 225, 225, 1.00), (19, 19, 22, 1.00)),
687 ("border", |c| c.border, (221, 222, 224, 1.00), (40, 40, 44, 1.00)),
688 ("border_secondary", |c| c.border_secondary(), (198, 198, 199, 1.00), (67, 67, 69, 1.00)),
689 ("border_tertiary", |c| c.border_tertiary(), (168, 168, 169, 1.00), (92, 92, 95, 1.00)),
690 ("danger", |c| c.danger.color, (255, 56, 60, 1.00), (219, 59, 62, 1.00)),
691 ("danger_foreground", |c| c.danger.foreground, (252, 252, 252, 1.00), (252, 252, 252, 1.00)),
692 ("danger_hover", |c| c.danger.hover(), (255, 85, 81, 1.00), (225, 84, 81, 1.00)),
693 ("danger_soft", |c| c.danger.soft(), (255, 56, 60, 0.15), (219, 59, 62, 0.15)),
694 ("danger_soft_foreground", |c| c.danger.soft_foreground(c.foreground), (164, 53, 51, 1.00), (235, 120, 114, 1.00)),
695 ("default", |c| c.default.color, (235, 235, 236, 1.00), (39, 39, 42, 1.00)),
696 ("default_foreground", |c| c.default.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
697 ("default_hover", |c| c.default.hover(), (225, 225, 226, 1.00), (46, 46, 49, 1.00)),
698 ("default_soft", |c| c.default.soft(), (235, 235, 235, 0.50), (39, 39, 42, 0.50)),
699 ("default_soft_foreground", |c| c.default.soft_foreground(c.foreground), (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
700 ("field_background", |c| c.field.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
701 ("field_foreground", |c| c.field.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
702 ("field_placeholder", |c| c.field.placeholder, (113, 113, 122, 1.00), (159, 159, 169, 1.00)),
703 ("focus", |c| c.focus, (4, 133, 247, 1.00), (4, 133, 247, 1.00)),
704 ("foreground", |c| c.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
705 ("link", |c| c.link, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
706 ("muted", |c| c.muted, (113, 113, 122, 1.00), (159, 159, 169, 1.00)),
707 ("overlay", |c| c.overlay.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
708 ("overlay_foreground", |c| c.overlay.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
709 ("scrollbar", |c| c.scrollbar, (24, 24, 27, 0.15), (255, 255, 255, 0.15)),
710 ("segment", |c| c.segment.background, (255, 255, 255, 1.00), (70, 70, 76, 1.00)),
711 ("segment_foreground", |c| c.segment.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
712 ("separator", |c| c.separator, (228, 228, 231, 1.00), (33, 33, 36, 1.00)),
713 ("separator_secondary", |c| c.separator_secondary(), (216, 216, 216, 1.00), (52, 52, 55, 1.00)),
714 ("separator_tertiary", |c| c.separator_tertiary(), (205, 205, 206, 1.00), (60, 60, 63, 1.00)),
715 ("success", |c| c.success.color, (23, 201, 100, 1.00), (23, 201, 100, 1.00)),
716 ("success_foreground", |c| c.success.foreground, (24, 24, 27, 1.00), (24, 24, 27, 1.00)),
717 ("success_hover", |c| c.success.hover(), (33, 181, 93, 1.00), (33, 181, 93, 1.00)),
718 ("success_soft", |c| c.success.soft(), (23, 201, 100, 0.15), (23, 201, 100, 0.12)),
719 ("success_soft_foreground", |c| c.success.soft_foreground(c.foreground), (43, 119, 69, 1.00), (116, 216, 143, 1.00)),
720 ("surface", |c| c.surface.background, (255, 255, 255, 1.00), (24, 24, 27, 1.00)),
721 ("surface_foreground", |c| c.surface.foreground, (24, 24, 27, 1.00), (252, 252, 252, 1.00)),
722 ("surface_hover", |c| c.surface.hover(), (234, 234, 234, 1.00), (39, 39, 42, 1.00)),
723 ("surface_secondary", |c| c.surface_secondary, (239, 239, 240, 1.00), (35, 35, 37, 1.00)),
724 ("surface_tertiary", |c| c.surface_tertiary, (234, 234, 235, 1.00), (38, 39, 40, 1.00)),
725 ("warning", |c| c.warning.color, (245, 165, 36, 1.00), (247, 183, 80, 1.00)),
726 ("warning_foreground", |c| c.warning.foreground, (24, 24, 27, 1.00), (24, 24, 27, 1.00)),
727 ("warning_hover", |c| c.warning.hover(), (220, 150, 42, 1.00), (222, 165, 76, 1.00)),
728 ("warning_soft", |c| c.warning.soft(), (245, 165, 36, 0.15), (247, 183, 80, 0.12)),
729 ("warning_soft_foreground", |c| c.warning.soft_foreground(c.foreground), (133, 95, 46, 1.00), (249, 203, 134, 1.00)),
730 ];
731 for (name, pick, light, dark) in table {
732 for (mode, c, want) in [
733 ("light", ThemeColors::light(), light),
734 ("dark", ThemeColors::dark(), dark),
735 ] {
736 let got = pick(&c);
737 let (r, g, b) = rgb8(got);
738 let slack = (2.0 / want.3).ceil() as i32;
741 let near = |a: u8, b: u8| (a as i32 - b as i32).abs() <= slack;
742 assert!(
743 near(r, want.0) && near(g, want.1) && near(b, want.2)
744 && (got.a - want.3).abs() < 0.011,
745 "{name} ({mode}) resolved to ({r}, {g}, {b}, {:.3}), upstream resolves ({}, {}, {}, {:.3})",
746 got.a, want.0, want.1, want.2, want.3
747 );
748 }
749 }
750 }
751
752 #[test]
753 fn soft_foreground_resolves_to_the_stylesheets_own_pixels() {
754 let c = ThemeColors::light();
758 for (role_name, expected) in [
759 ("accent", (30, 99, 174)),
760 ("success", (43, 119, 69)),
761 ("warning", (133, 95, 46)),
762 ("danger", (164, 53, 51)),
763 ] {
764 let got = rgb8(
765 c.role(Color::from_token(role_name).unwrap())
766 .soft_foreground(c.foreground),
767 );
768 let near = |a: u8, b: u8| (a as i32 - b as i32).abs() <= 2;
769 assert!(
770 near(got.0, expected.0) && near(got.1, expected.1) && near(got.2, expected.2),
771 "{role_name} soft-foreground was {got:?}, expected {expected:?}"
772 );
773 }
774 }
775
776 #[test]
777 fn a_custom_foreground_stays_live_in_the_soft_foreground_mix() {
778 let base = ThemeColors::light();
779 let custom = ThemeColors {
780 foreground: oklch(0.30, 0.05, 120.0),
781 ..base
782 };
783 assert_eq!(
786 custom.accent.soft_foreground(custom.foreground),
787 mix_oklab(custom.accent.color, custom.foreground, 0.30)
788 );
789 assert_ne!(
790 custom.accent.soft_foreground(custom.foreground),
791 base.accent.soft_foreground(base.foreground)
792 );
793 assert_eq!(
796 custom.default.soft_foreground(custom.foreground),
797 custom.default.foreground
798 );
799 }
800
801 #[test]
802 fn the_derived_borders_step_away_from_the_surface() {
803 let c = ThemeColors::light();
804 let steps = [
808 c.separator_secondary(),
809 c.border_secondary(),
810 c.border_tertiary(),
811 ];
812 for pair in steps.windows(2) {
813 assert!(
814 pair[1].l < pair[0].l,
815 "each step is darker than the last on a light surface"
816 );
817 }
818 }
819
820 #[test]
821 fn a_secondary_surface_keeps_the_page_foreground() {
822 let c = ThemeColors::light();
823 assert_eq!(c.surface_secondary_foreground(), c.foreground);
824 assert_eq!(c.surface_tertiary_foreground(), c.foreground);
825 }
826
827 #[test]
828 fn a_field_border_mixes_toward_its_own_foreground() {
829 let c = ThemeColors::light();
830 let border = c.field.border;
833 assert_ne!(c.field.border_hover(), border);
834 assert_ne!(c.field.border_focus(), c.field.border_hover());
835 }
836
837 #[test]
838 fn field_focus_matches_field_background() {
839 let c = ThemeColors::light();
840 assert_eq!(c.field.focus(), c.field.background);
841 }
842
843 #[test]
844 #[ignore = "v3 gives dark mode one colour for both; the shadow separates them"]
845 fn dark_surface_is_darker_than_overlay() {
846 let c = ThemeColors::dark();
847 assert!(c.surface.background.l < c.overlay.background.l);
848 }
849
850 #[test]
851 fn light_background_is_lighter_than_its_derived_levels() {
852 let c = ThemeColors::light();
853 assert!(c.background.l > c.background_secondary().l);
854 assert!(c.background_secondary().l > c.background_tertiary().l);
855 }
856}