Skip to main content

dioxus_ui_system/theme/
tokens.rs

1//! Design tokens for the UI system
2//!
3//! This module provides type-safe design tokens including colors, spacing,
4//! typography, and other visual properties that define the theme.
5
6use serde::{Deserialize, Serialize};
7
8/// The main theme tokens structure containing all design tokens
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct ThemeTokens {
11    /// Color palette
12    pub colors: ColorScale,
13    /// Spacing scale (margins, paddings, gaps)
14    pub spacing: SpacingScale,
15    /// Border radius scale
16    pub radius: RadiusScale,
17    /// Typography scale (font sizes, weights, etc.)
18    pub typography: TypographyScale,
19    /// Box shadows
20    pub shadows: ShadowScale,
21    /// Current theme mode
22    pub mode: ThemeMode,
23}
24
25/// Theme mode variants supporting light, dark, and custom brand themes
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub enum ThemeMode {
28    /// Light mode
29    Light,
30    /// Dark mode
31    Dark,
32    /// Custom brand theme with name
33    Brand(String),
34}
35
36impl Default for ThemeMode {
37    fn default() -> Self {
38        ThemeMode::Light
39    }
40}
41
42/// Color scale with semantic color names
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub struct ColorScale {
45    /// Primary brand color
46    pub primary: Color,
47    /// Text color on primary background
48    pub primary_foreground: Color,
49    /// Secondary accent color
50    pub secondary: Color,
51    /// Text color on secondary background
52    pub secondary_foreground: Color,
53    /// Page/element background color
54    pub background: Color,
55    /// Main text color
56    pub foreground: Color,
57    /// Muted/subtle background
58    pub muted: Color,
59    /// Muted text color
60    pub muted_foreground: Color,
61    /// Border color
62    pub border: Color,
63    /// Error/destructive color
64    pub destructive: Color,
65    /// Success color
66    pub success: Color,
67    /// Warning color
68    pub warning: Color,
69    /// Accent color for highlights
70    pub accent: Color,
71    /// Text on accent
72    pub accent_foreground: Color,
73    /// Card/elevated surface background
74    pub card: Color,
75    /// Text on card
76    pub card_foreground: Color,
77    /// Popover/dropdown background
78    pub popover: Color,
79    /// Text on popover
80    pub popover_foreground: Color,
81    /// Disabled state
82    pub disabled: Color,
83    /// Ring/focus indicator
84    pub ring: Color,
85}
86
87/// RGBA color representation
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct Color {
90    /// Red channel (0-255)
91    pub r: u8,
92    /// Green channel (0-255)
93    pub g: u8,
94    /// Blue channel (0-255)
95    pub b: u8,
96    /// Alpha channel (0.0-1.0)
97    pub a: f32,
98}
99
100impl Color {
101    /// Create a new color with RGB values (alpha = 1.0)
102    pub const fn new(r: u8, g: u8, b: u8) -> Self {
103        Self { r, g, b, a: 1.0 }
104    }
105
106    /// Create a new color with RGBA values
107    pub const fn new_rgba(r: u8, g: u8, b: u8, a: f32) -> Self {
108        Self { r, g, b, a }
109    }
110
111    /// Convert to CSS rgba() string
112    pub fn to_rgba(&self) -> String {
113        format!("rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a)
114    }
115
116    /// Convert to hex string
117    pub fn to_hex(&self) -> String {
118        if self.a < 1.0 {
119            format!(
120                "#{:02x}{:02x}{:02x}{:02x}",
121                self.r,
122                self.g,
123                self.b,
124                (self.a * 255.0) as u8
125            )
126        } else {
127            format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
128        }
129    }
130
131    /// Darken the color by a given amount (0.0-1.0)
132    pub fn darken(&self, amount: f32) -> Color {
133        let factor = 1.0 - amount.clamp(0.0, 1.0);
134        Color {
135            r: ((self.r as f32) * factor).clamp(0.0, 255.0) as u8,
136            g: ((self.g as f32) * factor).clamp(0.0, 255.0) as u8,
137            b: ((self.b as f32) * factor).clamp(0.0, 255.0) as u8,
138            a: self.a,
139        }
140    }
141
142    /// Lighten the color by a given amount (0.0-1.0)
143    pub fn lighten(&self, amount: f32) -> Color {
144        let factor = amount.clamp(0.0, 1.0);
145        Color {
146            r: ((self.r as f32) + (255.0 - self.r as f32) * factor).clamp(0.0, 255.0) as u8,
147            g: ((self.g as f32) + (255.0 - self.g as f32) * factor).clamp(0.0, 255.0) as u8,
148            b: ((self.b as f32) + (255.0 - self.b as f32) * factor).clamp(0.0, 255.0) as u8,
149            a: self.a,
150        }
151    }
152
153    /// Blend with another color (0.0 = self, 1.0 = other)
154    pub fn blend(&self, other: &Color, ratio: f32) -> Color {
155        let r = ratio.clamp(0.0, 1.0);
156        Color {
157            r: ((self.r as f32) * (1.0 - r) + (other.r as f32) * r) as u8,
158            g: ((self.g as f32) * (1.0 - r) + (other.g as f32) * r) as u8,
159            b: ((self.b as f32) * (1.0 - r) + (other.b as f32) * r) as u8,
160            a: self.a * (1.0 - r) + other.a * r,
161        }
162    }
163
164    /// Convert to RGBA tuple
165    pub fn to_rgba_tuple(&self) -> (u8, u8, u8, f32) {
166        (self.r, self.g, self.b, self.a)
167    }
168}
169
170/// Spacing scale for margins, paddings, and gaps
171#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct SpacingScale {
173    pub xs: u16,  // 4px
174    pub sm: u16,  // 8px
175    pub md: u16,  // 16px
176    pub lg: u16,  // 24px
177    pub xl: u16,  // 32px
178    pub xxl: u16, // 48px
179}
180
181impl SpacingScale {
182    /// Get spacing value by name
183    pub fn get(&self, size: &str) -> u16 {
184        match size {
185            "xs" => self.xs,
186            "sm" => self.sm,
187            "md" => self.md,
188            "lg" => self.lg,
189            "xl" => self.xl,
190            "xxl" => self.xxl,
191            _ => self.md,
192        }
193    }
194}
195
196/// Border radius scale
197#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
198pub struct RadiusScale {
199    pub none: u16,
200    pub sm: u16,
201    pub md: u16,
202    pub lg: u16,
203    pub xl: u16,
204    pub full: u16, // 9999 for circles/pills
205}
206
207impl RadiusScale {
208    /// Get radius value by name
209    pub fn get(&self, size: &str) -> u16 {
210        match size {
211            "none" => self.none,
212            "sm" => self.sm,
213            "md" => self.md,
214            "lg" => self.lg,
215            "xl" => self.xl,
216            "full" => self.full,
217            _ => self.md,
218        }
219    }
220}
221
222/// Typography scale
223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
224pub struct TypographyScale {
225    pub xs: Typography,
226    pub sm: Typography,
227    pub base: Typography,
228    pub lg: Typography,
229    pub xl: Typography,
230    pub xxl: Typography,
231    pub h1: Typography,
232    pub h2: Typography,
233    pub h3: Typography,
234    pub h4: Typography,
235}
236
237/// Individual typography specification
238#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239pub struct Typography {
240    /// Font size in pixels
241    pub size: u16,
242    /// Line height as multiplier
243    pub line_height: f32,
244    /// Font weight (400 = normal, 700 = bold)
245    pub weight: u16,
246    /// Font family stack
247    pub family: String,
248    /// Letter spacing in pixels
249    pub letter_spacing: Option<f32>,
250}
251
252impl TypographyScale {
253    /// Get typography by name
254    pub fn get(&self, size: &str) -> &Typography {
255        match size {
256            "xs" => &self.xs,
257            "sm" => &self.sm,
258            "base" => &self.base,
259            "lg" => &self.lg,
260            "xl" => &self.xl,
261            "xxl" => &self.xxl,
262            "h1" => &self.h1,
263            "h2" => &self.h2,
264            "h3" => &self.h3,
265            "h4" => &self.h4,
266            _ => &self.base,
267        }
268    }
269}
270
271/// Box shadow scale
272#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
273pub struct ShadowScale {
274    pub none: String,
275    pub sm: String,
276    pub md: String,
277    pub lg: String,
278    pub xl: String,
279    pub inner: String,
280}
281
282impl ShadowScale {
283    /// Get shadow by name
284    pub fn get(&self, size: &str) -> &String {
285        match size {
286            "none" => &self.none,
287            "sm" => &self.sm,
288            "md" => &self.md,
289            "lg" => &self.lg,
290            "xl" => &self.xl,
291            "inner" => &self.inner,
292            _ => &self.md,
293        }
294    }
295
296    /// Create a colored shadow based on a color
297    pub fn colored(&self, size: &str, color: &Color) -> String {
298        let base = self.get(size);
299        // Replace the rgba values in the shadow with the color
300        format!("{}; box-shadow-color: {}", base, color.to_rgba())
301    }
302}
303
304impl Default for ShadowScale {
305    fn default() -> Self {
306        Self {
307            none: String::new(),
308            sm: "0 1px 2px 0 rgba(0, 0, 0, 0.05)".into(),
309            md: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)".into(),
310            lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)".into(),
311            xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)".into(),
312            inner: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)".into(),
313        }
314    }
315}
316
317// Preset themes implementation
318impl ThemeTokens {
319    /// Create the default light theme
320    pub fn light() -> Self {
321        Self {
322            mode: ThemeMode::Light,
323            colors: ColorScale {
324                primary: Color::new(15, 23, 42),
325                primary_foreground: Color::new(248, 250, 252),
326                secondary: Color::new(241, 245, 249),
327                secondary_foreground: Color::new(15, 23, 42),
328                background: Color::new(255, 255, 255),
329                foreground: Color::new(15, 23, 42),
330                muted: Color::new(248, 250, 252),
331                muted_foreground: Color::new(100, 116, 139),
332                border: Color::new(226, 232, 240),
333                destructive: Color::new(239, 68, 68),
334                success: Color::new(34, 197, 94),
335                warning: Color::new(234, 179, 8),
336                accent: Color::new(241, 245, 249),
337                accent_foreground: Color::new(15, 23, 42),
338                card: Color::new(255, 255, 255),
339                card_foreground: Color::new(15, 23, 42),
340                popover: Color::new(255, 255, 255),
341                popover_foreground: Color::new(15, 23, 42),
342                disabled: Color::new(241, 245, 249),
343                ring: Color::new(15, 23, 42),
344            },
345            spacing: SpacingScale {
346                xs: 4,
347                sm: 8,
348                md: 16,
349                lg: 24,
350                xl: 32,
351                xxl: 48,
352            },
353            radius: RadiusScale {
354                none: 0,
355                sm: 4,
356                md: 8,
357                lg: 12,
358                xl: 16,
359                full: 9999,
360            },
361            typography: TypographyScale {
362                xs: Typography {
363                    size: 12,
364                    line_height: 1.0,
365                    weight: 400,
366                    family: "system-ui, -apple-system, sans-serif".into(),
367                    letter_spacing: Some(0.01),
368                },
369                sm: Typography {
370                    size: 14,
371                    line_height: 1.25,
372                    weight: 400,
373                    family: "system-ui, -apple-system, sans-serif".into(),
374                    letter_spacing: None,
375                },
376                base: Typography {
377                    size: 16,
378                    line_height: 1.5,
379                    weight: 400,
380                    family: "system-ui, -apple-system, sans-serif".into(),
381                    letter_spacing: None,
382                },
383                lg: Typography {
384                    size: 18,
385                    line_height: 1.75,
386                    weight: 400,
387                    family: "system-ui, -apple-system, sans-serif".into(),
388                    letter_spacing: None,
389                },
390                xl: Typography {
391                    size: 20,
392                    line_height: 1.75,
393                    weight: 600,
394                    family: "system-ui, -apple-system, sans-serif".into(),
395                    letter_spacing: None,
396                },
397                xxl: Typography {
398                    size: 24,
399                    line_height: 2.0,
400                    weight: 600,
401                    family: "system-ui, -apple-system, sans-serif".into(),
402                    letter_spacing: None,
403                },
404                h1: Typography {
405                    size: 36,
406                    line_height: 1.1,
407                    weight: 700,
408                    family: "system-ui, -apple-system, sans-serif".into(),
409                    letter_spacing: Some(-0.02),
410                },
411                h2: Typography {
412                    size: 30,
413                    line_height: 1.2,
414                    weight: 700,
415                    family: "system-ui, -apple-system, sans-serif".into(),
416                    letter_spacing: Some(-0.02),
417                },
418                h3: Typography {
419                    size: 24,
420                    line_height: 1.3,
421                    weight: 600,
422                    family: "system-ui, -apple-system, sans-serif".into(),
423                    letter_spacing: None,
424                },
425                h4: Typography {
426                    size: 20,
427                    line_height: 1.4,
428                    weight: 600,
429                    family: "system-ui, -apple-system, sans-serif".into(),
430                    letter_spacing: None,
431                },
432            },
433            shadows: ShadowScale::default(),
434        }
435    }
436
437    /// Create the dark theme
438    pub fn dark() -> Self {
439        let mut dark = Self::light();
440        dark.mode = ThemeMode::Dark;
441        dark.colors.background = Color::new(15, 23, 42);
442        dark.colors.foreground = Color::new(248, 250, 252);
443        dark.colors.muted = Color::new(30, 41, 59);
444        dark.colors.muted_foreground = Color::new(148, 163, 184);
445        dark.colors.border = Color::new(51, 65, 85);
446        dark.colors.primary = Color::new(248, 250, 252);
447        dark.colors.primary_foreground = Color::new(15, 23, 42);
448        dark.colors.secondary = Color::new(30, 41, 59);
449        dark.colors.secondary_foreground = Color::new(248, 250, 252);
450        dark.colors.accent = Color::new(30, 41, 59);
451        dark.colors.accent_foreground = Color::new(248, 250, 252);
452        dark.colors.card = Color::new(15, 23, 42);
453        dark.colors.card_foreground = Color::new(248, 250, 252);
454        dark.colors.popover = Color::new(15, 23, 42);
455        dark.colors.popover_foreground = Color::new(248, 250, 252);
456        dark.colors.disabled = Color::new(30, 41, 59);
457        dark.colors.ring = Color::new(248, 250, 252);
458        dark
459    }
460
461    /// Create a brand theme with custom primary color
462    pub fn brand(primary: Color, name: &str) -> Self {
463        let mut brand = Self::light();
464        brand.mode = ThemeMode::Brand(name.into());
465        brand.colors.primary = primary.clone();
466        brand.colors.primary_foreground = if is_dark_color(&primary) {
467            Color::new(255, 255, 255)
468        } else {
469            Color::new(0, 0, 0)
470        };
471        brand.colors.ring = primary;
472        brand
473    }
474}
475
476/// Determine if a color is dark (useful for choosing contrasting text)
477fn is_dark_color(color: &Color) -> bool {
478    let luminance =
479        (0.299 * color.r as f32 + 0.587 * color.g as f32 + 0.114 * color.b as f32) / 255.0;
480    luminance < 0.5
481}
482
483// Preset theme implementations
484impl ThemeTokens {
485    /// Rose theme - romantic pink/red tones
486    pub fn rose() -> Self {
487        let mut rose = Self::light();
488        rose.mode = ThemeMode::Brand("rose".into());
489        rose.colors.primary = Color::new(225, 29, 72); // Rose 600
490        rose.colors.primary_foreground = Color::new(255, 255, 255);
491        rose.colors.ring = Color::new(225, 29, 72);
492        rose.colors.accent = Color::new(255, 228, 230); // Rose 100
493        rose.colors.accent_foreground = Color::new(136, 19, 55); // Rose 800
494        rose
495    }
496
497    /// Blue theme - cool blue tones
498    pub fn blue() -> Self {
499        let mut blue = Self::light();
500        blue.mode = ThemeMode::Brand("blue".into());
501        blue.colors.primary = Color::new(37, 99, 235); // Blue 600
502        blue.colors.primary_foreground = Color::new(255, 255, 255);
503        blue.colors.ring = Color::new(37, 99, 235);
504        blue.colors.accent = Color::new(219, 234, 254); // Blue 100
505        blue.colors.accent_foreground = Color::new(30, 58, 138); // Blue 800
506        blue
507    }
508
509    /// Green theme - nature green tones
510    pub fn green() -> Self {
511        let mut green = Self::light();
512        green.mode = ThemeMode::Brand("green".into());
513        green.colors.primary = Color::new(22, 163, 74); // Green 600
514        green.colors.primary_foreground = Color::new(255, 255, 255);
515        green.colors.ring = Color::new(22, 163, 74);
516        green.colors.accent = Color::new(220, 252, 231); // Green 100
517        green.colors.accent_foreground = Color::new(20, 83, 45); // Green 800
518        green
519    }
520
521    /// Violet theme - purple tones
522    pub fn violet() -> Self {
523        let mut violet = Self::light();
524        violet.mode = ThemeMode::Brand("violet".into());
525        violet.colors.primary = Color::new(124, 58, 237); // Violet 600
526        violet.colors.primary_foreground = Color::new(255, 255, 255);
527        violet.colors.ring = Color::new(124, 58, 237);
528        violet.colors.accent = Color::new(237, 233, 254); // Violet 100
529        violet.colors.accent_foreground = Color::new(91, 33, 182); // Violet 800
530        violet
531    }
532
533    /// Orange theme - warm orange tones
534    pub fn orange() -> Self {
535        let mut orange = Self::light();
536        orange.mode = ThemeMode::Brand("orange".into());
537        orange.colors.primary = Color::new(234, 88, 12); // Orange 600
538        orange.colors.primary_foreground = Color::new(255, 255, 255);
539        orange.colors.ring = Color::new(234, 88, 12);
540        orange.colors.accent = Color::new(255, 237, 213); // Orange 100
541        orange.colors.accent_foreground = Color::new(154, 52, 18); // Orange 800
542        orange
543    }
544
545    /// Get all available preset themes
546    pub fn presets() -> Vec<(&'static str, ThemeTokens)> {
547        vec![
548            ("light", Self::light()),
549            ("dark", Self::dark()),
550            ("rose", Self::rose()),
551            ("blue", Self::blue()),
552            ("green", Self::green()),
553            ("violet", Self::violet()),
554            ("orange", Self::orange()),
555        ]
556    }
557
558    /// Get theme by name
559    pub fn by_name(name: &str) -> Option<Self> {
560        match name {
561            "light" => Some(Self::light()),
562            "dark" => Some(Self::dark()),
563            "rose" => Some(Self::rose()),
564            "blue" => Some(Self::blue()),
565            "green" => Some(Self::green()),
566            "violet" => Some(Self::violet()),
567            "orange" => Some(Self::orange()),
568            _ => None,
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_color_darken() {
579        let white = Color::new(255, 255, 255);
580        let darkened = white.darken(0.5);
581        assert_eq!(darkened.r, 127);
582        assert_eq!(darkened.g, 127);
583        assert_eq!(darkened.b, 127);
584    }
585
586    #[test]
587    fn test_color_lighten() {
588        let black = Color::new(0, 0, 0);
589        let lightened = black.lighten(0.5);
590        assert_eq!(lightened.r, 127);
591        assert_eq!(lightened.g, 127);
592        assert_eq!(lightened.b, 127);
593    }
594
595    #[test]
596    fn test_color_blend() {
597        let red = Color::new(255, 0, 0);
598        let blue = Color::new(0, 0, 255);
599        let blended = red.blend(&blue, 0.5);
600        assert_eq!(blended.r, 127);
601        assert_eq!(blended.g, 0);
602        assert_eq!(blended.b, 127);
603    }
604
605    #[test]
606    fn test_is_dark_color() {
607        assert!(is_dark_color(&Color::new(0, 0, 0)));
608        assert!(!is_dark_color(&Color::new(255, 255, 255)));
609    }
610
611    #[test]
612    fn test_preset_themes() {
613        let presets = ThemeTokens::presets();
614        assert_eq!(presets.len(), 7);
615        assert!(ThemeTokens::by_name("light").is_some());
616        assert!(ThemeTokens::by_name("dark").is_some());
617        assert!(ThemeTokens::by_name("rose").is_some());
618        assert!(ThemeTokens::by_name("unknown").is_none());
619    }
620}