1use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct ThemeTokens {
11 pub colors: ColorScale,
13 pub spacing: SpacingScale,
15 pub radius: RadiusScale,
17 pub typography: TypographyScale,
19 pub shadows: ShadowScale,
21 pub mode: ThemeMode,
23}
24
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27pub enum ThemeMode {
28 Light,
30 Dark,
32 Brand(String),
34}
35
36impl Default for ThemeMode {
37 fn default() -> Self {
38 ThemeMode::Light
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub struct ColorScale {
45 pub primary: Color,
47 pub primary_foreground: Color,
49 pub secondary: Color,
51 pub secondary_foreground: Color,
53 pub background: Color,
55 pub foreground: Color,
57 pub muted: Color,
59 pub muted_foreground: Color,
61 pub border: Color,
63 pub destructive: Color,
65 pub success: Color,
67 pub warning: Color,
69 pub accent: Color,
71 pub accent_foreground: Color,
73 pub card: Color,
75 pub card_foreground: Color,
77 pub popover: Color,
79 pub popover_foreground: Color,
81 pub disabled: Color,
83 pub ring: Color,
85}
86
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct Color {
90 pub r: u8,
92 pub g: u8,
94 pub b: u8,
96 pub a: f32,
98}
99
100impl Color {
101 pub const fn new(r: u8, g: u8, b: u8) -> Self {
103 Self { r, g, b, a: 1.0 }
104 }
105
106 pub const fn new_rgba(r: u8, g: u8, b: u8, a: f32) -> Self {
108 Self { r, g, b, a }
109 }
110
111 pub fn to_rgba(&self) -> String {
113 format!("rgba({}, {}, {}, {})", self.r, self.g, self.b, self.a)
114 }
115
116 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 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 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 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 pub fn to_rgba_tuple(&self) -> (u8, u8, u8, f32) {
161 (self.r, self.g, self.b, self.a)
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167pub struct SpacingScale {
168 pub xs: u16, pub sm: u16, pub md: u16, pub lg: u16, pub xl: u16, pub xxl: u16, }
175
176impl SpacingScale {
177 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#[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, }
201
202impl RadiusScale {
203 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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234pub struct Typography {
235 pub size: u16,
237 pub line_height: f32,
239 pub weight: u16,
241 pub family: String,
243 pub letter_spacing: Option<f32>,
245}
246
247impl TypographyScale {
248 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#[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 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 pub fn colored(&self, size: &str, color: &Color) -> String {
293 let base = self.get(size);
294 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
312impl ThemeTokens {
314 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 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 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
471fn 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
477impl ThemeTokens {
479 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.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.colors.accent_foreground = Color::new(136, 19, 55); rose
489 }
490
491 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.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.colors.accent_foreground = Color::new(30, 58, 138); blue
501 }
502
503 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.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.colors.accent_foreground = Color::new(20, 83, 45); green
513 }
514
515 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.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.colors.accent_foreground = Color::new(91, 33, 182); violet
525 }
526
527 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.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.colors.accent_foreground = Color::new(154, 52, 18); orange
537 }
538
539 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 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}