1mod tokens;
8
9use ravenrs_animation::Transition;
10use ravenrs_core::{Color, Insets, Point, Shadow};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
17pub enum Semantic {
18 Canvas,
20 Surface,
22 SurfaceElevated,
24 Control,
26 Border,
28 Ink,
30 InkMuted,
32 Accent,
34 AccentHover,
36 AccentPressed,
38 OnAccent,
40 FocusRing,
42 Success,
44 Warning,
46 Danger,
48 Scrim,
50 Chart1,
52 Chart2,
54 Chart3,
56 Chart4,
58 Chart5,
60 ChartGrid,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq)]
66#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
67pub struct Palette {
68 pub bg_000: Color,
70 pub bg_100: Color,
72 pub bg_200: Color,
74 pub bg_300: Color,
76 pub border: Color,
78 pub ink: Color,
80 pub ink_muted: Color,
82 pub accent: Color,
84 pub accent_hover: Color,
86 pub accent_pressed: Color,
88 pub on_accent: Color,
90 pub focus_ring: Color,
92 pub success: Color,
94 pub warning: Color,
96 pub danger: Color,
98}
99
100#[cfg_attr(feature = "live", ravenrs_live::expose)]
101impl Palette {
102 pub fn get(&self, role: Semantic) -> Color {
104 match role {
105 Semantic::Canvas => self.bg_000,
106 Semantic::Surface => self.bg_100,
107 Semantic::SurfaceElevated => self.bg_200,
108 Semantic::Control => self.bg_300,
109 Semantic::Border => self.border,
110 Semantic::Ink => self.ink,
111 Semantic::InkMuted => self.ink_muted,
112 Semantic::Accent => self.accent,
113 Semantic::AccentHover => self.accent_hover,
114 Semantic::AccentPressed => self.accent_pressed,
115 Semantic::OnAccent => self.on_accent,
116 Semantic::FocusRing => self.focus_ring,
117 Semantic::Success => self.success,
118 Semantic::Warning => self.warning,
119 Semantic::Danger => self.danger,
120 Semantic::Scrim => Color::rgba(0.0, 0.0, 0.0, 0.5),
121 Semantic::Chart1 => self.accent,
122 Semantic::Chart2 => self.chart_color(0x0f9f8f, 0x2dd4bf),
123 Semantic::Chart3 => self.chart_color(0x8b5cf6, 0xa78bfa),
124 Semantic::Chart4 => self.chart_color(0xe08a00, 0xfbbf24),
125 Semantic::Chart5 => self.chart_color(0xdb2777, 0xf472b6),
126 Semantic::ChartGrid => self.border,
127 }
128 }
129
130 fn chart_color(&self, on_light: u32, on_dark: u32) -> Color {
133 let on_dark_theme = self.ink.r + self.ink.g + self.ink.b > 1.5;
134 let rgb = if on_dark_theme { on_dark } else { on_light };
135 let channel = |shift: u32| ((rgb >> shift) & 0xff) as f32 / 255.0;
136 Color::rgba(channel(16), channel(8), channel(0), 1.0)
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq)]
142#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
143pub struct Weight(pub f32);
144
145#[cfg_attr(feature = "live", ravenrs_live::expose)]
146impl Weight {
147 pub const REGULAR: Self = Self(400.0);
149 pub const MEDIUM: Self = Self(500.0);
151 pub const SEMIBOLD: Self = Self(600.0);
153 pub const BOLD: Self = Self(700.0);
155}
156
157#[derive(Clone, Copy, Debug, PartialEq)]
159#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
160pub struct TypeStyle {
161 pub size: f32,
163 pub line_height: f32,
165 pub weight: Weight,
167 pub letter_spacing: f32,
169}
170
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
173#[non_exhaustive]
174#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
175pub enum TextStyle {
176 Metric,
178 Display,
180 Title,
182 Headline,
184 #[default]
186 Body,
187 Callout,
189 Caption,
191}
192
193#[derive(Clone, Copy, Debug, PartialEq)]
195#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
196pub struct Typography {
197 pub metric: TypeStyle,
199 pub display: TypeStyle,
201 pub title: TypeStyle,
203 pub headline: TypeStyle,
205 pub body: TypeStyle,
207 pub callout: TypeStyle,
209 pub caption: TypeStyle,
211}
212
213#[cfg_attr(feature = "live", ravenrs_live::expose)]
214impl Typography {
215 pub fn get(&self, style: TextStyle) -> TypeStyle {
217 match style {
218 TextStyle::Metric => self.metric,
219 TextStyle::Display => self.display,
220 TextStyle::Title => self.title,
221 TextStyle::Headline => self.headline,
222 TextStyle::Body => self.body,
223 TextStyle::Callout => self.callout,
224 TextStyle::Caption => self.caption,
225 }
226 }
227}
228
229const fn ts(size: f32, line_height: f32, weight: Weight, letter_spacing: f32) -> TypeStyle {
230 TypeStyle { size, line_height, weight, letter_spacing }
231}
232
233pub const TYPOGRAPHY: Typography = Typography {
235 metric: ts(42.0, 48.0, Weight::SEMIBOLD, -0.5),
236 display: ts(32.0, 38.0, Weight::SEMIBOLD, -0.3),
237 title: ts(22.0, 28.0, Weight::SEMIBOLD, 0.0),
238 headline: ts(17.0, 24.0, Weight::SEMIBOLD, 0.0),
239 body: ts(15.0, 22.0, Weight::REGULAR, 0.0),
240 callout: ts(13.0, 18.0, Weight::REGULAR, 0.0),
241 caption: ts(12.0, 16.0, Weight::SEMIBOLD, 0.4),
242};
243
244#[derive(Clone, Copy, Debug, PartialEq)]
246#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
247pub struct Spacing {
248 pub s1: f32,
250 pub s2: f32,
252 pub s3: f32,
254 pub s4: f32,
256 pub s5: f32,
258 pub s6: f32,
260 pub s8: f32,
262 pub s12: f32,
264}
265
266pub const SPACING: Spacing =
268 Spacing { s1: 4.0, s2: 8.0, s3: 12.0, s4: 16.0, s5: 20.0, s6: 24.0, s8: 32.0, s12: 48.0 };
269
270#[derive(Clone, Copy, Debug, PartialEq)]
272#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
273pub struct Radii {
274 pub sm: f32,
276 pub md: f32,
278 pub lg: f32,
280 pub xl: f32,
282 pub full: f32,
284}
285
286pub const RADII: Radii = Radii { sm: 6.0, md: 10.0, lg: 16.0, xl: 24.0, full: 9999.0 };
288
289#[derive(Clone, Copy, Debug, PartialEq)]
291#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
292pub struct Elevation {
293 pub soft: Shadow,
295 pub raised: Shadow,
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
302#[non_exhaustive]
303#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
304pub enum Material {
305 Solid,
307 Glass,
309}
310
311#[derive(Clone, Copy, Debug, PartialEq)]
313#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
314pub struct Interaction {
315 pub min_target: f32,
317 pub focus_ring_width: f32,
319 pub focus_ring_offset: f32,
321 pub reduced_motion: bool,
323}
324
325#[derive(Clone, Copy, Debug, PartialEq)]
327#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
328pub struct MotionTokens {
329 pub fast: Transition,
331 pub base: Transition,
333 pub slow: Transition,
335 pub snappy: Transition,
337 pub gentle: Transition,
339 pub draw_in: Transition,
341}
342
343#[derive(Clone, Copy, Debug, PartialEq)]
345#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
346pub struct Theme {
347 pub name: &'static str,
349 pub palette: Palette,
351 pub typography: Typography,
353 pub spacing: Spacing,
355 pub radii: Radii,
357 pub elevation: Elevation,
359 pub material: Material,
361 pub interaction: Interaction,
363 pub motion: MotionTokens,
365}
366
367const INTERACTION: Interaction =
368 Interaction { min_target: 40.0, focus_ring_width: 2.0, focus_ring_offset: 3.0, reduced_motion: false };
369
370const MOTION: MotionTokens = MotionTokens {
371 fast: Transition::fast(),
372 base: Transition::base(),
373 slow: Transition::slow(),
374 snappy: Transition::snappy(),
375 gentle: Transition::gentle(),
376 draw_in: Transition::draw_in(),
377};
378
379const fn shadow(y: f32, blur: f32, color: Color) -> Shadow {
380 Shadow { offset: Point::new(0.0, y), blur, color }
381}
382
383#[cfg_attr(feature = "live", ravenrs_live::expose)]
384impl Theme {
385 pub const fn raven_dark() -> Self {
387 Self {
388 name: "Raven Dark",
389 palette: Palette {
390 bg_000: Color::hex(0x0b0d12),
391 bg_100: Color::hex(0x12151c),
392 bg_200: Color::hex(0x181c25),
393 bg_300: Color::hex(0x222734),
394 border: Color::hex(0x2a3040),
395 ink: Color::hex(0xeef1f7),
396 ink_muted: Color::hex(0x9aa3b5),
397 accent: Color::hex(0x4c66ee),
398 accent_hover: Color::hex(0x5f78f5),
399 accent_pressed: Color::hex(0x3e55d6),
400 on_accent: Color::hex(0xffffff),
401 focus_ring: Color::hex(0x9db0ff),
402 success: Color::hex(0x3fb984),
403 warning: Color::hex(0xe8a23a),
404 danger: Color::hex(0xef5b5b),
405 },
406 typography: TYPOGRAPHY,
407 spacing: SPACING,
408 radii: RADII,
409 elevation: Elevation {
410 soft: shadow(8.0, 24.0, Color::rgba(0.0, 0.0, 0.0, 0.35)),
411 raised: shadow(16.0, 40.0, Color::rgba(0.0, 0.0, 0.0, 0.45)),
412 },
413 material: Material::Solid,
414 interaction: INTERACTION,
415 motion: MOTION,
416 }
417 }
418
419 pub const fn raven_light() -> Self {
421 Self {
422 name: "Raven Light",
423 palette: Palette {
424 bg_000: Color::hex(0xf4f5f8),
425 bg_100: Color::hex(0xffffff),
426 bg_200: Color::hex(0xffffff),
427 bg_300: Color::hex(0xebedf2),
428 border: Color::hex(0xdadde5),
429 ink: Color::hex(0x12151c),
430 ink_muted: Color::hex(0x5a6375),
431 accent: Color::hex(0x3f57db),
432 accent_hover: Color::hex(0x4a62e3),
433 accent_pressed: Color::hex(0x3348bf),
434 on_accent: Color::hex(0xffffff),
435 focus_ring: Color::hex(0x3f57db),
436 success: Color::hex(0x1f8a5b),
437 warning: Color::hex(0xb26a00),
438 danger: Color::hex(0xc93434),
439 },
440 typography: TYPOGRAPHY,
441 spacing: SPACING,
442 radii: RADII,
443 elevation: Elevation {
444 soft: shadow(6.0, 20.0, Color::rgba(17.0 / 255.0, 24.0 / 255.0, 39.0 / 255.0, 0.08)),
445 raised: shadow(12.0, 32.0, Color::rgba(17.0 / 255.0, 24.0 / 255.0, 39.0 / 255.0, 0.14)),
446 },
447 material: Material::Solid,
448 interaction: INTERACTION,
449 motion: MOTION,
450 }
451 }
452
453 pub const fn raven_glass() -> Self {
456 Self {
457 name: "Raven Glass",
458 palette: Palette {
459 bg_000: Color::hex(0x0a0e1a),
460 bg_100: Color::hex_alpha(0xffffff0f),
461 bg_200: Color::hex_alpha(0xffffff1a),
462 bg_300: Color::hex_alpha(0xffffff24),
463 border: Color::hex_alpha(0xffffff2e),
464 ink: Color::hex(0xf5f7ff),
465 ink_muted: Color::hex(0xb4bdd3),
466 accent: Color::hex(0x4c66ee),
467 accent_hover: Color::hex(0x5f78f5),
468 accent_pressed: Color::hex(0x3e55d6),
469 on_accent: Color::hex(0xffffff),
470 focus_ring: Color::hex(0x9db0ff),
471 success: Color::hex(0x3fb984),
472 warning: Color::hex(0xe8a23a),
473 danger: Color::hex(0xef5b5b),
474 },
475 typography: TYPOGRAPHY,
476 spacing: SPACING,
477 radii: RADII,
478 elevation: Elevation {
479 soft: shadow(8.0, 32.0, Color::rgba(0.0, 0.0, 0.0, 0.30)),
480 raised: shadow(16.0, 48.0, Color::rgba(0.0, 0.0, 0.0, 0.40)),
481 },
482 material: Material::Glass,
483 interaction: INTERACTION,
484 motion: MOTION,
485 }
486 }
487
488 pub fn color(&self, role: Semantic) -> Color {
497 self.palette.get(role)
498 }
499
500 pub fn text(&self, style: TextStyle) -> TypeStyle {
502 self.typography.get(style)
503 }
504
505 pub fn card_padding(&self) -> Insets {
507 Insets::uniform(self.spacing.s6)
508 }
509}
510
511impl Default for Theme {
512 fn default() -> Self {
513 Self::raven_dark()
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn opaque_over(fg: Color, bg: Color) -> Color {
522 bg.lerp(fg.with_alpha(1.0), fg.a)
523 }
524
525 #[test]
527 fn text_contrast_meets_wcag_aa() {
528 for theme in [Theme::raven_dark(), Theme::raven_light(), Theme::raven_glass()] {
529 let p = theme.palette;
530 let card = opaque_over(p.bg_200, p.bg_000);
531 for (name, fg, bg) in [
532 ("ink/canvas", p.ink, p.bg_000),
533 ("ink/card", p.ink, card),
534 ("ink-muted/canvas", p.ink_muted, p.bg_000),
535 ("ink-muted/card", p.ink_muted, card),
536 ("on-accent/accent", p.on_accent, p.accent),
537 ] {
538 let ratio = fg.contrast_ratio(bg);
539 assert!(ratio >= 4.5, "{}: {name} is {ratio:.2}:1", theme.name);
540 }
541 assert!(p.focus_ring.contrast_ratio(p.bg_000) >= 3.0, "{}: focus ring", theme.name);
542 }
543 }
544
545 #[test]
546 fn semantic_lookup_matches_palette() {
547 let t = Theme::raven_dark();
548 assert_eq!(t.color(Semantic::Accent), Color::hex(0x4c66ee));
549 assert_eq!(t.text(TextStyle::Metric).size, 42.0);
550 }
551}