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