Skip to main content

liora_theme/
lib.rs

1//! Theme tokens for Liora GPUI components.
2//!
3//! `liora-theme` defines the semantic colors, spacing, radius, typography, and
4//! component size/variant enums consumed by `liora-core` and `liora-components`.
5//! Use `Theme::light()` or `Theme::dark()` with `liora_core::init_liora(...)` at
6//! app startup.
7
8use gpui::{Hsla, Rgba};
9
10/// NaiveUI-inspired Forest Green theme.
11///
12/// Reference: https://github.com/tusen-ai/naive-ui
13/// Light theme base: neutralBase=#FFF, primary=#18A058 (green)
14/// Dark theme base: neutralBase=#000, primary=#63E2B7 (green)
15
16// ---------------------------------------------------------------------------
17// Color helpers — construct via gpui::Rgba then .into() to Hsla
18// ---------------------------------------------------------------------------
19
20fn rgb(r: u8, g: u8, b: u8) -> Hsla {
21    Rgba {
22        r: r as f32 / 255.0,
23        g: g as f32 / 255.0,
24        b: b as f32 / 255.0,
25        a: 1.0,
26    }
27    .into()
28}
29
30fn rgba(r: u8, g: u8, b: u8, a: f32) -> Hsla {
31    Rgba {
32        r: r as f32 / 255.0,
33        g: g as f32 / 255.0,
34        b: b as f32 / 255.0,
35        a,
36    }
37    .into()
38}
39
40/// Lighten: blend with white. factor 0.9 = very light (90% white)
41fn lighten(base: Hsla, factor: f32) -> Hsla {
42    base.blend(gpui::white().opacity(factor))
43}
44
45#[allow(dead_code)]
46fn darken(base: Hsla, factor: f32) -> Hsla {
47    base.blend(gpui::black().opacity(factor))
48}
49
50// ---------------------------------------------------------------------------
51// Semantic Color Family
52// ---------------------------------------------------------------------------
53
54#[derive(Clone)]
55/// Semantic color family containing base, state, and subtle background tokens.
56pub struct ColorFamily {
57    /// Base value for the z-index stack.
58    pub base: Hsla,
59    /// Color used for hover affordances.
60    pub hover: Hsla,
61    /// Color used for active or pressed affordances.
62    pub active: Hsla,
63    /// Supplemental accent color paired with the base token.
64    pub suppl: Hsla,
65    /// light-9: for subtle backgrounds
66    pub light_9: Hsla,
67    /// light-8
68    pub light_8: Hsla,
69    /// light-7: for hover backgrounds
70    pub light_7: Hsla,
71}
72
73impl ColorFamily {
74    fn new(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
75        Self {
76            base,
77            hover,
78            active,
79            suppl,
80            light_9: lighten(base, 0.9),
81            light_8: lighten(base, 0.8),
82            light_7: lighten(base, 0.7),
83        }
84    }
85
86    fn new_dark(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
87        Self {
88            base,
89            hover,
90            active,
91            suppl,
92            // These tokens are used as selected/hover subtle backgrounds. In
93            // dark mode they must stay translucent instead of being blended
94            // toward white, otherwise table rows and picker chips become
95            // visually louder than their foreground content.
96            light_9: base.opacity(0.16),
97            light_8: base.opacity(0.22),
98            light_7: base.opacity(0.30),
99        }
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Neutral Tokens
105// ---------------------------------------------------------------------------
106
107#[derive(Clone)]
108/// Neutral surface, text, border, interaction, and overlay tokens used across components.
109pub struct NeutralTokens {
110    /// Application body background color.
111    pub body: Hsla,
112    /// Card and elevated surface background color.
113    pub card: Hsla,
114    /// Modal panel background color.
115    pub modal: Hsla,
116    /// Popover and floating-panel background color.
117    pub popover: Hsla,
118    /// Inverted surface color used behind high-contrast content.
119    pub inverted: Hsla,
120
121    /// Primary text color.
122    pub text_1: Hsla,
123    /// Secondary text color.
124    pub text_2: Hsla,
125    /// Tertiary text color.
126    pub text_3: Hsla,
127    /// Text color used for disabled controls.
128    pub text_disabled: Hsla,
129    /// Placeholder text color for empty inputs.
130    pub placeholder: Hsla,
131    /// Optional icon rendered with the item.
132    pub icon: Hsla,
133
134    /// Border color used in the normal state.
135    pub border: Hsla,
136    /// Divider line color.
137    pub divider: Hsla,
138
139    /// Color used for hover affordances.
140    pub hover: Hsla,
141    /// Pressed-state background color.
142    pub pressed: Hsla,
143
144    /// Track or rail color for slider-like controls.
145    pub rail: Hsla,
146
147    /// Translucent overlay color.
148    pub overlay: Hsla,
149    /// Backdrop mask color for modal layers.
150    pub mask: Hsla,
151}
152
153// ---------------------------------------------------------------------------
154// Spacing / Radius / Font (unchanged structure, refined values)
155// ---------------------------------------------------------------------------
156
157#[derive(Clone)]
158/// Spacing scale used by layout and component padding presets.
159pub struct Spacing {
160    /// Extra-small spacing or size token.
161    pub xs: f32,
162    /// Small spacing, radius, or size token.
163    pub sm: f32,
164    /// Medium spacing, radius, or size token.
165    pub md: f32,
166    /// Large spacing, radius, or size token.
167    pub lg: f32,
168    /// Extra-large spacing or size token.
169    pub xl: f32,
170}
171
172#[derive(Clone)]
173/// Corner-radius scale used by controls, cards, and overlays.
174pub struct Radius {
175    /// Small spacing, radius, or size token.
176    pub sm: f32,
177    /// Medium spacing, radius, or size token.
178    pub md: f32,
179    /// Large spacing, radius, or size token.
180    pub lg: f32,
181    /// Fully rounded radius token.
182    pub full: f32,
183}
184
185#[derive(Clone)]
186/// Font-size scale used by typography and compact component variants.
187pub struct FontSize {
188    /// Extra-small spacing or size token.
189    pub xs: f32,
190    /// Small spacing, radius, or size token.
191    pub sm: f32,
192    /// Medium spacing, radius, or size token.
193    pub md: f32,
194    /// Large spacing, radius, or size token.
195    pub lg: f32,
196    /// Extra-large spacing or size token.
197    pub xl: f32,
198}
199
200// ---------------------------------------------------------------------------
201// Theme
202// ---------------------------------------------------------------------------
203
204#[derive(Clone)]
205/// Color tokens for secondary button and low-emphasis surfaces.
206pub struct SecondaryColors {
207    /// Base background color used in the normal state.
208    pub bg: Hsla,
209    /// Color used for hover affordances.
210    pub hover: Hsla,
211    /// Pressed-state background color.
212    pub pressed: Hsla,
213}
214
215#[derive(Clone)]
216/// Complete Liora visual token set for one color mode.
217pub struct Theme {
218    /// Display name shown to users for this item.
219    pub name: String,
220    /// Spacing token group used throughout Liora components.
221    pub spacing: Spacing,
222    /// Corner radius applied to the rendered control.
223    pub radius: Radius,
224    /// Font-size token group used throughout Liora components.
225    pub font_size: FontSize,
226
227    // Semantic color families
228    /// Primary brand semantic color family.
229    pub primary: ColorFamily,
230    /// Informational semantic color family.
231    pub info: ColorFamily,
232    /// Success semantic color family.
233    pub success: ColorFamily,
234    /// Warning semantic color family.
235    pub warning: ColorFamily,
236    /// Danger semantic color family.
237    pub danger: ColorFamily,
238
239    // Neutral tokens
240    /// Neutral surface, text, border, and overlay tokens.
241    pub neutral: NeutralTokens,
242
243    // Secondary button style (NaiveUI buttonColor2)
244    /// Secondary button color tokens.
245    pub secondary: SecondaryColors,
246
247    // Shadows
248    /// Small elevation shadow token.
249    pub shadow_1: &'static str,
250    /// Medium elevation shadow token.
251    pub shadow_2: &'static str,
252    /// Large elevation shadow token.
253    pub shadow_3: &'static str,
254}
255
256impl Default for Theme {
257    fn default() -> Self {
258        Self::light()
259    }
260}
261
262impl Theme {
263    // ========================================================================
264    // Light Theme
265    // ========================================================================
266    /// Builds the complete light theme token set.
267    pub fn light() -> Self {
268        Self {
269            name: "light".into(),
270            spacing: Spacing {
271                xs: 4.0,
272                sm: 8.0,
273                md: 12.0,
274                lg: 20.0,
275                xl: 32.0,
276            },
277            radius: Radius {
278                sm: 2.0,
279                md: 4.0,
280                lg: 8.0,
281                full: 9999.0,
282            },
283            font_size: FontSize {
284                xs: 10.0,
285                sm: 12.0,
286                md: 14.0,
287                lg: 16.0,
288                xl: 20.0,
289            },
290
291            primary: ColorFamily::new(
292                rgb(24, 160, 88),  // #18A058 — NaiveUI primary green
293                rgb(54, 173, 106), // #36AD6A
294                rgb(12, 122, 67),  // #0C7A43
295                rgb(54, 173, 106), // #36AD6A
296            ),
297            info: ColorFamily::new(
298                rgb(32, 128, 240), // #2080F0 — NaiveUI info blue
299                rgb(64, 152, 252), // #4098FC
300                rgb(16, 96, 201),  // #1060C9
301                rgb(64, 152, 252), // #4098FC
302            ),
303            success: ColorFamily::new(
304                rgb(24, 160, 88),  // #18A058
305                rgb(54, 173, 106), // #36AD6A
306                rgb(12, 122, 67),  // #0C7A43
307                rgb(54, 173, 106), // #36AD6A
308            ),
309            warning: ColorFamily::new(
310                rgb(240, 160, 32), // #F0A020 — NaiveUI warning gold
311                rgb(252, 176, 64), // #FCB040
312                rgb(201, 124, 16), // #C97C10
313                rgb(252, 176, 64), // #FCB040
314            ),
315            danger: ColorFamily::new(
316                rgb(208, 48, 80),  // #D03050 — NaiveUI error red
317                rgb(222, 87, 109), // #DE576D
318                rgb(171, 31, 63),  // #AB1F3F
319                rgb(222, 87, 109), // #DE576D
320            ),
321
322            neutral: NeutralTokens {
323                body: rgb(255, 255, 255),
324                card: rgb(255, 255, 255),
325                modal: rgb(255, 255, 255),
326                popover: rgb(255, 255, 255),
327                inverted: rgb(0, 20, 40),
328
329                text_1: rgb(31, 34, 37),
330                text_2: rgb(51, 54, 57),
331                text_3: rgb(118, 124, 130),
332                text_disabled: rgba(194, 194, 194, 1.0),
333                placeholder: rgba(194, 194, 194, 1.0),
334                icon: rgba(31, 34, 37, 1.0),
335
336                border: rgb(224, 224, 230),
337                divider: rgb(239, 239, 245),
338
339                hover: rgb(243, 243, 245),
340                pressed: rgb(237, 237, 239),
341
342                rail: rgb(219, 219, 223),
343
344                overlay: rgba(0, 0, 0, 0.50),
345                mask: rgba(255, 255, 255, 0.90),
346            },
347            // NaiveUI button secondary colors
348            secondary: SecondaryColors {
349                bg: rgba(46, 51, 56, 0.05),
350                hover: rgba(46, 51, 56, 0.09),
351                pressed: rgba(46, 51, 56, 0.13),
352            },
353
354            shadow_1: "0 1px 2px -2px rgba(0,0,0,.08), 0 3px 6px 0 rgba(0,0,0,.06), 0 5px 12px 4px rgba(0,0,0,.04)",
355            shadow_2: "0 3px 6px -4px rgba(0,0,0,.12), 0 6px 16px 0 rgba(0,0,0,.08), 0 9px 28px 8px rgba(0,0,0,.05)",
356            shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
357        }
358    }
359
360    // ========================================================================
361    // Dark Theme
362    // ========================================================================
363    /// Builds the complete dark theme token set.
364    pub fn dark() -> Self {
365        Self {
366            name: "dark".into(),
367            spacing: Spacing {
368                xs: 4.0,
369                sm: 8.0,
370                md: 12.0,
371                lg: 20.0,
372                xl: 32.0,
373            },
374            radius: Radius {
375                sm: 2.0,
376                md: 4.0,
377                lg: 8.0,
378                full: 9999.0,
379            },
380            font_size: FontSize {
381                xs: 12.0,
382                sm: 14.0,
383                md: 14.0,
384                lg: 15.0,
385                xl: 16.0,
386            },
387
388            primary: ColorFamily::new_dark(
389                rgb(99, 226, 183),  // #63E2B7 — brighter green for dark
390                rgb(127, 231, 196), // #7FE7C4
391                rgb(90, 206, 167),  // #5ACEA7
392                rgb(42, 148, 125),  // #2A947D (suppl)
393            ),
394            info: ColorFamily::new_dark(
395                rgb(112, 192, 232), // #70C0E8
396                rgb(138, 203, 236), // #8ACBEC
397                rgb(102, 175, 211), // #66AFD3
398                rgb(56, 137, 197),  // #3889C5
399            ),
400            success: ColorFamily::new_dark(
401                rgb(99, 226, 183),  // #63E2B7
402                rgb(127, 231, 196), // #7FE7C4
403                rgb(90, 206, 167),  // #5ACEA7
404                rgb(42, 148, 125),  // #2A947D
405            ),
406            warning: ColorFamily::new_dark(
407                rgb(242, 201, 125), // #F2C97D
408                rgb(245, 213, 153), // #F5D599
409                rgb(230, 194, 96),  // #E6C260
410                rgb(240, 138, 0),   // #F08A00
411            ),
412            danger: ColorFamily::new_dark(
413                rgb(232, 128, 128), // #E88080
414                rgb(233, 139, 139), // #E98B8B
415                rgb(229, 114, 114), // #E57272
416                rgb(208, 58, 82),   // #D03A52
417            ),
418
419            neutral: NeutralTokens {
420                body: rgb(16, 16, 20),    // #101014
421                card: rgb(24, 24, 28),    // #18181C
422                modal: rgb(44, 44, 50),   // #2C2C32
423                popover: rgb(72, 72, 78), // #48484E
424                inverted: rgb(255, 255, 255),
425
426                text_1: rgba(255, 255, 255, 0.90),
427                text_2: rgba(255, 255, 255, 0.82),
428                text_3: rgba(255, 255, 255, 0.52),
429                text_disabled: rgba(255, 255, 255, 0.38),
430                placeholder: rgba(255, 255, 255, 0.38),
431                icon: rgba(255, 255, 255, 0.38),
432
433                border: rgba(255, 255, 255, 0.24),
434                divider: rgba(255, 255, 255, 0.09),
435
436                hover: rgba(255, 255, 255, 0.09),
437                pressed: rgba(255, 255, 255, 0.05),
438
439                rail: rgba(255, 255, 255, 0.20),
440
441                overlay: rgba(0, 0, 0, 0.60),
442                mask: rgba(0, 0, 0, 0.70),
443            },
444
445            shadow_1: "0 1px 2px -2px rgba(0,0,0,.24), 0 3px 6px 0 rgba(0,0,0,.18), 0 5px 12px 4px rgba(0,0,0,.12)",
446            shadow_2: "0 3px 6px -4px rgba(0,0,0,.24), 0 6px 12px 0 rgba(0,0,0,.16), 0 9px 18px 8px rgba(0,0,0,.10)",
447            shadow_3: "0 6px 16px -9px rgba(0,0,0,.08), 0 9px 28px 0 rgba(0,0,0,.05), 0 12px 48px 16px rgba(0,0,0,.03)",
448
449            secondary: SecondaryColors {
450                bg: rgba(255, 255, 255, 0.08),
451                hover: rgba(255, 255, 255, 0.12),
452                pressed: rgba(255, 255, 255, 0.16),
453            },
454        }
455    }
456
457    // ========================================================================
458    // Convenience: resolve colors for a ButtonVariant
459    // ========================================================================
460    /// Returns semantic colors for the requested button variant.
461    pub fn color_by_variant(
462        &self,
463        variant: ButtonVariant,
464        secondary: bool,
465        background: bool,
466        border: bool,
467    ) -> ButtonVariantColors {
468        if secondary {
469            return self.secondary_colors(variant, background, border);
470        }
471
472        // Filled (primary) style
473        match variant {
474            ButtonVariant::Default => ButtonVariantColors {
475                bg: rgba(0, 0, 0, 0.0),
476                hover_bg: self.secondary.hover,
477                active_bg: self.secondary.pressed,
478                text: self.neutral.text_2,
479                border: self.neutral.border,
480                text_hover: self.primary.base,
481                border_hover: self.primary.base,
482            },
483            ButtonVariant::Tertiary => ButtonVariantColors {
484                bg: self.secondary.bg,
485                hover_bg: self.secondary.hover,
486                active_bg: self.secondary.pressed,
487                text: self.neutral.text_2,
488                border: rgba(0, 0, 0, 0.0),
489                text_hover: self.neutral.text_1,
490                border_hover: rgba(0, 0, 0, 0.0),
491            },
492            ButtonVariant::Text => ButtonVariantColors {
493                bg: rgba(0, 0, 0, 0.0),
494                hover_bg: self.secondary.hover,
495                active_bg: self.secondary.pressed,
496                text: self.neutral.text_2,
497                border: rgba(0, 0, 0, 0.0),
498                text_hover: self.primary.base,
499                border_hover: rgba(0, 0, 0, 0.0),
500            },
501            ButtonVariant::Primary => self.filled_colors(&self.primary),
502            ButtonVariant::Info => self.filled_colors(&self.info),
503            ButtonVariant::Success => self.filled_colors(&self.success),
504            ButtonVariant::Warning => self.filled_colors(&self.warning),
505            ButtonVariant::Danger => self.filled_colors(&self.danger),
506        }
507    }
508
509    /// Secondary (light bg + colored text) for colored variants;
510    /// Default/Tertiary stay neutral.
511    fn secondary_colors(
512        &self,
513        variant: ButtonVariant,
514        show_bg: bool,
515        show_border: bool,
516    ) -> ButtonVariantColors {
517        match variant {
518            ButtonVariant::Default => ButtonVariantColors {
519                bg: if show_bg {
520                    self.secondary.bg
521                } else {
522                    rgba(0, 0, 0, 0.0)
523                },
524                hover_bg: self.secondary.hover,
525                active_bg: self.secondary.pressed,
526                text: self.neutral.text_2,
527                border: if show_border {
528                    self.neutral.border
529                } else {
530                    rgba(0, 0, 0, 0.0)
531                },
532                text_hover: self.primary.base,
533                border_hover: self.primary.base,
534            },
535            ButtonVariant::Tertiary => ButtonVariantColors {
536                bg: if show_bg {
537                    self.secondary.bg
538                } else {
539                    rgba(0, 0, 0, 0.0)
540                },
541                hover_bg: self.secondary.hover,
542                active_bg: self.secondary.pressed,
543                text: self.neutral.text_2,
544                border: if show_border {
545                    self.neutral.border
546                } else {
547                    rgba(0, 0, 0, 0.0)
548                },
549                text_hover: self.neutral.text_1,
550                border_hover: rgba(0, 0, 0, 0.0),
551            },
552            ButtonVariant::Text => ButtonVariantColors {
553                bg: rgba(0, 0, 0, 0.0),
554                hover_bg: self.secondary.hover,
555                active_bg: self.secondary.pressed,
556                text: self.neutral.text_2,
557                border: rgba(0, 0, 0, 0.0),
558                text_hover: self.primary.base,
559                border_hover: rgba(0, 0, 0, 0.0),
560            },
561            ButtonVariant::Primary => self.secondary_family(&self.primary, show_bg, show_border),
562            ButtonVariant::Info => self.secondary_family(&self.info, show_bg, show_border),
563            ButtonVariant::Success => self.secondary_family(&self.success, show_bg, show_border),
564            ButtonVariant::Warning => self.secondary_family(&self.warning, show_bg, show_border),
565            ButtonVariant::Danger => self.secondary_family(&self.danger, show_bg, show_border),
566        }
567    }
568
569    fn secondary_family(
570        &self,
571        family: &ColorFamily,
572        show_bg: bool,
573        show_border: bool,
574    ) -> ButtonVariantColors {
575        ButtonVariantColors {
576            bg: if show_bg {
577                family.light_9
578            } else {
579                rgba(0, 0, 0, 0.0)
580            },
581            hover_bg: family.light_8,
582            active_bg: family.light_7,
583            text: family.base,
584            border: if show_border {
585                family.base
586            } else {
587                rgba(0, 0, 0, 0.0)
588            },
589            text_hover: family.hover,
590            border_hover: family.hover,
591        }
592    }
593
594    fn filled_colors(&self, family: &ColorFamily) -> ButtonVariantColors {
595        let hover = family.base.blend(gpui::black().opacity(0.10));
596        let active = family.base.blend(gpui::black().opacity(0.25));
597        ButtonVariantColors {
598            bg: family.base,
599            hover_bg: hover,
600            active_bg: active,
601            text: rgb(255, 255, 255),
602            border: family.base,
603            text_hover: rgb(255, 255, 255),
604            border_hover: hover,
605        }
606    }
607}
608
609// ---------------------------------------------------------------------------
610// Enums
611// ---------------------------------------------------------------------------
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614/// Options that control button variant behavior.
615pub enum ButtonVariant {
616    /// Uses the neutral default button treatment.
617    Default,
618    /// Uses a low-emphasis tertiary button treatment.
619    Tertiary,
620    /// Uses a text-only button treatment without a filled container.
621    Text,
622    /// Uses the primary brand-accent button treatment.
623    Primary,
624    /// Uses the informational semantic color family.
625    Info,
626    /// Uses the success semantic color family.
627    Success,
628    /// Uses the warning semantic color family.
629    Warning,
630    /// Uses the danger semantic color family.
631    Danger,
632}
633
634/// Public design-token group for Liora button variant colors styling.
635pub struct ButtonVariantColors {
636    /// Base background color used in the normal state.
637    pub bg: Hsla,
638    /// Background color used while the control is hovered.
639    pub hover_bg: Hsla,
640    /// Background color used while the control is pressed or active.
641    pub active_bg: Hsla,
642    /// Foreground text color used in the normal state.
643    pub text: Hsla,
644    /// Border color used in the normal state.
645    pub border: Hsla,
646    /// Foreground text color used while the control is hovered.
647    pub text_hover: Hsla,
648    /// Border color used while the control is hovered.
649    pub border_hover: Hsla,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653/// Options that control button size behavior.
654pub enum ButtonSize {
655    /// Uses compact button metrics for dense layouts.
656    Small,
657    /// Uses the standard button metrics for normal layouts.
658    Default,
659    /// Uses larger button metrics for prominent actions.
660    Large,
661}
662
663impl ButtonSize {
664    /// Returns the numeric height token consumed by Liora component layout.
665    pub fn height(&self) -> f32 {
666        match self {
667            ButtonSize::Small => 28.0,   // NaiveUI heightSmall
668            ButtonSize::Default => 34.0, // NaiveUI heightMedium
669            ButtonSize::Large => 40.0,   // NaiveUI heightLarge
670        }
671    }
672
673    /// Returns the numeric horizontal padding token consumed by Liora component layout.
674    pub fn padding_x(&self) -> f32 {
675        match self {
676            ButtonSize::Small => 12.0,   // NaiveUI: 0 12px
677            ButtonSize::Default => 14.0, // NaiveUI: 0 14px
678            ButtonSize::Large => 18.0,   // NaiveUI: 0 18px
679        }
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use gpui::Rgba;
687
688    fn rgba_color(color: Hsla) -> Rgba {
689        color.into()
690    }
691
692    #[test]
693    fn filled_button_hover_and_active_backgrounds_get_progressively_darker() {
694        let theme = Theme::light();
695        let colors = theme.color_by_variant(ButtonVariant::Primary, false, true, true);
696
697        let bg = rgba_color(colors.bg);
698        let hover = rgba_color(colors.hover_bg);
699        let active = rgba_color(colors.active_bg);
700
701        assert!(hover.r < bg.r, "hover red channel should be darker");
702        assert!(hover.g < bg.g, "hover green channel should be darker");
703        assert!(hover.b < bg.b, "hover blue channel should be darker");
704        assert!(
705            active.r < hover.r,
706            "active red channel should be darker than hover"
707        );
708        assert!(
709            active.g < hover.g,
710            "active green channel should be darker than hover"
711        );
712        assert!(
713            active.b < hover.b,
714            "active blue channel should be darker than hover"
715        );
716    }
717
718    #[test]
719    fn dark_semantic_subtle_backgrounds_remain_translucent() {
720        let theme = Theme::dark();
721
722        assert!(theme.primary.light_9.a < 0.2);
723        assert!(theme.primary.light_8.a > theme.primary.light_9.a);
724        assert!(theme.primary.light_7.a > theme.primary.light_8.a);
725        assert_eq!(theme.primary.light_9.h, theme.primary.base.h);
726    }
727
728    #[test]
729    fn light_semantic_subtle_backgrounds_remain_opaque_tints() {
730        let theme = Theme::light();
731
732        assert_eq!(theme.primary.light_9.a, 1.0);
733        assert!(theme.primary.light_9.l > theme.primary.base.l);
734    }
735
736    #[test]
737    fn default_button_hover_and_active_backgrounds_are_visible_overlays() {
738        let theme = Theme::light();
739        let colors = theme.color_by_variant(ButtonVariant::Default, false, true, true);
740
741        let bg = rgba_color(colors.bg);
742        let hover = rgba_color(colors.hover_bg);
743        let active = rgba_color(colors.active_bg);
744
745        assert_eq!(
746            bg.a, 0.0,
747            "default button base background should stay transparent"
748        );
749        assert!(hover.a > bg.a, "hover background should be visible");
750        assert!(
751            active.a > hover.a,
752            "active background should be stronger than hover"
753        );
754    }
755}