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)]
55pub struct ColorFamily {
56    pub base: Hsla,
57    pub hover: Hsla,
58    pub active: Hsla,
59    pub suppl: Hsla,
60    /// light-9: for subtle backgrounds
61    pub light_9: Hsla,
62    /// light-8
63    pub light_8: Hsla,
64    /// light-7: for hover backgrounds
65    pub light_7: Hsla,
66}
67
68impl ColorFamily {
69    fn new(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
70        Self {
71            base,
72            hover,
73            active,
74            suppl,
75            light_9: lighten(base, 0.9),
76            light_8: lighten(base, 0.8),
77            light_7: lighten(base, 0.7),
78        }
79    }
80
81    fn new_dark(base: Hsla, hover: Hsla, active: Hsla, suppl: Hsla) -> Self {
82        Self {
83            base,
84            hover,
85            active,
86            suppl,
87            // These tokens are used as selected/hover subtle backgrounds. In
88            // dark mode they must stay translucent instead of being blended
89            // toward white, otherwise table rows and picker chips become
90            // visually louder than their foreground content.
91            light_9: base.opacity(0.16),
92            light_8: base.opacity(0.22),
93            light_7: base.opacity(0.30),
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Neutral Tokens
100// ---------------------------------------------------------------------------
101
102#[derive(Clone)]
103pub struct NeutralTokens {
104    pub body: Hsla,
105    pub card: Hsla,
106    pub modal: Hsla,
107    pub popover: Hsla,
108    pub inverted: Hsla,
109
110    pub text_1: Hsla,
111    pub text_2: Hsla,
112    pub text_3: Hsla,
113    pub text_disabled: Hsla,
114    pub placeholder: Hsla,
115    pub icon: Hsla,
116
117    pub border: Hsla,
118    pub divider: Hsla,
119
120    pub hover: Hsla,
121    pub pressed: Hsla,
122
123    pub rail: Hsla,
124
125    pub overlay: Hsla,
126    pub mask: Hsla,
127}
128
129// ---------------------------------------------------------------------------
130// Spacing / Radius / Font (unchanged structure, refined values)
131// ---------------------------------------------------------------------------
132
133#[derive(Clone)]
134pub struct Spacing {
135    pub xs: f32,
136    pub sm: f32,
137    pub md: f32,
138    pub lg: f32,
139    pub xl: f32,
140}
141
142#[derive(Clone)]
143pub struct Radius {
144    pub sm: f32,
145    pub md: f32,
146    pub lg: f32,
147    pub full: f32,
148}
149
150#[derive(Clone)]
151pub struct FontSize {
152    pub xs: f32,
153    pub sm: f32,
154    pub md: f32,
155    pub lg: f32,
156    pub xl: f32,
157}
158
159// ---------------------------------------------------------------------------
160// Theme
161// ---------------------------------------------------------------------------
162
163#[derive(Clone)]
164pub struct SecondaryColors {
165    pub bg: Hsla,
166    pub hover: Hsla,
167    pub pressed: Hsla,
168}
169
170#[derive(Clone)]
171pub struct Theme {
172    pub name: String,
173    pub spacing: Spacing,
174    pub radius: Radius,
175    pub font_size: FontSize,
176
177    // Semantic color families
178    pub primary: ColorFamily,
179    pub info: ColorFamily,
180    pub success: ColorFamily,
181    pub warning: ColorFamily,
182    pub danger: ColorFamily,
183
184    // Neutral tokens
185    pub neutral: NeutralTokens,
186
187    // Secondary button style (NaiveUI buttonColor2)
188    pub secondary: SecondaryColors,
189
190    // Shadows
191    pub shadow_1: &'static str,
192    pub shadow_2: &'static str,
193    pub shadow_3: &'static str,
194}
195
196impl Default for Theme {
197    fn default() -> Self {
198        Self::light()
199    }
200}
201
202impl Theme {
203    // ========================================================================
204    // Light Theme
205    // ========================================================================
206    pub fn light() -> Self {
207        Self {
208            name: "light".into(),
209            spacing: Spacing {
210                xs: 4.0,
211                sm: 8.0,
212                md: 12.0,
213                lg: 20.0,
214                xl: 32.0,
215            },
216            radius: Radius {
217                sm: 2.0,
218                md: 4.0,
219                lg: 8.0,
220                full: 9999.0,
221            },
222            font_size: FontSize {
223                xs: 10.0,
224                sm: 12.0,
225                md: 14.0,
226                lg: 16.0,
227                xl: 20.0,
228            },
229
230            primary: ColorFamily::new(
231                rgb(24, 160, 88),  // #18A058 — NaiveUI primary green
232                rgb(54, 173, 106), // #36AD6A
233                rgb(12, 122, 67),  // #0C7A43
234                rgb(54, 173, 106), // #36AD6A
235            ),
236            info: ColorFamily::new(
237                rgb(32, 128, 240), // #2080F0 — NaiveUI info blue
238                rgb(64, 152, 252), // #4098FC
239                rgb(16, 96, 201),  // #1060C9
240                rgb(64, 152, 252), // #4098FC
241            ),
242            success: ColorFamily::new(
243                rgb(24, 160, 88),  // #18A058
244                rgb(54, 173, 106), // #36AD6A
245                rgb(12, 122, 67),  // #0C7A43
246                rgb(54, 173, 106), // #36AD6A
247            ),
248            warning: ColorFamily::new(
249                rgb(240, 160, 32), // #F0A020 — NaiveUI warning gold
250                rgb(252, 176, 64), // #FCB040
251                rgb(201, 124, 16), // #C97C10
252                rgb(252, 176, 64), // #FCB040
253            ),
254            danger: ColorFamily::new(
255                rgb(208, 48, 80),  // #D03050 — NaiveUI error red
256                rgb(222, 87, 109), // #DE576D
257                rgb(171, 31, 63),  // #AB1F3F
258                rgb(222, 87, 109), // #DE576D
259            ),
260
261            neutral: NeutralTokens {
262                body: rgb(255, 255, 255),
263                card: rgb(255, 255, 255),
264                modal: rgb(255, 255, 255),
265                popover: rgb(255, 255, 255),
266                inverted: rgb(0, 20, 40),
267
268                text_1: rgb(31, 34, 37),
269                text_2: rgb(51, 54, 57),
270                text_3: rgb(118, 124, 130),
271                text_disabled: rgba(194, 194, 194, 1.0),
272                placeholder: rgba(194, 194, 194, 1.0),
273                icon: rgba(31, 34, 37, 1.0),
274
275                border: rgb(224, 224, 230),
276                divider: rgb(239, 239, 245),
277
278                hover: rgb(243, 243, 245),
279                pressed: rgb(237, 237, 239),
280
281                rail: rgb(219, 219, 223),
282
283                overlay: rgba(0, 0, 0, 0.50),
284                mask: rgba(255, 255, 255, 0.90),
285            },
286            // NaiveUI button secondary colors
287            secondary: SecondaryColors {
288                bg: rgba(46, 51, 56, 0.05),
289                hover: rgba(46, 51, 56, 0.09),
290                pressed: rgba(46, 51, 56, 0.13),
291            },
292
293            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)",
294            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)",
295            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)",
296        }
297    }
298
299    // ========================================================================
300    // Dark Theme
301    // ========================================================================
302    pub fn dark() -> Self {
303        Self {
304            name: "dark".into(),
305            spacing: Spacing {
306                xs: 4.0,
307                sm: 8.0,
308                md: 12.0,
309                lg: 20.0,
310                xl: 32.0,
311            },
312            radius: Radius {
313                sm: 2.0,
314                md: 4.0,
315                lg: 8.0,
316                full: 9999.0,
317            },
318            font_size: FontSize {
319                xs: 12.0,
320                sm: 14.0,
321                md: 14.0,
322                lg: 15.0,
323                xl: 16.0,
324            },
325
326            primary: ColorFamily::new_dark(
327                rgb(99, 226, 183),  // #63E2B7 — brighter green for dark
328                rgb(127, 231, 196), // #7FE7C4
329                rgb(90, 206, 167),  // #5ACEA7
330                rgb(42, 148, 125),  // #2A947D (suppl)
331            ),
332            info: ColorFamily::new_dark(
333                rgb(112, 192, 232), // #70C0E8
334                rgb(138, 203, 236), // #8ACBEC
335                rgb(102, 175, 211), // #66AFD3
336                rgb(56, 137, 197),  // #3889C5
337            ),
338            success: ColorFamily::new_dark(
339                rgb(99, 226, 183),  // #63E2B7
340                rgb(127, 231, 196), // #7FE7C4
341                rgb(90, 206, 167),  // #5ACEA7
342                rgb(42, 148, 125),  // #2A947D
343            ),
344            warning: ColorFamily::new_dark(
345                rgb(242, 201, 125), // #F2C97D
346                rgb(245, 213, 153), // #F5D599
347                rgb(230, 194, 96),  // #E6C260
348                rgb(240, 138, 0),   // #F08A00
349            ),
350            danger: ColorFamily::new_dark(
351                rgb(232, 128, 128), // #E88080
352                rgb(233, 139, 139), // #E98B8B
353                rgb(229, 114, 114), // #E57272
354                rgb(208, 58, 82),   // #D03A52
355            ),
356
357            neutral: NeutralTokens {
358                body: rgb(16, 16, 20),    // #101014
359                card: rgb(24, 24, 28),    // #18181C
360                modal: rgb(44, 44, 50),   // #2C2C32
361                popover: rgb(72, 72, 78), // #48484E
362                inverted: rgb(255, 255, 255),
363
364                text_1: rgba(255, 255, 255, 0.90),
365                text_2: rgba(255, 255, 255, 0.82),
366                text_3: rgba(255, 255, 255, 0.52),
367                text_disabled: rgba(255, 255, 255, 0.38),
368                placeholder: rgba(255, 255, 255, 0.38),
369                icon: rgba(255, 255, 255, 0.38),
370
371                border: rgba(255, 255, 255, 0.24),
372                divider: rgba(255, 255, 255, 0.09),
373
374                hover: rgba(255, 255, 255, 0.09),
375                pressed: rgba(255, 255, 255, 0.05),
376
377                rail: rgba(255, 255, 255, 0.20),
378
379                overlay: rgba(0, 0, 0, 0.60),
380                mask: rgba(0, 0, 0, 0.70),
381            },
382
383            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)",
384            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)",
385            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)",
386
387            secondary: SecondaryColors {
388                bg: rgba(255, 255, 255, 0.08),
389                hover: rgba(255, 255, 255, 0.12),
390                pressed: rgba(255, 255, 255, 0.16),
391            },
392        }
393    }
394
395    // ========================================================================
396    // Convenience: resolve colors for a ButtonVariant
397    // ========================================================================
398    pub fn color_by_variant(
399        &self,
400        variant: ButtonVariant,
401        secondary: bool,
402        background: bool,
403        border: bool,
404    ) -> ButtonVariantColors {
405        if secondary {
406            return self.secondary_colors(variant, background, border);
407        }
408
409        // Filled (primary) style
410        match variant {
411            ButtonVariant::Default => ButtonVariantColors {
412                bg: rgba(0, 0, 0, 0.0),
413                hover_bg: self.secondary.hover,
414                active_bg: self.secondary.pressed,
415                text: self.neutral.text_2,
416                border: self.neutral.border,
417                text_hover: self.primary.base,
418                border_hover: self.primary.base,
419            },
420            ButtonVariant::Tertiary => ButtonVariantColors {
421                bg: self.secondary.bg,
422                hover_bg: self.secondary.hover,
423                active_bg: self.secondary.pressed,
424                text: self.neutral.text_2,
425                border: rgba(0, 0, 0, 0.0),
426                text_hover: self.neutral.text_1,
427                border_hover: rgba(0, 0, 0, 0.0),
428            },
429            ButtonVariant::Text => ButtonVariantColors {
430                bg: rgba(0, 0, 0, 0.0),
431                hover_bg: self.secondary.hover,
432                active_bg: self.secondary.pressed,
433                text: self.neutral.text_2,
434                border: rgba(0, 0, 0, 0.0),
435                text_hover: self.primary.base,
436                border_hover: rgba(0, 0, 0, 0.0),
437            },
438            ButtonVariant::Primary => self.filled_colors(&self.primary),
439            ButtonVariant::Info => self.filled_colors(&self.info),
440            ButtonVariant::Success => self.filled_colors(&self.success),
441            ButtonVariant::Warning => self.filled_colors(&self.warning),
442            ButtonVariant::Danger => self.filled_colors(&self.danger),
443        }
444    }
445
446    /// Secondary (light bg + colored text) for colored variants;
447    /// Default/Tertiary stay neutral.
448    fn secondary_colors(
449        &self,
450        variant: ButtonVariant,
451        show_bg: bool,
452        show_border: bool,
453    ) -> ButtonVariantColors {
454        match variant {
455            ButtonVariant::Default => ButtonVariantColors {
456                bg: if show_bg {
457                    self.secondary.bg
458                } else {
459                    rgba(0, 0, 0, 0.0)
460                },
461                hover_bg: self.secondary.hover,
462                active_bg: self.secondary.pressed,
463                text: self.neutral.text_2,
464                border: if show_border {
465                    self.neutral.border
466                } else {
467                    rgba(0, 0, 0, 0.0)
468                },
469                text_hover: self.primary.base,
470                border_hover: self.primary.base,
471            },
472            ButtonVariant::Tertiary => ButtonVariantColors {
473                bg: if show_bg {
474                    self.secondary.bg
475                } else {
476                    rgba(0, 0, 0, 0.0)
477                },
478                hover_bg: self.secondary.hover,
479                active_bg: self.secondary.pressed,
480                text: self.neutral.text_2,
481                border: if show_border {
482                    self.neutral.border
483                } else {
484                    rgba(0, 0, 0, 0.0)
485                },
486                text_hover: self.neutral.text_1,
487                border_hover: rgba(0, 0, 0, 0.0),
488            },
489            ButtonVariant::Text => ButtonVariantColors {
490                bg: rgba(0, 0, 0, 0.0),
491                hover_bg: self.secondary.hover,
492                active_bg: self.secondary.pressed,
493                text: self.neutral.text_2,
494                border: rgba(0, 0, 0, 0.0),
495                text_hover: self.primary.base,
496                border_hover: rgba(0, 0, 0, 0.0),
497            },
498            ButtonVariant::Primary => self.secondary_family(&self.primary, show_bg, show_border),
499            ButtonVariant::Info => self.secondary_family(&self.info, show_bg, show_border),
500            ButtonVariant::Success => self.secondary_family(&self.success, show_bg, show_border),
501            ButtonVariant::Warning => self.secondary_family(&self.warning, show_bg, show_border),
502            ButtonVariant::Danger => self.secondary_family(&self.danger, show_bg, show_border),
503        }
504    }
505
506    fn secondary_family(
507        &self,
508        family: &ColorFamily,
509        show_bg: bool,
510        show_border: bool,
511    ) -> ButtonVariantColors {
512        ButtonVariantColors {
513            bg: if show_bg {
514                family.light_9
515            } else {
516                rgba(0, 0, 0, 0.0)
517            },
518            hover_bg: family.light_8,
519            active_bg: family.light_7,
520            text: family.base,
521            border: if show_border {
522                family.base
523            } else {
524                rgba(0, 0, 0, 0.0)
525            },
526            text_hover: family.hover,
527            border_hover: family.hover,
528        }
529    }
530
531    fn filled_colors(&self, family: &ColorFamily) -> ButtonVariantColors {
532        let hover = family.base.blend(gpui::black().opacity(0.10));
533        let active = family.base.blend(gpui::black().opacity(0.25));
534        ButtonVariantColors {
535            bg: family.base,
536            hover_bg: hover,
537            active_bg: active,
538            text: rgb(255, 255, 255),
539            border: family.base,
540            text_hover: rgb(255, 255, 255),
541            border_hover: hover,
542        }
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Enums
548// ---------------------------------------------------------------------------
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum ButtonVariant {
552    Default,
553    Tertiary,
554    Text,
555    Primary,
556    Info,
557    Success,
558    Warning,
559    Danger,
560}
561
562pub struct ButtonVariantColors {
563    pub bg: Hsla,
564    pub hover_bg: Hsla,
565    pub active_bg: Hsla,
566    pub text: Hsla,
567    pub border: Hsla,
568    pub text_hover: Hsla,
569    pub border_hover: Hsla,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573pub enum ButtonSize {
574    Small,
575    Default,
576    Large,
577}
578
579impl ButtonSize {
580    pub fn height(&self) -> f32 {
581        match self {
582            ButtonSize::Small => 28.0,   // NaiveUI heightSmall
583            ButtonSize::Default => 34.0, // NaiveUI heightMedium
584            ButtonSize::Large => 40.0,   // NaiveUI heightLarge
585        }
586    }
587
588    pub fn padding_x(&self) -> f32 {
589        match self {
590            ButtonSize::Small => 12.0,   // NaiveUI: 0 12px
591            ButtonSize::Default => 14.0, // NaiveUI: 0 14px
592            ButtonSize::Large => 18.0,   // NaiveUI: 0 18px
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use gpui::Rgba;
601
602    fn rgba_color(color: Hsla) -> Rgba {
603        color.into()
604    }
605
606    #[test]
607    fn filled_button_hover_and_active_backgrounds_get_progressively_darker() {
608        let theme = Theme::light();
609        let colors = theme.color_by_variant(ButtonVariant::Primary, false, true, true);
610
611        let bg = rgba_color(colors.bg);
612        let hover = rgba_color(colors.hover_bg);
613        let active = rgba_color(colors.active_bg);
614
615        assert!(hover.r < bg.r, "hover red channel should be darker");
616        assert!(hover.g < bg.g, "hover green channel should be darker");
617        assert!(hover.b < bg.b, "hover blue channel should be darker");
618        assert!(
619            active.r < hover.r,
620            "active red channel should be darker than hover"
621        );
622        assert!(
623            active.g < hover.g,
624            "active green channel should be darker than hover"
625        );
626        assert!(
627            active.b < hover.b,
628            "active blue channel should be darker than hover"
629        );
630    }
631
632    #[test]
633    fn dark_semantic_subtle_backgrounds_remain_translucent() {
634        let theme = Theme::dark();
635
636        assert!(theme.primary.light_9.a < 0.2);
637        assert!(theme.primary.light_8.a > theme.primary.light_9.a);
638        assert!(theme.primary.light_7.a > theme.primary.light_8.a);
639        assert_eq!(theme.primary.light_9.h, theme.primary.base.h);
640    }
641
642    #[test]
643    fn light_semantic_subtle_backgrounds_remain_opaque_tints() {
644        let theme = Theme::light();
645
646        assert_eq!(theme.primary.light_9.a, 1.0);
647        assert!(theme.primary.light_9.l > theme.primary.base.l);
648    }
649
650    #[test]
651    fn default_button_hover_and_active_backgrounds_are_visible_overlays() {
652        let theme = Theme::light();
653        let colors = theme.color_by_variant(ButtonVariant::Default, false, true, true);
654
655        let bg = rgba_color(colors.bg);
656        let hover = rgba_color(colors.hover_bg);
657        let active = rgba_color(colors.active_bg);
658
659        assert_eq!(
660            bg.a, 0.0,
661            "default button base background should stay transparent"
662        );
663        assert!(hover.a > bg.a, "hover background should be visible");
664        assert!(
665            active.a > hover.a,
666            "active background should be stronger than hover"
667        );
668    }
669}