1use std::ops::Range;
2
3use gpui_kit::{
4 FontWeight, Hsla, Pixels, SharedString, Size,
5 base::{
6 ColorTokens, RadiusTokens, SemanticThemeTokens, ShadowTokens, SpacingTokens,
7 TextStyleToken, ThemeAppearance, TypographyTokens,
8 },
9 px,
10};
11
12use super::{ThemeConfig, color::mix};
13
14const BASE_FONT_SIZE: f32 = 16.;
16
17#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct ThemeTokens {
27 pub appearance: ThemeAppearance,
29 pub touch: bool,
32 pub base: SemanticThemeTokens,
34 pub selected: Hsla,
37 pub link: Hsla,
40 pub tooltip: Hsla,
43 pub tooltip_foreground: Hsla,
45 pub scrim: Hsla,
49 pub dialog_overlay: Hsla,
54 pub sidebar: Hsla,
57 pub sidebar_border: Hsla,
59 pub sidebar_accent: Hsla,
64 pub sidebar_selected: Hsla,
66 pub sidebar_muted_foreground: Hsla,
68 pub text_control: TextStyleToken,
71 pub text_heading: TextStyleToken,
73 pub text_title: TextStyleToken,
75 pub metrics: MetricTokens,
77 pub disabled_opacity: f32,
82 pub terminal_cursor_opacity: f32,
86 pub switch_track_on: Hsla,
91 pub switch_track_off: Hsla,
93 pub switch_thumb: Hsla,
97 pub success: Hsla,
99 pub warning: Hsla,
101 pub info: Hsla,
104 pub destructive_solid: Hsla,
108 pub success_solid: Hsla,
110 pub warning_solid: Hsla,
112 pub info_solid: Hsla,
114 pub solid_foreground: Hsla,
116 pub destructive_tint: Hsla,
119 pub success_tint: Hsla,
122 pub warning_tint: Hsla,
125 pub info_tint: Hsla,
128 pub progress_track: Hsla,
131 pub popover: Hsla,
135 pub popover_foreground: Hsla,
138 pub popover_border: Hsla,
141 pub popover_accent: Hsla,
145 pub popover_separator: Hsla,
148 pub popover_muted_foreground: Hsla,
152 pub select_indicator: Hsla,
156 pub select_trigger: Hsla,
162 pub select_trigger_border: Hsla,
167 pub tab_separator: Hsla,
170 pub field: Hsla,
174 pub field_border: Hsla,
178 pub field_focus_border: Hsla,
183 pub text_textarea: TextStyleToken,
187 pub dock_drop: Hsla,
192 pub dock_handle: Hsla,
196 pub dock_handle_hover: Hsla,
199 pub dock_unfocused: Hsla,
204}
205
206#[derive(Clone, Debug, PartialEq)]
213#[non_exhaustive]
214pub struct MetricTokens {
215 pub control_xs: Pixels,
217 pub control_sm: Pixels,
219 pub control_md: Pixels,
221 pub control_lg: Pixels,
223 pub control_padding_xs: Pixels,
225 pub control_padding_sm: Pixels,
227 pub control_padding_md: Pixels,
229 pub control_padding_lg: Pixels,
231 pub row_sm: Pixels,
233 pub row: Pixels,
235 pub row_lg: Pixels,
237 pub title_bar: Pixels,
240 pub title_bar_content_offset: Pixels,
245 pub window_controls_position: Pixels,
249 pub window_controls_inset: Pixels,
252 pub window_control_width: Pixels,
254 pub sidebar_width: Pixels,
256 pub sidebar_width_range: Range<Pixels>,
258 pub icon_sidebar_width: Pixels,
260 pub sidebar_sheet_breakpoint: Pixels,
264 pub sidebar_sheet_width: Pixels,
267 pub resize_handle: Pixels,
269 pub focus_ring: Pixels,
271 pub field_height: Pixels,
276 pub field_padding_x: Pixels,
280 pub field_padding_y: Pixels,
284 pub scrollbar_thumb: Pixels,
287 pub scrollbar_thumb_active: Pixels,
290 pub scrollbar_inset: Pixels,
292 pub switch_track_width: Pixels,
295 pub switch_track_height: Pixels,
298 pub switch_thumb_size: Pixels,
301 pub switch_thumb_inset: Pixels,
304 pub slider_track: Pixels,
306 pub slider_thumb: Pixels,
308 pub slider_tick: Pixels,
311 pub radio_size: Pixels,
313 pub radio_dot: Pixels,
316 pub ring_size: Pixels,
319 pub ring_stroke: Pixels,
321 pub attachment_tile: Pixels,
324 pub model_picker_width: Pixels,
327 pub model_picker_height: Pixels,
332 pub model_picker_rail: Pixels,
335 pub model_row: Pixels,
338 pub model_header: Pixels,
341 pub status_tab_height: Pixels,
345 pub status_tab_gap: Pixels,
348 pub status_label_gap: Pixels,
351 pub status_tab_inset: Pixels,
354 pub menu_gap: Pixels,
357 pub menu_min_width: Pixels,
360 pub menu_search_min_width: Pixels,
364 pub menu_max_width: Pixels,
367 pub menu_max_height: Pixels,
370 pub dialog_width: Pixels,
374 pub dialog_padding: Pixels,
377 pub command_dialog_width: Pixels,
380 pub command_dialog_top: Pixels,
386 pub folder_list_height: Pixels,
391 pub tab_surface_inset: Pixels,
394 pub tab_width: Pixels,
396 pub tab_min_width: Pixels,
399 pub tab_shoulder: Pixels,
402 pub tab_radius: Pixels,
405 pub tab_separator_gutter: Pixels,
408 pub tab_separator_inset: Pixels,
411 pub tab_icon: Pixels,
413 pub tab_content_gap: Pixels,
416 pub tab_control: Pixels,
420 pub tab_close: Pixels,
424 pub tag_height: Pixels,
426 pub tag_icon: Pixels,
428 pub badge_count: Pixels,
431 pub badge_count_text: Pixels,
433 pub badge_count_offset: Pixels,
436 pub badge_dot: Pixels,
438 pub badge_icon: Pixels,
440 pub avatar_sm: Pixels,
442 pub avatar_md: Pixels,
444 pub avatar_lg: Pixels,
446 pub avatar_initials_sm: Pixels,
449 pub avatar_initials_md: Pixels,
451 pub avatar_initials_lg: Pixels,
453 pub avatar_group_overlap: Pixels,
456 pub avatar_group_ring: Pixels,
458 pub terminal_padding: Pixels,
465 pub terminal_cursor_thickness: Pixels,
468 pub dock_handle_width: Pixels,
472 pub dock_handle_height: Pixels,
475 pub dock_handle_icon: Pixels,
477 pub dock_handle_reveal: f32,
481 pub dock_drag_threshold: Pixels,
485 pub dock_card: Size<Pixels>,
489}
490
491impl MetricTokens {
492 fn derive(ui_font_size: Pixels, touch: bool) -> Self {
498 let factor = f32::from(ui_font_size) / BASE_FONT_SIZE;
499 let scaled = |value: f32| px(value * factor);
500 let (control_xs, control_sm, control_md, control_lg) = if touch {
501 (px(36.), px(40.), px(44.), px(48.))
502 } else {
503 (scaled(20.), scaled(24.), scaled(28.), scaled(32.))
504 };
505 let (row_sm, row, row_lg) = if touch {
506 (px(40.), px(44.), px(56.))
507 } else {
508 (scaled(28.), scaled(30.), scaled(48.))
509 };
510 let title_bar = px(if touch { 44. } else { 38. });
511 let control_padding_sm = scaled(8.);
512 Self {
513 control_xs,
514 control_sm,
515 control_md,
516 control_lg,
517 control_padding_xs: scaled(6.),
518 control_padding_sm,
519 control_padding_md: scaled(if touch { 14. } else { 10. }),
520 control_padding_lg: scaled(18.),
521 row_sm,
522 row,
523 row_lg,
524 title_bar,
525 title_bar_content_offset: px(if touch { 0. } else { 2. }),
526 window_controls_position: px(14.),
527 window_controls_inset: px(88.),
528 window_control_width: px(46.),
529 sidebar_width: px(300.),
530 sidebar_width_range: px(220.)..px(480.),
531 icon_sidebar_width: px(48.),
532 sidebar_sheet_breakpoint: px(768.),
533 sidebar_sheet_width: px(288.),
534 resize_handle: px(8.),
535 focus_ring: px(3.),
536 field_height: if touch { control_md } else { control_lg },
537 field_padding_x: scaled(12.),
538 field_padding_y: control_padding_sm,
539 scrollbar_thumb: px(7.),
540 scrollbar_thumb_active: px(11.),
541 scrollbar_inset: px(2.),
542 switch_track_width: if touch { px(51.) } else { scaled(32.) },
543 switch_track_height: if touch { px(31.) } else { scaled(20.) },
544 switch_thumb_size: if touch { px(27.) } else { scaled(16.) },
545 switch_thumb_inset: if touch { px(2.) } else { scaled(2.) },
546 slider_track: scaled(4.),
547 slider_thumb: scaled(16.),
548 slider_tick: scaled(4.),
549 radio_size: scaled(16.),
550 radio_dot: scaled(6.),
551 ring_size: scaled(16.),
552 ring_stroke: scaled(2.),
553 attachment_tile: scaled(56.),
554 model_picker_width: scaled(361.),
555 model_picker_height: scaled(346.),
556 model_picker_rail: scaled(44.),
557 model_row: scaled(47.),
558 model_header: scaled(42.),
559 status_tab_height: scaled(38.),
560 status_tab_inset: scaled(14.),
561 status_tab_gap: scaled(28.),
562 status_label_gap: scaled(8.),
563 menu_gap: scaled(2.),
564 menu_min_width: scaled(128.),
565 menu_search_min_width: scaled(240.),
566 menu_max_width: scaled(360.),
567 menu_max_height: scaled(390.),
568 dialog_width: scaled(480.),
569 dialog_padding: scaled(20.),
570 command_dialog_width: scaled(512.),
571 command_dialog_top: scaled(120.),
572 folder_list_height: row_sm * 6.5,
573 tab_surface_inset: px(if touch { 0. } else { 4. }),
574 tab_width: px(160.),
575 tab_min_width: px(112.),
576 tab_shoulder: px(7.),
577 tab_radius: px(7.),
578 tab_separator_gutter: px(7.),
579 tab_separator_inset: px(11.),
580 tab_icon: px(13.),
581 tab_content_gap: px(6.),
582 tab_control: control_sm,
583 tab_close: control_xs,
584 tag_height: scaled(20.),
585 tag_icon: scaled(12.),
586 badge_count: scaled(16.),
587 badge_count_text: scaled(10.),
588 badge_count_offset: scaled(4.),
589 badge_dot: scaled(6.),
590 badge_icon: scaled(16.),
591 avatar_sm: scaled(24.),
592 avatar_md: scaled(32.),
593 avatar_lg: scaled(40.),
594 avatar_initials_sm: scaled(10.),
595 avatar_initials_md: scaled(12.),
596 avatar_initials_lg: scaled(14.),
597 avatar_group_overlap: scaled(8.),
598 avatar_group_ring: px(2.),
599 terminal_padding: px(2.),
600 terminal_cursor_thickness: px(2.),
601 dock_handle_width: px(80.),
602 dock_handle_height: if touch { control_md } else { px(12.) },
603 dock_handle_icon: px(12.),
604 dock_handle_reveal: 0.2,
605 dock_drag_threshold: px(6.),
606 dock_card: Size::new(px(380.), px(260.)),
607 }
608 }
609}
610
611#[derive(Clone, Copy, Debug, PartialEq)]
613pub(super) struct Metrics {
614 pub radius: Pixels,
615 pub ui_font_size: Pixels,
616 pub code_font_size: Pixels,
617 pub touch: bool,
619}
620
621impl ThemeTokens {
622 pub(super) fn derive(
629 config: &ThemeConfig,
630 appearance: ThemeAppearance,
631 metrics: Metrics,
632 ) -> Self {
633 let dark = appearance == ThemeAppearance::Dark;
634 let c = f32::from(config.contrast) / 50.;
635 let surface = config.surface;
636 let ink = config.ink;
637 let floor = super::color::lightness(super::color::hex("#181818"));
642 let base = {
643 let (from, to) = (
644 super::color::lightness(surface),
645 super::color::lightness(ink),
646 );
647 if dark && from < floor && to > floor {
648 mix(surface, ink, (floor - from) / (to - from))
649 } else {
650 surface
651 }
652 };
653 let toward_ink = |amount: f32| mix(base, ink, amount);
654
655 let soft = toward_ink(0.044 * c);
664 let selected = toward_ink(if dark { 0.0867 } else { 0.10 } * c);
665 let border = toward_ink(0.07);
666 let input = toward_ink(0.10);
667 let foreground = if dark { mix(ink, surface, 0.12) } else { ink };
668 let muted_foreground = mix(ink, surface, if dark { 0.412 } else { 0.38 });
669 let card = if dark {
670 toward_ink(0.0475 * c)
671 } else {
672 surface
673 };
674 let link = if dark {
677 mix(config.accent, ink, 0.35)
678 } else {
679 config.accent
680 };
681 let (tooltip, tooltip_foreground) = if dark {
684 (toward_ink(0.016), ink)
685 } else {
686 (ink, surface)
687 };
688 let primary = mix(ink, surface, 0.12);
691 let primary_foreground = mix(surface, ink, 0.11);
692 let sidebar = if dark { soft } else { toward_ink(0.008) };
697 let sidebar_step = |amount: f32| mix(sidebar, ink, amount);
698 let sidebar_accent = sidebar_step(if dark { 0.055 } else { 0.025 });
699 let sidebar_selected = sidebar_step(if dark { 0.095 } else { 0.05 });
700 let sidebar_border = sidebar_step(if dark { 0.10 } else { 0.07 });
701 let sidebar_muted_foreground = mix(ink, sidebar, if dark { 0.59 } else { 0.665 });
702 let destructive_foreground = readable_on(config.semantic.destructive, surface, ink);
703 let switch_track_on = config.accent;
706 let switch_track_off = toward_ink(0.20);
707 let switch_thumb = lighter_of(surface, ink);
708 let progress_track = primary.opacity(0.2);
709 let solid_foreground = lighter_of(surface, ink);
714 let darker = if solid_foreground == surface {
715 ink
716 } else {
717 surface
718 };
719 let solid = |color: Hsla| {
720 (0..=20)
721 .map(|step| mix(color, darker, step as f32 * 0.04))
722 .find(|fill| super::color::contrast_ratio(*fill, solid_foreground) >= 4.5)
723 .unwrap_or(darker)
724 };
725 let tint = |color: Hsla| mix(surface, color, if dark { 0.1667 } else { 0.111 } * c);
726 let popover = if dark { toward_ink(0.109) } else { surface };
727 let popover_step = |amount: f32| mix(popover, ink, amount);
728 let popover_border = if dark { popover_step(0.13) } else { border };
729 let popover_accent = popover_step(if dark { 0.09 } else { 0.033 });
730 let popover_separator = if dark { popover_step(0.095) } else { border };
731 let popover_muted_foreground = mix(ink, popover, if dark { 0.32 } else { 0.38 });
732 let select_indicator = mix(ink, popover, if dark { 0.227 } else { 0.38 });
733 let select_trigger = if dark { toward_ink(0.09) } else { surface };
734 let select_trigger_border = if dark { toward_ink(0.178) } else { input };
735 let tab_separator = muted_foreground.opacity(0.6);
736 let field = if dark { toward_ink(0.104) } else { surface };
737 let field_border = if dark { toward_ink(0.181) } else { input };
738 let field_focus_border = if dark {
739 mix(field, link, 0.78)
740 } else {
741 config.accent
742 };
743
744 let colors = ColorTokens {
745 background: surface,
746 foreground,
747 surface: card,
748 surface_foreground: foreground,
749 primary,
750 primary_foreground,
751 secondary: soft,
752 secondary_foreground: foreground,
753 muted: soft,
754 muted_foreground,
755 accent: soft,
756 accent_foreground: foreground,
757 destructive: config.semantic.destructive,
758 destructive_foreground,
759 border,
760 input,
761 ring: config.accent,
762 selection: config.accent.alpha(0.3),
763 };
764
765 let typography = typography(config, metrics);
766 let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
767 let text_control = TextStyleToken {
771 size: px(if metrics.touch { 17. } else { 13. } * factor),
772 line_height: px(if metrics.touch { 22. } else { 16. } * factor),
773 weight: FontWeight::NORMAL,
774 };
775 let text_textarea = TextStyleToken {
776 line_height: px(if metrics.touch { 22. } else { 18.5 } * factor),
777 ..text_control
778 };
779 let text_heading = TextStyleToken {
780 size: px(if metrics.touch { 17. } else { 15. } * factor),
781 line_height: px(22. * factor),
782 weight: if metrics.touch {
783 FontWeight::SEMIBOLD
784 } else {
785 FontWeight::MEDIUM
786 },
787 };
788 let text_title = TextStyleToken {
789 size: px(if metrics.touch { 28. } else { 24. } * factor),
790 line_height: px(if metrics.touch { 34. } else { 32. } * factor),
791 weight: FontWeight::SEMIBOLD,
792 };
793 let shadow = ShadowTokens::elevations(if dark {
794 gpui_kit::black().alpha(0.4)
795 } else {
796 ink.alpha(0.1)
797 });
798
799 Self {
800 appearance,
801 touch: metrics.touch,
802 base: SemanticThemeTokens {
803 colors,
804 radius: radius_scale(metrics.radius),
805 spacing: SpacingTokens::default(),
806 typography,
807 shadow,
808 },
809 selected,
810 link,
811 tooltip,
812 tooltip_foreground,
813 scrim: gpui_kit::hsla(0., 0., 0., 0.5),
814 dialog_overlay: gpui_kit::hsla(0., 0., 0., 0.25),
815 sidebar,
816 sidebar_border,
817 sidebar_accent,
818 sidebar_selected,
819 sidebar_muted_foreground,
820 text_control,
821 text_heading,
822 text_title,
823 metrics: MetricTokens::derive(metrics.ui_font_size, metrics.touch),
824 disabled_opacity: 0.55,
825 terminal_cursor_opacity: 0.55,
826 switch_track_on,
827 switch_track_off,
828 switch_thumb,
829 success: config.semantic.success,
830 warning: config.semantic.warning,
831 info: config.accent,
832 destructive_solid: solid(config.semantic.destructive),
833 success_solid: solid(config.semantic.success),
834 warning_solid: solid(config.semantic.warning),
835 info_solid: solid(config.accent),
836 solid_foreground,
837 destructive_tint: tint(config.semantic.destructive),
838 success_tint: tint(config.semantic.success),
839 warning_tint: tint(config.semantic.warning),
840 info_tint: tint(config.accent),
841 progress_track,
842 popover,
843 popover_foreground: ink,
844 popover_border,
845 popover_accent,
846 popover_separator,
847 popover_muted_foreground,
848 select_indicator,
849 select_trigger,
850 select_trigger_border,
851 tab_separator,
852 field,
853 field_border,
854 field_focus_border,
855 text_textarea,
856 dock_drop: config.accent.opacity(0.3),
857 dock_handle: foreground.opacity(0.3),
858 dock_handle_hover: foreground.opacity(0.8),
859 dock_unfocused: surface.opacity(0.3),
860 }
861 }
862
863 pub fn is_dark(&self) -> bool {
865 self.appearance == ThemeAppearance::Dark
866 }
867
868 pub fn colors(&self) -> &ColorTokens {
870 &self.base.colors
871 }
872
873 pub fn background(&self) -> Hsla {
875 self.base.colors.background
876 }
877
878 pub fn foreground(&self) -> Hsla {
880 self.base.colors.foreground
881 }
882
883 pub fn primary(&self) -> Hsla {
885 self.base.colors.primary
886 }
887
888 pub fn primary_foreground(&self) -> Hsla {
890 self.base.colors.primary_foreground
891 }
892
893 pub fn secondary(&self) -> Hsla {
895 self.base.colors.secondary
896 }
897
898 pub fn secondary_foreground(&self) -> Hsla {
900 self.base.colors.secondary_foreground
901 }
902
903 pub fn muted(&self) -> Hsla {
905 self.base.colors.muted
906 }
907
908 pub fn muted_foreground(&self) -> Hsla {
910 self.base.colors.muted_foreground
911 }
912
913 pub fn accent(&self) -> Hsla {
915 self.base.colors.accent
916 }
917
918 pub fn accent_foreground(&self) -> Hsla {
920 self.base.colors.accent_foreground
921 }
922
923 pub fn destructive(&self) -> Hsla {
925 self.base.colors.destructive
926 }
927
928 pub fn destructive_foreground(&self) -> Hsla {
930 self.base.colors.destructive_foreground
931 }
932
933 pub fn border(&self) -> Hsla {
935 self.base.colors.border
936 }
937
938 pub fn input(&self) -> Hsla {
940 self.base.colors.input
941 }
942
943 pub fn ring(&self) -> Hsla {
945 self.base.colors.ring
946 }
947
948 pub fn focus_ring(&self) -> Hsla {
951 self.ring().opacity(0.5)
952 }
953
954 pub fn selection(&self) -> Hsla {
956 self.base.colors.selection
957 }
958
959 pub fn font_family(&self) -> &SharedString {
961 &self.base.typography.sans
962 }
963
964 pub fn mono_font_family(&self) -> &SharedString {
966 &self.base.typography.mono
967 }
968
969 pub fn radius_sm(&self) -> Pixels {
971 self.base.radius.sm
972 }
973
974 pub fn radius_md(&self) -> Pixels {
976 self.base.radius.md
977 }
978
979 pub fn radius_lg(&self) -> Pixels {
981 self.base.radius.lg
982 }
983
984 pub fn radius_xl(&self) -> Pixels {
986 self.base.radius.xl
987 }
988
989 pub fn radius_full(&self) -> Pixels {
991 self.base.radius.full
992 }
993}
994
995fn radius_scale(radius: Pixels) -> RadiusTokens {
997 RadiusTokens {
998 none: px(0.),
999 sm: radius * 0.6,
1000 md: radius * 0.8,
1001 lg: radius,
1002 xl: radius * 1.4,
1003 full: px(9999.),
1004 }
1005}
1006
1007fn typography(config: &ThemeConfig, metrics: Metrics) -> TypographyTokens {
1009 let defaults = TypographyTokens::default();
1010 let factor = f32::from(metrics.ui_font_size) / BASE_FONT_SIZE;
1011 let scale = |token: TextStyleToken| TextStyleToken {
1012 size: token.size * factor,
1013 line_height: token.line_height * factor,
1014 weight: token.weight,
1015 };
1016 let code_factor = f32::from(metrics.code_font_size) / f32::from(defaults.mono_md.size);
1017 TypographyTokens {
1018 sans: config.fonts.ui.clone().unwrap_or(defaults.sans),
1019 mono: config.fonts.code.clone().unwrap_or(defaults.mono),
1020 xs: scale(defaults.xs),
1021 sm: scale(defaults.sm),
1022 md: scale(defaults.md),
1023 lg: scale(defaults.lg),
1024 xl: scale(defaults.xl),
1025 mono_md: TextStyleToken {
1026 size: metrics.code_font_size,
1027 line_height: defaults.mono_md.line_height * code_factor,
1028 weight: defaults.mono_md.weight,
1029 },
1030 }
1031}
1032
1033fn lighter_of(a: Hsla, b: Hsla) -> Hsla {
1035 use super::color::lightness;
1036 if lightness(a) >= lightness(b) { a } else { b }
1037}
1038
1039fn readable_on(background: Hsla, surface: Hsla, ink: Hsla) -> Hsla {
1045 use super::color::lightness;
1046 const LIGHT_BACKGROUND: f32 = 0.72;
1047 let lighter = lighter_of(surface, ink);
1048 let darker = if lighter == surface { ink } else { surface };
1049 if lightness(background) > LIGHT_BACKGROUND {
1050 darker
1051 } else {
1052 lighter
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use crate::theme::color::{hex, lightness, to_hex};
1060
1061 fn metrics() -> Metrics {
1062 Metrics {
1063 radius: px(10.),
1064 ui_font_size: px(16.),
1065 code_font_size: px(13.),
1066 touch: false,
1067 }
1068 }
1069
1070 fn light() -> ThemeTokens {
1071 ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics())
1072 }
1073
1074 #[test]
1075 fn a_black_surface_keeps_visible_separators() {
1076 let mut config = ThemeConfig::dark();
1077 config.surface = super::super::color::hex("#000000");
1078 let tokens = ThemeTokens::derive(&config, ThemeAppearance::Dark, metrics());
1079 let lightness = super::super::color::lightness;
1080 assert_eq!(tokens.background(), config.surface);
1081 assert!(
1082 lightness(tokens.border()) > lightness(super::super::color::hex("#181818")),
1083 "border {}",
1084 super::super::color::to_hex(tokens.border())
1085 );
1086 }
1087
1088 #[test]
1089 fn touch_grows_controls_to_the_hig_hit_target_and_text_to_body_size() {
1090 let touch = ThemeTokens::derive(
1091 &ThemeConfig::light(),
1092 ThemeAppearance::Light,
1093 Metrics {
1094 touch: true,
1095 ..metrics()
1096 },
1097 );
1098 assert!(touch.touch);
1099 assert_eq!(touch.metrics.control_md, px(44.), "the HIG minimum");
1100 assert_eq!(touch.metrics.row, px(44.));
1101 assert_eq!(touch.metrics.control_xs, px(36.));
1102 assert_eq!(touch.metrics.title_bar, px(44.), "an iOS navigation bar");
1103 assert_eq!(touch.text_control.size, px(17.), "HIG body");
1104 assert_eq!(touch.text_title.size, px(28.), "HIG title 1");
1105 assert_eq!(touch.metrics.sidebar_sheet_width, px(288.));
1107 assert_eq!(touch.metrics.switch_track_width, px(51.));
1110 assert_eq!(touch.metrics.switch_track_height, px(31.));
1111 assert_eq!(touch.metrics.switch_thumb_size, px(27.));
1112 assert_eq!(touch.metrics.switch_thumb_inset, px(2.));
1113 let large_touch = ThemeTokens::derive(
1114 &ThemeConfig::light(),
1115 ThemeAppearance::Light,
1116 Metrics {
1117 touch: true,
1118 ui_font_size: px(20.),
1119 ..metrics()
1120 },
1121 );
1122 assert_eq!(large_touch.metrics.switch_thumb_inset, px(2.));
1123 let mouse = light();
1124 assert!(!mouse.touch);
1125 assert_eq!(mouse.metrics.control_md, px(28.));
1126 assert_eq!(mouse.metrics.row, px(30.));
1127 assert_eq!(mouse.text_control.size, px(13.));
1128 }
1129
1130 fn dark() -> ThemeTokens {
1131 ThemeTokens::derive(&ThemeConfig::dark(), ThemeAppearance::Dark, metrics())
1132 }
1133
1134 #[test]
1135 fn surfaces_step_away_from_the_background_in_both_appearances() {
1136 let light = light();
1137 let bg = lightness(light.background());
1138 assert!(lightness(light.secondary()) < bg);
1139 assert!(lightness(light.border()) < lightness(light.secondary()));
1140 assert!(lightness(light.input()) < lightness(light.border()));
1141 assert!(lightness(light.selected) < lightness(light.secondary()));
1142 assert_eq!(
1143 light.base.colors.surface,
1144 light.background(),
1145 "light cards sit flat"
1146 );
1147 assert!(lightness(light.muted_foreground()) > lightness(light.foreground()));
1148
1149 let dark = dark();
1150 let bg = lightness(dark.background());
1151 assert!(lightness(dark.secondary()) > bg);
1152 assert!(lightness(dark.border()) > bg);
1153 assert!(lightness(dark.input()) > lightness(dark.border()));
1154 assert!(
1155 lightness(dark.base.colors.surface) > bg,
1156 "dark cards are lifted"
1157 );
1158 assert!(lightness(dark.foreground()) < lightness(dark.primary()) + 0.001);
1159 assert!(lightness(dark.muted_foreground()) < lightness(dark.foreground()));
1160 }
1161
1162 #[test]
1163 fn tokens_reproduce_the_reference_values() {
1164 let light = light();
1167 assert_eq!(to_hex(light.secondary()), "#f5f5f5");
1168 assert_eq!(to_hex(light.selected), "#e8e8e8");
1169 assert_eq!(to_hex(light.border()), "#ededed");
1170 assert_eq!(to_hex(light.input()), "#e5e5e6");
1171 assert_eq!(to_hex(light.muted_foreground()), "#67696b");
1172 let dark = dark();
1173 assert_eq!(to_hex(dark.secondary()), "#222222");
1174 assert_eq!(to_hex(dark.base.colors.surface), "#232323");
1175 assert_eq!(to_hex(dark.selected), "#2c2c2c");
1176 assert_eq!(to_hex(dark.border()), "#252525");
1177 assert_eq!(to_hex(dark.foreground()), "#dfdfdf");
1178 assert_eq!(to_hex(dark.muted_foreground()), "#969696");
1179 assert_eq!(to_hex(dark.primary()), "#dfdfdf");
1180 assert_eq!(to_hex(dark.primary_foreground()), "#2d2d2d");
1181 assert_eq!(to_hex(dark.tooltip), "#1b1b1b");
1182 assert_eq!(to_hex(dark.tooltip_foreground), "#ffffff");
1183 assert_eq!(to_hex(light.tooltip), "#1a1c1f");
1184 assert_eq!(to_hex(light.foreground()), "#1a1c1f");
1185 }
1186
1187 #[test]
1188 fn sidebar_tokens_reproduce_the_reference_values() {
1189 let dark = dark();
1192 assert_eq!(to_hex(dark.sidebar), "#222222");
1193 assert_eq!(to_hex(dark.sidebar_selected), "#333333");
1194 assert_eq!(to_hex(dark.sidebar_accent), "#2c2c2c");
1195 assert!(lightness(dark.sidebar_accent) > lightness(dark.sidebar));
1196 assert!(lightness(dark.sidebar_accent) < lightness(dark.sidebar_selected));
1197 assert_eq!(to_hex(dark.sidebar_border), "#343434");
1198 assert_eq!(to_hex(dark.sidebar_muted_foreground), "#747474");
1199 let light = light();
1200 assert_eq!(to_hex(light.sidebar), "#fdfdfd");
1201 assert_eq!(to_hex(light.sidebar_selected), "#f0f0f0");
1202 assert_eq!(to_hex(light.sidebar_accent), "#f6f6f7");
1203 assert!(lightness(light.sidebar_accent) < lightness(light.sidebar));
1204 assert!(lightness(light.sidebar_accent) > lightness(light.sidebar_selected));
1205 assert_eq!(to_hex(light.sidebar_border), "#ebebeb");
1206 assert_eq!(to_hex(light.sidebar_muted_foreground), "#a9aaab");
1208 }
1209
1210 #[test]
1211 fn contrast_scales_the_distance() {
1212 let mut low = ThemeConfig::light();
1213 low.contrast = 20;
1214 let mut high = ThemeConfig::light();
1215 high.contrast = 80;
1216 let low = ThemeTokens::derive(&low, ThemeAppearance::Light, metrics());
1217 let high = ThemeTokens::derive(&high, ThemeAppearance::Light, metrics());
1218 assert!(lightness(low.secondary()) > lightness(high.secondary()));
1219 assert!(lightness(low.selected) > lightness(high.selected));
1220 assert_eq!(
1221 low.border(),
1222 high.border(),
1223 "separators do not move with contrast"
1224 );
1225 }
1226
1227 #[test]
1228 fn status_tints_step_toward_their_color() {
1229 let dark = ThemeTokens::derive(&ThemeConfig::dark(), ThemeAppearance::Dark, metrics());
1230 let light = ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics());
1231 assert_eq!(dark.info, hex("#539af8"), "info is the accent");
1232 for theme in [&dark, &light] {
1233 for solid in [
1234 theme.destructive_solid,
1235 theme.success_solid,
1236 theme.warning_solid,
1237 theme.info_solid,
1238 ] {
1239 let ratio = crate::theme::contrast_ratio(solid, theme.solid_foreground);
1240 assert!(ratio >= 4.5, "{} is {ratio}", to_hex(solid));
1241 }
1242 }
1243 assert_eq!(to_hex(dark.destructive_solid), "#d23f39");
1244 assert_eq!(to_hex(dark.success_tint), "#25372a");
1245 assert_eq!(to_hex(dark.destructive_tint), "#412421");
1246 assert_eq!(to_hex(light.success_tint), "#ebf6ec");
1247 assert_eq!(to_hex(light.destructive_tint), "#fbebe8");
1248 }
1249
1250 #[test]
1251 fn the_progress_track_is_the_primary_at_a_fifth() {
1252 let dark = dark();
1253 assert_eq!(to_hex(dark.primary()), "#dfdfdf");
1254 assert_eq!(to_hex(dark.progress_track), "#dfdfdf33");
1255 let light = light();
1256 assert_eq!(to_hex(light.progress_track), "#31333533");
1257 }
1258
1259 #[test]
1260 fn switch_colors_reproduce_the_reference_values() {
1261 let dark = dark();
1264 assert_eq!(to_hex(dark.switch_track_on), "#539af8");
1265 assert_eq!(to_hex(dark.switch_thumb), "#ffffff");
1266 assert_eq!(dark.switch_track_on, dark.ring());
1267 assert!(lightness(dark.switch_track_off) > lightness(dark.background()));
1268 assert!(lightness(dark.switch_track_off) < lightness(dark.switch_thumb));
1269 let light = light();
1270 assert_eq!(to_hex(light.switch_thumb), "#ffffff");
1271 assert_eq!(light.switch_track_on, light.ring());
1272 assert!(lightness(light.switch_track_off) < lightness(light.background()));
1273 assert_eq!(to_hex(light.focus_ring()), "#339cff80");
1274 assert_eq!(light.focus_ring().a, 0.5);
1275 }
1276
1277 #[test]
1278 fn menu_colors_reproduce_the_reference_values() {
1279 let dark = dark();
1280 assert_eq!(to_hex(dark.popover), "#2d2d2d");
1281 assert_eq!(to_hex(dark.popover_border), "#444444");
1282 assert_eq!(to_hex(dark.popover_accent), "#3d3d3d");
1283 assert_eq!(to_hex(dark.popover_separator), "#3e3e3e");
1284 assert_eq!(to_hex(dark.popover_foreground), "#ffffff");
1285 assert_eq!(to_hex(dark.popover_muted_foreground), "#b5b5b5");
1286 assert_eq!(to_hex(dark.select_indicator), "#cacaca");
1287 assert_eq!(to_hex(dark.select_trigger), "#292929");
1288 assert_eq!(to_hex(dark.select_trigger_border), "#3a3a3a");
1289 assert!(lightness(dark.popover) > lightness(dark.base.colors.surface));
1290 let light = light();
1291 assert_eq!(light.popover, light.background());
1292 assert_eq!(light.popover_border, light.border());
1293 assert_eq!(light.popover_separator, light.border());
1294 assert_eq!(light.popover_foreground, light.foreground());
1295 assert_eq!(light.popover_muted_foreground, light.muted_foreground());
1296 assert_eq!(light.select_trigger, light.background());
1297 assert_eq!(light.select_trigger_border, light.input());
1298 assert_eq!(to_hex(light.popover_accent), "#f6f6f7", "a hovered row");
1299 assert!(lightness(light.popover_accent) < lightness(light.popover));
1300 }
1301
1302 #[test]
1303 fn menu_metrics_scale_with_the_font_size() {
1304 let default = light();
1305 assert_eq!(default.metrics.menu_gap, px(2.));
1306 assert_eq!(default.metrics.menu_min_width, px(128.));
1307 assert_eq!(default.metrics.menu_search_min_width, px(240.));
1308 assert_eq!(default.metrics.menu_max_width, px(360.));
1309 assert_eq!(default.metrics.menu_max_height, px(390.));
1310 let large = ThemeTokens::derive(
1311 &ThemeConfig::light(),
1312 ThemeAppearance::Light,
1313 Metrics {
1314 radius: px(10.),
1315 ui_font_size: px(20.),
1316 code_font_size: px(15.),
1317 touch: false,
1318 },
1319 );
1320 assert_eq!(large.metrics.menu_max_height, px(487.5));
1321 assert_eq!(large.metrics.menu_gap, px(2.5));
1322 }
1323
1324 #[test]
1325 fn dialog_tokens_reproduce_the_reference_values() {
1326 let dark = dark();
1329 assert_eq!(to_hex(dark.dialog_overlay), "#00000040");
1330 assert_eq!(to_hex(dark.background()), "#181818");
1331 let over = (f32::from(0x18u8) * (1. - dark.dialog_overlay.a)).round() as u8;
1332 assert_eq!(over, 0x12);
1333 assert_eq!(to_hex(light().dialog_overlay), "#00000040");
1334 let default = light();
1335 assert_eq!(default.metrics.dialog_width, px(480.));
1336 assert_eq!(default.metrics.dialog_padding, px(20.));
1337 assert_eq!(default.metrics.command_dialog_width, px(512.));
1338 assert_eq!(default.metrics.command_dialog_top, px(120.));
1339 assert_eq!(default.metrics.folder_list_height, px(182.));
1340 }
1341
1342 #[test]
1343 fn field_colors_reproduce_the_reference_values() {
1344 let dark = dark();
1348 assert_eq!(to_hex(dark.field), "#2c2c2c");
1349 assert_eq!(to_hex(dark.field_border), "#3b3b3b");
1350 assert_eq!(
1351 to_hex(dark.field_focus_border),
1352 "#799cca",
1353 "two steps from the #799ec8 sample, inside its noise"
1354 );
1355 assert_eq!(dark.text_textarea.line_height, px(18.5));
1356 assert_eq!(dark.text_textarea.size, px(13.));
1357 assert!(lightness(dark.field_border) > lightness(dark.field));
1358 assert!(lightness(dark.field) > lightness(dark.background()));
1359 let light = light();
1360 assert_eq!(light.field, light.background());
1361 assert_eq!(light.field_border, light.input());
1362 assert_eq!(light.field_focus_border, light.ring());
1363 }
1364
1365 #[test]
1366 fn accent_and_semantic_colors_pass_through() {
1367 let light = light();
1368 assert_eq!(light.ring(), hex("#339cff"));
1369 assert_eq!(light.destructive(), hex("#ba2623"));
1370 assert_eq!(light.destructive_foreground(), hex("#ffffff"));
1371 assert_eq!(light.selection().a, 0.3);
1372 let dark = dark();
1373 assert_eq!(dark.ring(), hex("#539af8"));
1374 assert_eq!(
1375 to_hex(dark.link),
1376 "#91bffd",
1377 "links are paler than the ring on dark"
1378 );
1379 assert_eq!(light.link, light.ring(), "and the accent itself on light");
1380 assert_eq!(dark.destructive_foreground(), hex("#ffffff"));
1381 let mut pale = ThemeConfig::light();
1382 pale.semantic.destructive = hex("#ffe066");
1383 let pale = ThemeTokens::derive(&pale, ThemeAppearance::Light, metrics());
1384 assert_eq!(
1385 pale.destructive_foreground(),
1386 hex("#1a1c1f"),
1387 "ink reads on pale yellow"
1388 );
1389 }
1390
1391 #[test]
1392 fn radius_scale_derives_from_one_value() {
1393 let tokens = light();
1394 assert_eq!(tokens.radius_sm(), px(6.));
1395 assert_eq!(tokens.radius_md(), px(8.));
1396 assert_eq!(tokens.radius_lg(), px(10.));
1397 assert_eq!(tokens.radius_xl(), px(14.));
1398 assert_eq!(tokens.radius_full(), px(9999.));
1399 }
1400
1401 #[test]
1402 fn control_text_and_metrics_scale_with_the_font_size_but_chrome_does_not() {
1403 let default = light();
1404 assert_eq!(default.text_control.size, px(13.));
1405 assert_eq!(default.text_control.line_height, px(16.));
1406 assert_eq!(default.text_heading.size, px(15.));
1407 assert_eq!(default.text_title.size, px(24.));
1408 assert_eq!(default.text_title.weight, FontWeight::SEMIBOLD);
1409 assert_eq!(default.metrics.control_md, px(28.));
1410 assert_eq!(default.metrics.row, px(30.));
1411 assert_eq!(default.metrics.title_bar, px(38.));
1412 assert_eq!(default.metrics.title_bar_content_offset, px(2.));
1413 assert_eq!(default.metrics.window_controls_position, px(14.));
1414 let large = ThemeTokens::derive(
1415 &ThemeConfig::light(),
1416 ThemeAppearance::Light,
1417 Metrics {
1418 radius: px(10.),
1419 ui_font_size: px(20.),
1420 code_font_size: px(15.),
1421 touch: false,
1422 },
1423 );
1424 assert_eq!(large.text_control.size, px(16.25));
1425 assert_eq!(large.metrics.control_md, px(35.));
1426 assert_eq!(large.metrics.row, px(37.5));
1427 assert_eq!(large.metrics.title_bar, px(38.), "window chrome stays");
1428 assert_eq!(large.metrics.sidebar_width, px(300.), "layout stays");
1429 assert_eq!(default.metrics.switch_track_width, px(32.));
1430 assert_eq!(default.metrics.switch_track_height, px(20.));
1431 assert_eq!(default.metrics.switch_thumb_size, px(16.));
1432 assert_eq!(default.metrics.switch_thumb_inset, px(2.));
1433 assert_eq!(large.metrics.switch_track_width, px(40.));
1434 assert_eq!(large.metrics.switch_thumb_size, px(20.));
1435 assert_eq!(large.metrics.switch_thumb_inset, px(2.5));
1436 assert_eq!(default.metrics.avatar_md, px(32.));
1437 assert_eq!(default.metrics.tag_height, px(20.));
1438 assert_eq!(large.metrics.tag_height, px(25.));
1439 assert_eq!(default.metrics.badge_count, px(16.));
1440 assert_eq!(default.metrics.badge_dot, px(6.));
1441 assert_eq!(large.metrics.avatar_md, px(40.));
1442 assert_eq!(large.metrics.avatar_group_ring, px(2.), "a hairline stays");
1443 assert_eq!(default.metrics.terminal_padding, px(2.));
1444 assert_eq!(
1445 large.metrics.terminal_padding,
1446 px(2.),
1447 "the grid's room stays"
1448 );
1449 assert_eq!(large.metrics.terminal_cursor_thickness, px(2.));
1450 assert_eq!(large.terminal_cursor_opacity, 0.55);
1451 }
1452
1453 #[test]
1454 fn the_tab_separator_is_the_muted_text_at_sixty_percent() {
1455 let dark = crate::theme::test_tokens(&ThemeConfig::dark(), ThemeAppearance::Dark);
1456 assert_eq!(dark.tab_separator.a, 0.6);
1457 assert_eq!(
1458 to_hex(dark.tab_separator.alpha(1.)),
1459 to_hex(dark.muted_foreground())
1460 );
1461 let light = crate::theme::test_tokens(&ThemeConfig::light(), ThemeAppearance::Light);
1462 assert_eq!(light.tab_separator.a, 0.6);
1463 }
1464
1465 #[test]
1466 fn tab_metrics_are_the_reference_values_and_grow_on_touch() {
1467 let default = light();
1470 let tab = &default.metrics;
1471 assert_eq!(tab.tab_surface_inset, px(4.));
1472 assert_eq!(tab.tab_width, px(160.));
1473 assert_eq!(tab.tab_min_width, px(112.));
1474 assert_eq!(tab.tab_shoulder, px(7.));
1475 assert_eq!(tab.tab_radius, px(7.));
1476 assert_eq!(tab.tab_separator_gutter, px(7.));
1477 assert_eq!(tab.tab_separator_inset, px(11.));
1478 assert_eq!(tab.tab_icon, px(13.));
1479 assert_eq!(tab.tab_content_gap, px(6.));
1480 assert_eq!(tab.tab_control, px(24.));
1481 assert_eq!(tab.tab_close, px(20.));
1482 let large = ThemeTokens::derive(
1483 &ThemeConfig::light(),
1484 ThemeAppearance::Light,
1485 Metrics {
1486 ui_font_size: px(20.),
1487 ..metrics()
1488 },
1489 );
1490 assert_eq!(large.metrics.tab_surface_inset, px(4.), "chrome stays");
1491 assert_eq!(large.metrics.tab_control, px(30.), "holds an Sm control");
1492 assert_eq!(large.metrics.tab_close, px(25.), "holds an Xs control");
1493 assert_eq!(large.metrics.tab_width, px(160.));
1494 let touch = ThemeTokens::derive(
1495 &ThemeConfig::light(),
1496 ThemeAppearance::Light,
1497 Metrics {
1498 touch: true,
1499 ..metrics()
1500 },
1501 );
1502 assert_eq!(touch.metrics.tab_surface_inset, px(0.), "fills the bar");
1503 assert_eq!(touch.metrics.tab_control, touch.metrics.control_sm);
1504 assert_eq!(touch.metrics.tab_close, touch.metrics.control_xs);
1505 assert_eq!(touch.metrics.tab_width, px(160.));
1506 }
1507
1508 #[test]
1509 fn typography_follows_the_font_sizes_and_families() {
1510 let mut config = ThemeConfig::light();
1511 config.fonts.ui = Some("Inter".into());
1512 config.fonts.code = Some("JetBrains Mono".into());
1513 let tokens = ThemeTokens::derive(
1514 &config,
1515 ThemeAppearance::Light,
1516 Metrics {
1517 radius: px(10.),
1518 ui_font_size: px(20.),
1519 code_font_size: px(15.),
1520 touch: false,
1521 },
1522 );
1523 assert_eq!(tokens.font_family(), &SharedString::from("Inter"));
1524 assert_eq!(
1525 tokens.mono_font_family(),
1526 &SharedString::from("JetBrains Mono")
1527 );
1528 assert_eq!(tokens.base.typography.md.size, px(20.));
1529 assert_eq!(tokens.base.typography.sm.size, px(17.5));
1530 assert_eq!(tokens.base.typography.md.line_height, px(30.));
1531 assert_eq!(tokens.base.typography.mono_md.size, px(15.));
1532 let defaults =
1533 ThemeTokens::derive(&ThemeConfig::light(), ThemeAppearance::Light, metrics());
1534 assert_eq!(defaults.font_family(), &TypographyTokens::default().sans);
1535 assert_eq!(defaults.base.typography.md.size, px(16.));
1536 assert_eq!(defaults.base.typography.mono_md.size, px(13.));
1537 }
1538
1539 #[test]
1540 fn slider_metrics_are_the_shadcn_values_and_scale_with_the_font_size() {
1541 let default = light();
1542 assert_eq!(default.metrics.slider_track, px(4.), "a thin rail");
1543 assert_eq!(default.metrics.slider_thumb, px(16.), "shadcn size-4");
1544 assert_eq!(default.metrics.slider_tick, px(4.));
1545 let large = ThemeTokens::derive(
1546 &ThemeConfig::light(),
1547 ThemeAppearance::Light,
1548 Metrics {
1549 ui_font_size: px(20.),
1550 ..metrics()
1551 },
1552 );
1553 assert_eq!(large.metrics.slider_thumb, px(20.));
1554 }
1555
1556 #[test]
1557 fn a_chosen_radio_and_an_on_switch_share_the_accent() {
1558 for (tokens, hex) in [(dark(), "#539af8"), (light(), "#339cff")] {
1559 assert_eq!(to_hex(tokens.switch_track_on), hex);
1560 assert_eq!(tokens.switch_track_on, tokens.ring());
1561 }
1562 }
1563
1564 #[test]
1565 fn radio_metrics_are_the_shadcn_values() {
1566 let default = light();
1567 assert_eq!(default.metrics.radio_size, px(16.), "shadcn size-4");
1568 assert_eq!(
1569 default.metrics.radio_dot,
1570 px(6.),
1571 "three quarters of size-2"
1572 );
1573 }
1574
1575 #[test]
1576 fn ring_metrics_scale_with_the_font_size() {
1577 let default = light();
1578 assert_eq!(default.metrics.ring_size, px(16.));
1579 assert_eq!(default.metrics.ring_stroke, px(2.));
1580 }
1581
1582 #[test]
1583 fn the_attachment_tile_metric_and_the_model_picker_metrics_are_set() {
1584 let dark = dark();
1585 assert_eq!(dark.metrics.attachment_tile, px(56.));
1586 assert_eq!(dark.metrics.model_picker_width, px(361.));
1587 assert_eq!(dark.metrics.model_picker_height, px(346.));
1588 assert_eq!(dark.metrics.model_picker_rail, px(44.));
1589 assert_eq!(dark.metrics.model_row, px(47.));
1590 assert_eq!(dark.metrics.model_header, px(42.));
1591 }
1592
1593 #[test]
1594 fn the_status_tab_metrics_are_the_reference_samples() {
1595 let m = &dark().metrics;
1596 assert_eq!(m.status_tab_height, px(38.));
1597 assert_eq!(m.status_tab_inset, px(14.));
1598 assert_eq!(m.status_tab_gap, px(28.));
1599 assert_eq!(m.status_label_gap, px(8.));
1600 }
1601
1602 #[test]
1603 fn the_composer_reads_text_at_least_4_5_to_1_on_the_surfaces_it_uses() {
1604 use crate::theme::color::contrast_ratio;
1605 for tokens in [dark(), light()] {
1606 let muted = contrast_ratio(tokens.muted_foreground(), tokens.field);
1607 assert!(muted >= 4.5, "muted text on the card: {muted}");
1608 let warning = contrast_ratio(tokens.warning, tokens.field);
1609 assert!(warning >= 4.5, "the risky mode on the card: {warning}");
1610 let tab = contrast_ratio(tokens.foreground(), tokens.sidebar);
1611 assert!(tab >= 4.5, "text on the status tab: {tab}");
1612 let hairline = contrast_ratio(tokens.field_border, tokens.sidebar);
1613 assert!(
1614 hairline > 1.05,
1615 "the card's hairline reads on the tab: {hairline}"
1616 );
1617 }
1618 }
1619
1620 #[test]
1621 fn dock_panes_take_ghosttys_handle_and_trellis_drag_sizes() {
1622 let dark = dark();
1623 assert_eq!(to_hex(dark.dock_drop.alpha(1.)), to_hex(dark.ring()));
1624 assert_eq!(dark.dock_drop.a, 0.3, "Ghostty's drop overlay");
1625 assert_eq!(dark.dock_handle.a, 0.3);
1626 assert_eq!(dark.dock_handle_hover.a, 0.8);
1627 assert_eq!(to_hex(dark.dock_unfocused.alpha(1.)), "#181818");
1628 assert_eq!(dark.dock_unfocused.a, 0.3, "Ghostty's unfocused split");
1629 assert_eq!(to_hex(dark.dock_handle.alpha(1.)), "#dfdfdf");
1630 let m = &dark.metrics;
1631 assert_eq!(m.dock_handle_width, px(80.));
1632 assert_eq!(m.dock_handle_height, px(12.));
1633 assert_eq!(m.dock_handle_reveal, 0.2);
1634 assert_eq!(m.dock_drag_threshold, px(6.));
1635 assert_eq!(m.dock_card, Size::new(px(380.), px(260.)));
1636 let touch = ThemeTokens::derive(
1637 &ThemeConfig::dark(),
1638 ThemeAppearance::Dark,
1639 Metrics {
1640 touch: true,
1641 ..metrics()
1642 },
1643 );
1644 assert_eq!(touch.metrics.dock_handle_height, px(44.), "the HIG target");
1645 assert_eq!(touch.metrics.dock_handle_width, px(80.));
1646 }
1647}