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!(
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 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 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 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 pub fn to_rgba_tuple(&self) -> (u8, u8, u8, f32) {
166 (self.r, self.g, self.b, self.a)
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
172pub struct SpacingScale {
173 pub xs: u16, pub sm: u16, pub md: u16, pub lg: u16, pub xl: u16, pub xxl: u16, }
180
181impl SpacingScale {
182 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#[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, }
206
207impl RadiusScale {
208 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#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
239pub struct Typography {
240 pub size: u16,
242 pub line_height: f32,
244 pub weight: u16,
246 pub family: String,
248 pub letter_spacing: Option<f32>,
250}
251
252impl TypographyScale {
253 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#[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 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 pub fn colored(&self, size: &str, color: &Color) -> String {
298 let base = self.get(size);
299 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
317impl ThemeTokens {
319 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 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 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
476fn 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
483impl ThemeTokens {
485 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.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.colors.accent_foreground = Color::new(136, 19, 55); rose
495 }
496
497 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.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.colors.accent_foreground = Color::new(30, 58, 138); blue
507 }
508
509 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.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.colors.accent_foreground = Color::new(20, 83, 45); green
519 }
520
521 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.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.colors.accent_foreground = Color::new(91, 33, 182); violet
531 }
532
533 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.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.colors.accent_foreground = Color::new(154, 52, 18); orange
543 }
544
545 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 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}