Skip to main content

oxframe_2d/
theme.rs

1//! Design tokens. A [`Theme`] is a palette, a type scale, spacing, radii,
2//! shadows and motion. Nodes read it while a scene runs.
3//!
4//! Built-in themes: [`Theme::dark`] (the default), [`Theme::light`],
5//! [`Theme::midnight`], [`Theme::paper`] and [`Theme::vivid`].
6//! [`Theme::accent`] retints a theme from one brand color and keeps text
7//! at WCAG 4.5:1 against the background.
8
9use std::cell::RefCell;
10
11use oxframe_core::{Color, CubicBezier, Seconds, Vec2, presets};
12
13/// A color role. Use it wherever a [`Color`] is accepted: `.fill(Accent)`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Ink {
16    /// Page background.
17    Background,
18    /// Cards and panels, a step off the background.
19    Surface,
20    /// Primary text.
21    Text,
22    /// Secondary text. Still readable on the background and on [`Surface`](Ink::Surface).
23    Muted,
24    /// Brand color.
25    Accent,
26    /// Text drawn on top of [`Accent`](Ink::Accent).
27    OnAccent,
28    /// Positive state.
29    Success,
30    /// Caution.
31    Warning,
32    /// Failure.
33    Danger,
34}
35
36/// A step on the space, radius and shadow scales: `.padding(Md)`, `.radius(Lg)`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Step {
39    /// Smallest step.
40    Xs,
41    /// Small.
42    Sm,
43    /// Medium, the ordinary gap.
44    Md,
45    /// Large.
46    Lg,
47    /// Largest.
48    Xl,
49}
50
51/// How long a motion token lasts: `.to(1.0, Normal)`.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Pace {
54    /// A small change.
55    Fast,
56    /// The ordinary duration. `fade_in` uses this.
57    Normal,
58    /// A large change.
59    Slow,
60}
61
62impl Seconds for Pace {
63    fn seconds(self) -> f64 {
64        let m = current().motion;
65        match self {
66            Pace::Fast => m.fast,
67            Pace::Normal => m.normal,
68            Pace::Slow => m.slow,
69        }
70    }
71}
72
73/// One text role: size in pixels, weight 100..=900, line height as a multiple.
74#[derive(Debug, Clone, Copy)]
75pub struct TypeStyle {
76    /// Font size in pixels.
77    pub size: f64,
78    /// 100..=900.
79    pub weight: f64,
80    /// Multiple of the font size.
81    pub line_height: f64,
82}
83
84/// Display, headings, body, caption and code.
85#[derive(Debug, Clone, Copy)]
86pub struct TypeScale {
87    /// The largest title.
88    pub display: TypeStyle,
89    /// Page title.
90    pub h1: TypeStyle,
91    /// Section title.
92    pub h2: TypeStyle,
93    /// Running text.
94    pub body: TypeStyle,
95    /// Secondary text.
96    pub caption: TypeStyle,
97    /// Monospace.
98    pub code: TypeStyle,
99}
100
101/// Five pixel sizes, smallest to largest.
102#[derive(Debug, Clone, Copy)]
103pub struct Scale {
104    /// Extra small.
105    pub xs: f64,
106    /// Small.
107    pub sm: f64,
108    /// Medium.
109    pub md: f64,
110    /// Large.
111    pub lg: f64,
112    /// Extra large.
113    pub xl: f64,
114}
115
116impl Scale {
117    fn get(self, step: Step) -> f64 {
118        match step {
119            Step::Xs => self.xs,
120            Step::Sm => self.sm,
121            Step::Md => self.md,
122            Step::Lg => self.lg,
123            Step::Xl => self.xl,
124        }
125    }
126}
127
128/// A drop shadow.
129#[derive(Debug, Clone, Copy)]
130pub struct ShadowSpec {
131    /// Shadow color.
132    pub color: Color,
133    /// Blur radius in pixels.
134    pub blur: f64,
135    /// Offset in pixels; y is down.
136    pub offset: Vec2,
137}
138
139/// Durations in seconds and the eases for entering, leaving and emphasis.
140#[derive(Debug, Clone, Copy)]
141pub struct Motion {
142    /// Short.
143    pub fast: f64,
144    /// Ordinary. `fade_in` uses this.
145    pub normal: f64,
146    /// Long.
147    pub slow: f64,
148    /// Ease for something appearing.
149    pub enter: CubicBezier,
150    /// Ease for something leaving.
151    pub exit: CubicBezier,
152    /// Ease for a bump or a pop.
153    pub emphasis: CubicBezier,
154}
155
156struct Raw {
157    background: Color,
158    surface: Color,
159    text: Color,
160    muted: Color,
161    accent: Color,
162    success: Color,
163    warning: Color,
164    danger: Color,
165    type_scale: TypeScale,
166    motion: Motion,
167}
168
169/// Colors, type, space, radii, shadows and motion for one look.
170#[derive(Debug, Clone)]
171pub struct Theme {
172    /// Page background. `Project::theme` copies this onto the project.
173    pub background: Color,
174    /// Cards and panels.
175    pub surface: Color,
176    /// Primary text.
177    pub text: Color,
178    /// Secondary text.
179    pub muted: Color,
180    /// Brand color, readable on the background.
181    pub accent: Color,
182    /// Text on top of the accent.
183    pub on_accent: Color,
184    /// Positive.
185    pub success: Color,
186    /// Caution.
187    pub warning: Color,
188    /// Failure.
189    pub danger: Color,
190    /// Type roles.
191    pub type_scale: TypeScale,
192    /// Padding, gap and margin, in pixels.
193    pub space: Scale,
194    /// Corner radii, in pixels.
195    pub radius: Scale,
196    /// Shadows for [`Step::Sm`], [`Step::Md`] and [`Step::Lg`].
197    pub shadow_sm: ShadowSpec,
198    /// Medium shadow.
199    pub shadow_md: ShadowSpec,
200    /// Large shadow.
201    pub shadow_lg: ShadowSpec,
202    /// Durations and eases.
203    pub motion: Motion,
204}
205
206impl Theme {
207    /// Near-black background, white text, blue accent. The project default.
208    pub fn dark() -> Self {
209        Self::palette(Raw {
210            background: hex("#141414"),
211            surface: hex("#1c1e24"),
212            text: Color::WHITE,
213            muted: hex("#b7bcc6"),
214            accent: hex("#68abdf"),
215            success: hex("#3dcc7a"),
216            warning: hex("#f5c242"),
217            danger: hex("#e13238"),
218            type_scale: type_scale(1.0),
219            motion: motion(1.0),
220        })
221    }
222
223    /// Off-white background, ink text.
224    pub fn light() -> Self {
225        Self::palette(Raw {
226            background: hex("#f6f4f1"),
227            surface: Color::WHITE,
228            text: hex("#1a1c1f"),
229            muted: hex("#555b66"),
230            accent: hex("#1d6aab"),
231            success: hex("#0e8a45"),
232            warning: hex("#8a5a00"),
233            danger: hex("#c4232c"),
234            type_scale: type_scale(1.0),
235            motion: motion(1.0),
236        })
237    }
238
239    /// Blue-black, cool accent.
240    pub fn midnight() -> Self {
241        Self::palette(Raw {
242            background: hex("#0b1020"),
243            surface: hex("#151b2e"),
244            text: hex("#e8eefc"),
245            muted: hex("#9aa6c2"),
246            accent: hex("#7ee0ff"),
247            success: hex("#3dcc7a"),
248            warning: hex("#f5c242"),
249            danger: hex("#ff6b6b"),
250            type_scale: type_scale(1.0),
251            motion: motion(1.05),
252        })
253    }
254
255    /// Warm paper and ink.
256    pub fn paper() -> Self {
257        Self::palette(Raw {
258            background: hex("#f3ead7"),
259            surface: hex("#fffaf1"),
260            text: hex("#2a241b"),
261            muted: hex("#6b5e4e"),
262            accent: hex("#b4532a"),
263            success: hex("#2f7d4a"),
264            warning: hex("#8a5a00"),
265            danger: hex("#a32020"),
266            type_scale: type_scale(0.96),
267            motion: motion(1.15),
268        })
269    }
270
271    /// Dark, with a strong accent and snappier motion.
272    pub fn vivid() -> Self {
273        Self::palette(Raw {
274            background: hex("#0e0e10"),
275            surface: hex("#18181c"),
276            text: Color::WHITE,
277            muted: hex("#c4c4cc"),
278            accent: hex("#ff4d6d"),
279            success: hex("#3dcc7a"),
280            warning: hex("#ffd000"),
281            danger: hex("#ff4d6d"),
282            type_scale: type_scale(1.08),
283            motion: Motion {
284                fast: 0.15,
285                normal: 0.35,
286                slow: 0.6,
287                ..motion(1.0)
288            },
289        })
290    }
291
292    /// `dark`, `light`, `midnight`, `paper` or `vivid`.
293    pub fn preset(name: &str) -> Option<Self> {
294        Some(match name {
295            "dark" => Self::dark(),
296            "light" => Self::light(),
297            "midnight" => Self::midnight(),
298            "paper" => Self::paper(),
299            "vivid" => Self::vivid(),
300            _ => return None,
301        })
302    }
303
304    /// Rebuilds the accent (and the surface tint) from one brand color.
305    /// The result stays at least 4.5:1 against the background.
306    pub fn accent(mut self, color: Color) -> Self {
307        self.surface = self.background.mix(color, 0.16);
308        if self.is_light() {
309            self.surface = self.surface.mix(Color::WHITE, 0.35);
310        }
311        let fit = |c: Color| {
312            c.readable_on(self.background, 4.5)
313                .readable_on(self.surface, 4.5)
314        };
315        self.accent = fit(color);
316        self.on_accent = on_color(self.accent);
317        self.text = fit(self.text);
318        self.muted = fit(self.muted);
319        self.success = fit(self.success);
320        self.warning = fit(self.warning);
321        self.danger = fit(self.danger);
322        self
323    }
324
325    /// Whether the background is light (paper, light).
326    pub fn is_light(&self) -> bool {
327        self.background.luminance() > 0.4
328    }
329
330    /// The color of a role.
331    pub fn ink(&self, ink: Ink) -> Color {
332        match ink {
333            Ink::Background => self.background,
334            Ink::Surface => self.surface,
335            Ink::Text => self.text,
336            Ink::Muted => self.muted,
337            Ink::Accent => self.accent,
338            Ink::OnAccent => self.on_accent,
339            Ink::Success => self.success,
340            Ink::Warning => self.warning,
341            Ink::Danger => self.danger,
342        }
343    }
344
345    /// Padding, gap or margin for a step.
346    pub fn space(&self, step: Step) -> f64 {
347        self.space.get(step)
348    }
349
350    /// Corner radius for a step.
351    pub fn radius(&self, step: Step) -> f64 {
352        self.radius.get(step)
353    }
354
355    /// Shadow for a step. `Xs` uses the small shadow, `Xl` the large one.
356    pub fn shadow(&self, step: Step) -> ShadowSpec {
357        match step {
358            Step::Xs | Step::Sm => self.shadow_sm,
359            Step::Md => self.shadow_md,
360            Step::Lg | Step::Xl => self.shadow_lg,
361        }
362    }
363
364    /// A type role. `code` is the monospace size; the family is set by `.code()`.
365    pub fn role(&self, role: TypeRole) -> TypeStyle {
366        match role {
367            TypeRole::Display => self.type_scale.display,
368            TypeRole::H1 => self.type_scale.h1,
369            TypeRole::H2 => self.type_scale.h2,
370            TypeRole::Body => self.type_scale.body,
371            TypeRole::Caption => self.type_scale.caption,
372            TypeRole::Code => self.type_scale.code,
373        }
374    }
375
376    fn palette(raw: Raw) -> Self {
377        let Raw {
378            background,
379            surface,
380            text,
381            muted,
382            accent,
383            success,
384            warning,
385            danger,
386            type_scale,
387            motion,
388        } = raw;
389        let fit = |c: Color| c.readable_on(background, 4.5).readable_on(surface, 4.5);
390        let accent = fit(accent);
391        Self {
392            background,
393            surface,
394            text: fit(text),
395            muted: fit(muted),
396            accent,
397            on_accent: on_color(accent),
398            success: fit(success),
399            warning: fit(warning),
400            danger: fit(danger),
401            type_scale,
402            space: Scale {
403                xs: 8.0,
404                sm: 16.0,
405                md: 24.0,
406                lg: 40.0,
407                xl: 64.0,
408            },
409            radius: Scale {
410                xs: 6.0,
411                sm: 12.0,
412                md: 20.0,
413                lg: 32.0,
414                xl: 48.0,
415            },
416            shadow_sm: shadow(0.22, 12.0, 4.0),
417            shadow_md: shadow(0.32, 28.0, 12.0),
418            shadow_lg: shadow(0.42, 48.0, 24.0),
419            motion,
420        }
421    }
422}
423
424/// Which type role `.display()`, `.h1()` and the others select.
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum TypeRole {
427    /// Largest title.
428    Display,
429    /// Page title.
430    H1,
431    /// Section title.
432    H2,
433    /// Running text.
434    Body,
435    /// Secondary text.
436    Caption,
437    /// Monospace.
438    Code,
439}
440
441fn on_color(bg: Color) -> Color {
442    if Color::WHITE.contrast_ratio(bg) >= 4.5 {
443        Color::WHITE
444    } else {
445        Color::BLACK
446    }
447}
448
449fn hex(s: &str) -> Color {
450    Color::from_hex(s).expect("theme color")
451}
452
453fn shadow(alpha: f32, blur: f64, y: f64) -> ShadowSpec {
454    ShadowSpec {
455        color: Color::BLACK.with_alpha(alpha),
456        blur,
457        offset: Vec2::new(0.0, y),
458    }
459}
460
461fn style(size: f64, weight: f64, line_height: f64) -> TypeStyle {
462    TypeStyle {
463        size,
464        weight,
465        line_height,
466    }
467}
468
469fn type_scale(k: f64) -> TypeScale {
470    let s = |n: f64| (n * k).round();
471    TypeScale {
472        display: style(s(96.0), 700.0, 1.05),
473        h1: style(s(64.0), 700.0, 1.1),
474        h2: style(s(44.0), 600.0, 1.15),
475        body: style(s(32.0), 400.0, 1.35),
476        caption: style(s(22.0), 400.0, 1.3),
477        code: style(s(28.0), 450.0, 1.45),
478    }
479}
480
481fn motion(k: f64) -> Motion {
482    Motion {
483        fast: 0.2 * k,
484        normal: 0.45 * k,
485        slow: 0.8 * k,
486        enter: presets::OUT_CUBIC,
487        exit: presets::IN_CUBIC,
488        emphasis: presets::OUT_BACK,
489    }
490}
491
492thread_local! {
493    static CURRENT: RefCell<Theme> = RefCell::new(Theme::dark());
494}
495
496/// The theme of the scene that is running, or [`Theme::dark`] outside one.
497pub fn current() -> Theme {
498    CURRENT.with(|t| t.borrow().clone())
499}
500
501/// Installs `theme` for the duration of `f`.
502pub fn enter<R>(theme: &Theme, f: impl FnOnce() -> R) -> R {
503    let prev = CURRENT.with(|t| t.replace(theme.clone()));
504    struct Restore(Option<Theme>);
505    impl Drop for Restore {
506        fn drop(&mut self) {
507            if let Some(prev) = self.0.take() {
508                CURRENT.with(|t| *t.borrow_mut() = prev);
509            }
510        }
511    }
512    let _restore = Restore(Some(prev));
513    f()
514}
515
516/// Something that can be used as a color: a [`Color`] or an [`Ink`].
517pub trait IntoPaint {
518    /// Resolves to a color. An [`Ink`] uses the current theme.
519    fn into_paint(self) -> Color;
520}
521
522impl IntoPaint for Color {
523    fn into_paint(self) -> Color {
524        self
525    }
526}
527
528impl IntoPaint for Ink {
529    fn into_paint(self) -> Color {
530        current().ink(self)
531    }
532}
533
534impl From<Ink> for Color {
535    fn from(ink: Ink) -> Color {
536        ink.into_paint()
537    }
538}
539
540/// A padding, gap or margin: pixels, or a [`Step`] on the space scale.
541pub trait Len {
542    /// Pixels.
543    fn px(self) -> f64;
544}
545
546impl Len for f64 {
547    fn px(self) -> f64 {
548        self
549    }
550}
551
552impl Len for Step {
553    fn px(self) -> f64 {
554        current().space(self)
555    }
556}
557
558/// A corner radius: pixels, or a [`Step`] on the radius scale.
559pub trait Corner {
560    /// Pixels.
561    fn radius(self) -> f64;
562}
563
564impl Corner for f64 {
565    fn radius(self) -> f64 {
566        self
567    }
568}
569
570impl Corner for Step {
571    fn radius(self) -> f64 {
572        current().radius(self)
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    fn inks(theme: &Theme) -> [Color; 6] {
581        [
582            theme.text,
583            theme.muted,
584            theme.accent,
585            theme.success,
586            theme.warning,
587            theme.danger,
588        ]
589    }
590
591    #[test]
592    fn every_preset_is_readable() {
593        for theme in [
594            Theme::dark(),
595            Theme::light(),
596            Theme::midnight(),
597            Theme::paper(),
598            Theme::vivid(),
599            Theme::dark().accent(hex("#224466")),
600            Theme::light().accent(hex("#ff88aa")),
601        ] {
602            for c in inks(&theme) {
603                assert!(
604                    c.contrast_ratio(theme.background) >= 4.5,
605                    "bg {:?} {:?}",
606                    c,
607                    theme.background
608                );
609                assert!(
610                    c.contrast_ratio(theme.surface) >= 4.5,
611                    "surface {:?} {:?}",
612                    c,
613                    theme.surface
614                );
615            }
616            assert!(theme.on_accent.contrast_ratio(theme.accent) >= 4.5);
617        }
618    }
619
620    #[test]
621    fn accent_keeps_contrast_and_tints_the_surface() {
622        let dark = Theme::dark();
623        let branded = Theme::dark().accent(hex("#224466"));
624        assert!(branded.accent.contrast_ratio(branded.background) >= 4.5);
625        assert_ne!(branded.surface.to_rgba8(), dark.surface.to_rgba8());
626        assert_eq!(branded.background.to_rgba8(), dark.background.to_rgba8());
627    }
628
629    #[test]
630    fn steps_resolve_on_different_scales() {
631        let theme = Theme::dark();
632        enter(&theme, || {
633            assert_eq!(Step::Md.px(), theme.space.md);
634            assert_eq!(Step::Md.radius(), theme.radius.md);
635            assert_ne!(Step::Md.px(), Step::Md.radius());
636            assert!((Pace::Normal.seconds() - theme.motion.normal).abs() < 1e-9);
637        });
638    }
639}