Skip to main content

ravenrs_style/
lib.rs

1//! Design tokens and themes.
2//!
3//! Every value here mirrors `docs/design-system/tokens.json`, the source of truth
4//! that is also published as the RavenRS design system. Components never hard-code
5//! colors or sizes: they ask the [`Theme`] for a semantic token.
6
7mod tokens;
8
9use ravenrs_animation::Transition;
10use ravenrs_core::{Color, Insets, Point, Shadow};
11
12/// Semantic color roles. Widgets refer to roles, never to raw colors, so every
13/// component follows the active theme.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
17pub enum Semantic {
18    /// `bg-000`: the canvas behind everything.
19    Canvas,
20    /// `bg-100`: panels and sidebars.
21    Surface,
22    /// `bg-200`: cards.
23    SurfaceElevated,
24    /// `bg-300`: control fills.
25    Control,
26    /// `border`: hairlines and control outlines.
27    Border,
28    /// `ink`: primary text and icons.
29    Ink,
30    /// `ink-muted`: secondary text, captions and placeholders.
31    InkMuted,
32    /// `accent`: primary actions and selection.
33    Accent,
34    /// `accent-hover`: accent fill while hovered.
35    AccentHover,
36    /// `accent-pressed`: accent fill while pressed.
37    AccentPressed,
38    /// `on-accent`: text and icons drawn on an accent fill.
39    OnAccent,
40    /// `focus-ring`: keyboard focus indicator.
41    FocusRing,
42    /// `success`: positive status.
43    Success,
44    /// `warning`: cautionary status.
45    Warning,
46    /// `danger`: errors and destructive actions.
47    Danger,
48    /// `scrim`: the dimmed backdrop behind dialogs and sheets, the same in every theme.
49    Scrim,
50    /// `chart-1`: the first series of a chart, the theme's accent.
51    Chart1,
52    /// `chart-2`: the second series of a chart.
53    Chart2,
54    /// `chart-3`: the third series of a chart.
55    Chart3,
56    /// `chart-4`: the fourth series of a chart.
57    Chart4,
58    /// `chart-5`: the fifth series of a chart.
59    Chart5,
60    /// `chart-grid`: the gridlines of a chart.
61    ChartGrid,
62}
63
64/// The color tokens of one theme.
65#[derive(Clone, Copy, Debug, PartialEq)]
66#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
67pub struct Palette {
68    /// `bg-000`: the canvas behind everything.
69    pub bg_000: Color,
70    /// `bg-100`: panels and sidebars.
71    pub bg_100: Color,
72    /// `bg-200`: cards.
73    pub bg_200: Color,
74    /// `bg-300`: control fills.
75    pub bg_300: Color,
76    /// `border`: hairlines and control outlines.
77    pub border: Color,
78    /// `ink`: primary text and icons.
79    pub ink: Color,
80    /// `ink-muted`: secondary text, captions and placeholders.
81    pub ink_muted: Color,
82    /// `accent`: primary actions and selection.
83    pub accent: Color,
84    /// `accent-hover`: accent fill while hovered.
85    pub accent_hover: Color,
86    /// `accent-pressed`: accent fill while pressed.
87    pub accent_pressed: Color,
88    /// `on-accent`: text and icons drawn on an accent fill.
89    pub on_accent: Color,
90    /// `focus-ring`: keyboard focus indicator.
91    pub focus_ring: Color,
92    /// `success`: positive status.
93    pub success: Color,
94    /// `warning`: cautionary status.
95    pub warning: Color,
96    /// `danger`: errors and destructive actions.
97    pub danger: Color,
98}
99
100#[cfg_attr(feature = "live", ravenrs_live::expose)]
101impl Palette {
102    /// Resolves a semantic role to this palette's color.
103    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    /// A series color: none of them means anything, unlike the status colors, and
131    /// each theme needs its own to read on its background. Ink tells them apart.
132    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/// Font weight on the CSS 1–1000 scale.
141#[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    /// Regular weight (400).
148    pub const REGULAR: Self = Self(400.0);
149    /// Medium weight (500).
150    pub const MEDIUM: Self = Self(500.0);
151    /// Semibold weight (600).
152    pub const SEMIBOLD: Self = Self(600.0);
153    /// Bold weight (700).
154    pub const BOLD: Self = Self(700.0);
155}
156
157/// One step of the type scale.
158#[derive(Clone, Copy, Debug, PartialEq)]
159#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
160pub struct TypeStyle {
161    /// Font size in logical pixels.
162    pub size: f32,
163    /// Line height in logical pixels.
164    pub line_height: f32,
165    /// Font weight.
166    pub weight: Weight,
167    /// Extra space between characters in logical pixels; may be negative.
168    pub letter_spacing: f32,
169}
170
171/// Named steps of the type scale.
172#[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    /// Large dashboard numbers (42px).
177    Metric,
178    /// Page and hero headings (32px).
179    Display,
180    /// Section titles (22px).
181    Title,
182    /// Emphasized headings inside content (17px).
183    Headline,
184    /// Default running text (15px).
185    #[default]
186    Body,
187    /// Secondary, denser text (13px).
188    Callout,
189    /// Small labels and metadata (12px).
190    Caption,
191}
192
193/// The type scale: one [`TypeStyle`] per [`TextStyle`].
194#[derive(Clone, Copy, Debug, PartialEq)]
195#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
196pub struct Typography {
197    /// Style for [`TextStyle::Metric`].
198    pub metric: TypeStyle,
199    /// Style for [`TextStyle::Display`].
200    pub display: TypeStyle,
201    /// Style for [`TextStyle::Title`].
202    pub title: TypeStyle,
203    /// Style for [`TextStyle::Headline`].
204    pub headline: TypeStyle,
205    /// Style for [`TextStyle::Body`].
206    pub body: TypeStyle,
207    /// Style for [`TextStyle::Callout`].
208    pub callout: TypeStyle,
209    /// Style for [`TextStyle::Caption`].
210    pub caption: TypeStyle,
211}
212
213#[cfg_attr(feature = "live", ravenrs_live::expose)]
214impl Typography {
215    /// Returns the type style for a named step.
216    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
233/// The type scale shared by all themes (Inter, 400 and 600).
234pub 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/// The 4px spacing grid (`space-*` tokens).
245#[derive(Clone, Copy, Debug, PartialEq)]
246#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
247pub struct Spacing {
248    /// `space-1`: 4 logical pixels.
249    pub s1: f32,
250    /// `space-2`: 8 logical pixels.
251    pub s2: f32,
252    /// `space-3`: 12 logical pixels.
253    pub s3: f32,
254    /// `space-4`: 16 logical pixels.
255    pub s4: f32,
256    /// `space-5`: 20 logical pixels.
257    pub s5: f32,
258    /// `space-6`: 24 logical pixels.
259    pub s6: f32,
260    /// `space-8`: 32 logical pixels.
261    pub s8: f32,
262    /// `space-12`: 48 logical pixels.
263    pub s12: f32,
264}
265
266/// The spacing scale shared by all themes.
267pub 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/// Corner radii (`radius-*` tokens).
271#[derive(Clone, Copy, Debug, PartialEq)]
272#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
273pub struct Radii {
274    /// `radius-sm`: small controls, in logical pixels.
275    pub sm: f32,
276    /// `radius-md`: buttons and inputs, in logical pixels.
277    pub md: f32,
278    /// `radius-lg`: cards, in logical pixels.
279    pub lg: f32,
280    /// `radius-xl`: sheets and large panels, in logical pixels.
281    pub xl: f32,
282    /// `radius-full`: pill and circle shapes.
283    pub full: f32,
284}
285
286/// The corner radii shared by all themes.
287pub const RADII: Radii = Radii { sm: 6.0, md: 10.0, lg: 16.0, xl: 24.0, full: 9999.0 };
288
289/// Shadow tokens (`shadow-*`).
290#[derive(Clone, Copy, Debug, PartialEq)]
291#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
292pub struct Elevation {
293    /// `shadow-soft`: cards and resting surfaces.
294    pub soft: Shadow,
295    /// `shadow-raised`: popovers, menus and dialogs.
296    pub raised: Shadow,
297}
298
299/// What surfaces are made of. Glass surfaces are translucent and ask the
300/// renderer for a backdrop blur where available.
301#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
302#[non_exhaustive]
303#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
304pub enum Material {
305    /// Opaque fills.
306    Solid,
307    /// Translucent fills with backdrop blur where supported.
308    Glass,
309}
310
311/// Interaction sizing and motion preferences that components must respect.
312#[derive(Clone, Copy, Debug, PartialEq)]
313#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
314pub struct Interaction {
315    /// Minimum control height for pointer input.
316    pub min_target: f32,
317    /// Width of the keyboard focus ring.
318    pub focus_ring_width: f32,
319    /// Gap between a control and its focus ring.
320    pub focus_ring_offset: f32,
321    /// When true, components skip non-essential motion.
322    pub reduced_motion: bool,
323}
324
325/// Motion tokens: how long things take and how they move.
326#[derive(Clone, Copy, Debug, PartialEq)]
327#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
328pub struct MotionTokens {
329    /// Hover, press and focus color (`duration-fast`).
330    pub fast: Transition,
331    /// Small layout and opacity changes (`duration-base`).
332    pub base: Transition,
333    /// Panels and sheets entering, by time (`duration-slow`).
334    pub slow: Transition,
335    /// Things changing place or size (`spring-snappy`).
336    pub snappy: Transition,
337    /// Panels and sheets, by physics (`spring-gentle`).
338    pub gentle: Transition,
339    /// A chart's data drawing in (`duration-draw`).
340    pub draw_in: Transition,
341}
342
343/// A complete theme: palette plus the shared scales.
344#[derive(Clone, Copy, Debug, PartialEq)]
345#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
346pub struct Theme {
347    /// Display name, e.g. `"Raven Dark"`.
348    pub name: &'static str,
349    /// Color tokens.
350    pub palette: Palette,
351    /// Type scale.
352    pub typography: Typography,
353    /// Spacing scale.
354    pub spacing: Spacing,
355    /// Corner radii.
356    pub radii: Radii,
357    /// Shadow tokens.
358    pub elevation: Elevation,
359    /// Surface material.
360    pub material: Material,
361    /// Interaction sizing and motion preferences.
362    pub interaction: Interaction,
363    /// Motion tokens.
364    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    /// The default theme: deep neutrals with a single blue-violet accent.
386    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    /// Light theme: near-white surfaces with the same blue-violet accent.
420    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    /// Translucent surfaces over a deep canvas. Until the compositor's backdrop
454    /// blur lands, glass surfaces render as their translucent fill.
455    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    /// Resolves a semantic color role in this theme.
489    ///
490    /// ```
491    /// use ravenrs_style::{Semantic, Theme};
492    ///
493    /// let theme = Theme::raven_dark();
494    /// assert_eq!(theme.color(Semantic::Accent), theme.palette.accent);
495    /// ```
496    pub fn color(&self, role: Semantic) -> Color {
497        self.palette.get(role)
498    }
499
500    /// Returns the type style for a named step of the scale.
501    pub fn text(&self, style: TextStyle) -> TypeStyle {
502        self.typography.get(style)
503    }
504
505    /// Padding of a card (`space-6`).
506    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    /// The design system promises 4.5:1 for text on its grounds in every theme.
526    #[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}