Skip to main content

hypersig_style/
token.rs

1//! Design tokens: CSS custom properties with a typed value, and the theme
2//! that sets them for the page.
3
4use std::fmt::{self, Write as _};
5
6use crate::condition::Media;
7use crate::sheet::{ORDER, TOKEN_LAYER};
8use crate::value::{Angle, Color, Length, Percentage, Time};
9
10/// A value type a [`Token`] can hold.
11///
12/// It is implemented for the types whose value can be a `var()`: [`Color`],
13/// [`Length`], [`Percentage`], [`Time`], and [`Angle`].
14pub trait TokenValue: fmt::Display + sealed::Sealed {
15    #[doc(hidden)]
16    fn from_var(css: String) -> Self;
17}
18
19mod sealed {
20    pub trait Sealed {}
21}
22
23macro_rules! token_values {
24    ($($ty:ty),*) => {$(
25        impl sealed::Sealed for $ty {}
26
27        impl TokenValue for $ty {
28            fn from_var(css: String) -> Self {
29                <$ty>::var(css)
30            }
31        }
32    )*};
33}
34
35token_values!(Color, Length, Percentage, Time, Angle);
36
37/// A named value of type `T`, written as the custom property `--<name>`.
38///
39/// [`Token::var`] reads it as `var(--<name>, <default>)`, so a page without a
40/// [`Theme`] shows the default. A [`Theme`] sets it for the whole page, and
41/// [`Style::token`](crate::Style::token) for one element and those inside it.
42///
43/// Two tokens with the same name are one custom property to the browser, and
44/// nothing checks that each name is used once.
45///
46/// ```
47/// use hypersig_style::value::{Color, px};
48/// use hypersig_style::{Media, Style, Theme, Token};
49///
50/// let brand = Token::new("brand", Color::hex(0xb83f45));
51/// let button = Style::new().background_color(brand.var()).padding(px(8));
52/// let theme = Theme::new().on(Media::Dark, Theme::new().set(&brand, Color::hex(0xff6b6b)));
53/// assert!(theme.to_css().contains("@media (prefers-color-scheme: dark){:root{--brand:#ff6b6b}}"));
54/// # let _ = button;
55/// ```
56#[derive(Debug, Clone, PartialEq)]
57pub struct Token<T> {
58    name: &'static str,
59    default: T,
60}
61
62impl<T: TokenValue> Token<T> {
63    /// A token named `name` whose value is `default` until something sets it.
64    ///
65    /// # Panics
66    ///
67    /// Panics if `name` is empty or holds a character other than an ASCII
68    /// letter, digit, `-`, or `_`, which a custom property name would need
69    /// escaped.
70    #[must_use]
71    pub fn new(name: &'static str, default: T) -> Self {
72        assert!(
73            !name.is_empty()
74                && name
75                    .bytes()
76                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
77            "a token name is ASCII letters, digits, `-`, and `_`"
78        );
79        Self { name, default }
80    }
81
82    /// The name, without the leading `--`.
83    #[must_use]
84    pub fn name(&self) -> &'static str {
85        self.name
86    }
87
88    /// The token's current value, as `var(--<name>, <default>)`.
89    #[must_use]
90    pub fn var(&self) -> T {
91        T::from_var(format!("var(--{}, {})", self.name, self.default))
92    }
93}
94
95/// Values for tokens across the whole page, written on `:root`.
96#[derive(Debug, Clone, Default, PartialEq)]
97pub struct Theme {
98    declarations: Vec<(Vec<Media>, &'static str, String)>,
99}
100
101impl Theme {
102    /// A theme that sets nothing.
103    #[must_use]
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    /// Sets `token` to `value`. A later value for the same token under the
109    /// same conditions replaces an earlier one.
110    #[must_use]
111    pub fn set<T: TokenValue>(mut self, token: &Token<T>, value: impl Into<T>) -> Self {
112        self.insert(Vec::new(), token.name, value.into().to_string());
113        self
114    }
115
116    /// Adds the values of `theme`, applied only under `media`.
117    #[must_use]
118    pub fn on(mut self, media: Media, theme: Self) -> Self {
119        for (mut conditions, name, value) in theme.declarations {
120            if let Err(at) = conditions.binary_search(&media) {
121                conditions.insert(at, media);
122            }
123            self.insert(conditions, name, value);
124        }
125        self
126    }
127
128    /// Adds the values of `theme`, which win over this theme's own.
129    #[must_use]
130    pub fn merge(mut self, theme: Self) -> Self {
131        for (media, name, value) in theme.declarations {
132            self.insert(media, name, value);
133        }
134        self
135    }
136
137    fn insert(&mut self, media: Vec<Media>, name: &'static str, value: String) {
138        if let Some(old) = self
139            .declarations
140            .iter_mut()
141            .find(|(m, n, _)| *m == media && *n == name)
142        {
143            old.2 = value;
144        } else {
145            self.declarations.push((media, name, value));
146        }
147    }
148
149    /// Whether the theme sets nothing.
150    #[must_use]
151    pub fn is_empty(&self) -> bool {
152        self.declarations.is_empty()
153    }
154
155    /// The theme as a stylesheet in [`TOKEN_LAYER`]: the unconditioned values
156    /// first, then one block per set of media conditions, in the order
157    /// [`Media`] lists them.
158    #[must_use]
159    pub fn to_css(&self) -> String {
160        let mut groups: Vec<&Vec<Media>> = self.declarations.iter().map(|(m, _, _)| m).collect();
161        groups.sort();
162        groups.dedup();
163        let mut out = format!("{ORDER}@layer {TOKEN_LAYER}{{");
164        for media in groups {
165            if !media.is_empty() {
166                out.push_str("@media ");
167                for (i, m) in media.iter().enumerate() {
168                    if i > 0 {
169                        out.push_str(" and ");
170                    }
171                    out.push_str(m.query());
172                }
173                out.push('{');
174            }
175            out.push_str(":root{");
176            let mut first = true;
177            for (_, name, value) in self.declarations.iter().filter(|(m, _, _)| m == media) {
178                if !first {
179                    out.push(';');
180                }
181                first = false;
182                let _ = write!(out, "--{name}:{value}");
183            }
184            out.push('}');
185            if !media.is_empty() {
186                out.push('}');
187            }
188        }
189        out.push('}');
190        out
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::{Theme, Token};
197    use crate::value::{Color, px, rem};
198    use crate::{Media, Sheet, Style};
199
200    #[test]
201    fn reads_a_token_with_its_default_as_fallback() {
202        let space = Token::new("space-2", rem(0.5));
203        let style = Style::new().padding(space.var());
204        let mut sheet = Sheet::new();
205        sheet.add(&style);
206        assert!(sheet.to_css().contains("{padding:var(--space-2, 0.5rem)}"));
207    }
208
209    #[test]
210    fn sets_a_token_on_an_element() {
211        let brand = Token::new("brand", Color::RED);
212        let style = Style::new().token(&brand, Color::BLUE);
213        assert_eq!(style.class_names().collect::<Vec<_>>(), ["--brand_blue"]);
214        let mut sheet = Sheet::new();
215        sheet.add(&style);
216        assert!(sheet.to_css().ends_with("{.--brand_blue{--brand:blue}}"));
217    }
218
219    #[test]
220    fn writes_the_theme_in_the_token_layer() {
221        let brand = Token::new("brand", Color::RED);
222        let gap = Token::new("gap", px(4));
223        let theme = Theme::new()
224            .set(&brand, Color::BLUE)
225            .on(Media::Dark, Theme::new().set(&brand, Color::WHITE))
226            .set(&gap, px(8))
227            .set(&brand, Color::NAVY);
228        assert_eq!(
229            theme.to_css(),
230            "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
231             @layer hypersig-tokens{:root{--brand:navy;--gap:8px}\
232             @media (prefers-color-scheme: dark){:root{--brand:white}}}"
233        );
234    }
235
236    #[test]
237    #[should_panic(expected = "a token name is ASCII letters")]
238    fn rejects_a_name_that_needs_escaping() {
239        let _ = Token::new("brand color", Color::RED);
240    }
241}