Skip to main content

webserver_base/templates/
theme.rs

1//! Light and dark, declared once and used consistently.
2
3use serde::{Deserialize, Serialize};
4
5/// The colour a browser tints its chrome with, and the schemes the site
6/// supports.
7///
8/// The two are one type because they must agree: declaring `color-scheme: light
9/// dark` while offering a single theme colour gives a dark page a light address
10/// bar, and declaring `color-scheme: light` on a site with dark styles renders
11/// its form controls and scrollbars wrong. Naming the scheme in the constructor
12/// makes the contradiction unrepresentable.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub enum ThemeColor {
15    /// A light-only site.
16    Light(String),
17    /// A dark-only site.
18    Dark(String),
19    /// A site that follows the reader's preference.
20    LightDark { light: String, dark: String },
21}
22
23/// One `<meta name="theme-color">`, with the media query it applies under.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct ThemeColorTag {
26    pub content: String,
27    /// `None` on a single-scheme site, where an unconditional tag is correct.
28    pub media: Option<String>,
29}
30
31impl ThemeColor {
32    /// A light-only site.
33    #[must_use]
34    pub fn light(color: impl Into<String>) -> Self {
35        Self::Light(color.into())
36    }
37
38    /// A dark-only site.
39    #[must_use]
40    pub fn dark(color: impl Into<String>) -> Self {
41        Self::Dark(color.into())
42    }
43
44    /// A site that follows `prefers-color-scheme`.
45    ///
46    /// Both colours should be the page's *background* per scheme, so the
47    /// browser chrome reads as continuous with the page rather than as an
48    /// accent stripe above it.
49    #[must_use]
50    pub fn light_dark(light: impl Into<String>, dark: impl Into<String>) -> Self {
51        Self::LightDark {
52            light: light.into(),
53            dark: dark.into(),
54        }
55    }
56
57    /// The `<meta name="color-scheme">` value.
58    #[must_use]
59    pub const fn color_scheme(&self) -> &'static str {
60        match self {
61            Self::Light(_) => "light",
62            Self::Dark(_) => "dark",
63            Self::LightDark { .. } => "light dark",
64        }
65    }
66
67    /// One representative colour, for contexts that accept only a single value
68    /// — the web app manifest, which has no media-query equivalent. The light
69    /// value wins, matching how a browser renders a splash screen by default.
70    #[must_use]
71    pub fn primary(&self) -> &str {
72        match self {
73            Self::Light(color) | Self::Dark(color) => color,
74            Self::LightDark { light, .. } => light,
75        }
76    }
77
78    /// The `<meta name="theme-color">` tags to emit, in order.
79    #[must_use]
80    pub fn tags(&self) -> Vec<ThemeColorTag> {
81        match self {
82            Self::Light(color) | Self::Dark(color) => vec![ThemeColorTag {
83                content: color.clone(),
84                media: None,
85            }],
86            Self::LightDark { light, dark } => vec![
87                ThemeColorTag {
88                    content: light.clone(),
89                    media: Some(String::from("(prefers-color-scheme: light)")),
90                },
91                ThemeColorTag {
92                    content: dark.clone(),
93                    media: Some(String::from("(prefers-color-scheme: dark)")),
94                },
95            ],
96        }
97    }
98}
99
100/// Which theme to stamp when the reader has expressed no preference and the
101/// operating system reports none either.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum Fallback {
104    Light,
105    Dark,
106}
107
108impl Fallback {
109    const fn as_str(self) -> &'static str {
110        match self {
111            Self::Light => "light",
112            Self::Dark => "dark",
113        }
114    }
115}
116
117/// The storage key a reader's explicit theme choice is saved under.
118///
119/// Fixed rather than configurable: it is a contract between this script and
120/// every project's own theme toggle, and two spellings of it is simply a bug.
121pub const THEME_STORAGE_KEY: &str = "theme";
122
123/// The pre-paint theme script.
124///
125/// Runs before the first paint so the page never flashes the wrong theme, which
126/// is why it is inline and blocking rather than a module at the end of `body`.
127///
128/// It stamps `data-theme` on `<html>` in exactly two of three cases:
129///
130/// 1. the reader chose a theme — stamp it;
131/// 2. the reader chose nothing but the OS states a preference — stamp
132///    **nothing**, so the stylesheet's media queries stay live and follow the
133///    OS if it changes mid-session;
134/// 3. neither states anything — stamp [`Fallback`].
135///
136/// What `[data-theme="dark"]` *means* is entirely the project's CSS. This type
137/// owns only the handshake.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct ThemeScript {
140    fallback: Fallback,
141}
142
143impl ThemeScript {
144    /// A script that falls back to `fallback` when nothing states a preference.
145    #[must_use]
146    pub const fn new(fallback: Fallback) -> Self {
147        Self { fallback }
148    }
149
150    /// The JavaScript to inline.
151    ///
152    /// The `try` wraps only the storage read: browsers with site data blocked
153    /// throw there, which is an environment, not a bug. Everything after it is
154    /// left unguarded so a genuine mistake in this crate fails loudly.
155    #[must_use]
156    pub fn source(&self) -> String {
157        format!(
158            "(function(){{var t;try{{t=localStorage.getItem(\"{key}\")}}catch(e){{}}\
159             if(t===\"light\"||t===\"dark\"){{document.documentElement.dataset.theme=t;return}}\
160             if(!matchMedia(\"(prefers-color-scheme: light)\").matches&&\
161             !matchMedia(\"(prefers-color-scheme: dark)\").matches)\
162             {{document.documentElement.dataset.theme=\"{fallback}\"}}}})()",
163            key = THEME_STORAGE_KEY,
164            fallback = self.fallback.as_str(),
165        )
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::{Fallback, ThemeColor, ThemeColorTag, ThemeScript};
172
173    #[test]
174    fn a_single_scheme_site_emits_one_unconditional_tag() {
175        let expected: Vec<ThemeColorTag> = vec![ThemeColorTag {
176            content: String::from("#fafafa"),
177            media: None,
178        }];
179        let actual: Vec<ThemeColorTag> = ThemeColor::light("#fafafa").tags();
180        assert_eq!(expected, actual);
181
182        let expected_scheme: &str = "light";
183        let actual_scheme: &str = ThemeColor::light("#fafafa").color_scheme();
184        assert_eq!(expected_scheme, actual_scheme);
185    }
186
187    #[test]
188    fn a_dual_scheme_site_emits_one_media_scoped_tag_per_scheme() {
189        let theme: ThemeColor = ThemeColor::light_dark("#fafafa", "#121212");
190
191        let expected_scheme: &str = "light dark";
192        let actual_scheme: &str = theme.color_scheme();
193        assert_eq!(expected_scheme, actual_scheme);
194
195        let tags: Vec<ThemeColorTag> = theme.tags();
196
197        let expected_len: usize = 2;
198        let actual_len: usize = tags.len();
199        assert_eq!(expected_len, actual_len);
200
201        assert_eq!(
202            Some(String::from("(prefers-color-scheme: light)")),
203            tags[0].media
204        );
205        assert_eq!(
206            Some(String::from("(prefers-color-scheme: dark)")),
207            tags[1].media
208        );
209    }
210
211    #[test]
212    fn the_theme_script_stamps_nothing_when_the_os_states_a_preference() {
213        let source: String = ThemeScript::new(Fallback::Dark).source();
214
215        // The OS-following branch is the one that must NOT assign, or a theme
216        // change mid-session would be ignored until reload.
217        assert!(source.contains("prefers-color-scheme: light"));
218        assert!(source.contains("prefers-color-scheme: dark"));
219        assert!(source.contains("!matchMedia"));
220    }
221
222    #[test]
223    fn the_theme_script_guards_only_the_storage_read() {
224        let source: String = ThemeScript::new(Fallback::Light).source();
225
226        let expected: usize = 1;
227        let actual: usize = source.matches("try{").count();
228        assert_eq!(expected, actual);
229        assert!(source.contains("try{t=localStorage.getItem(\"theme\")}catch(e){}"));
230    }
231
232    #[test]
233    fn the_fallback_is_what_gets_stamped_when_nothing_states_a_preference() {
234        assert!(
235            ThemeScript::new(Fallback::Dark)
236                .source()
237                .contains(r#"theme="dark""#)
238        );
239        assert!(
240            ThemeScript::new(Fallback::Light)
241                .source()
242                .contains(r#"theme="light""#)
243        );
244    }
245}