Skip to main content

blockworx_web/
mode.rs

1//! Light and dark, read once for both halves of the page.
2//!
3//! The chrome is Tailwind and the diagram is the app's palette, but which end
4//! of the axis each is drawn at is one reading: the class the root carries and
5//! the `Palette` the kernel is told come from the same answer, so the bar and
6//! the ground cannot disagree. The preference itself is
7//! [`blockworx_paint::Mode`], kept in [`crate::prefs`]; what is here is the
8//! browser's own answer and the class the chrome varies on.
9
10use blockworx_paint::{Luminance, Mode};
11use wasm_bindgen::{JsCast as _, prelude::Closure};
12use web_sys::{MediaQueryList, Window};
13
14/// The query `Mode::System` follows.
15const DARK: &str = "(prefers-color-scheme: dark)";
16
17/// What the browser answers for `prefers-color-scheme: dark`. `Unstated` is
18/// an engine that does not answer the query at all, which is not the same as
19/// answering "light".
20#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
21pub enum Prefers {
22    Dark,
23    Light,
24    #[default]
25    Unstated,
26}
27
28impl Prefers {
29    /// What `window` says right now.
30    #[must_use]
31    pub fn of(window: &Window) -> Self {
32        match window.match_media(DARK) {
33            Ok(Some(query)) => Self::from(&query),
34            _ => Self::Unstated,
35        }
36    }
37
38    /// The preference as [`Mode::is_dark`] takes it.
39    fn system_dark(self) -> Option<bool> {
40        match self {
41            Self::Dark => Some(true),
42            Self::Light => Some(false),
43            Self::Unstated => None,
44        }
45    }
46}
47
48impl From<&MediaQueryList> for Prefers {
49    fn from(query: &MediaQueryList) -> Self {
50        if query.matches() {
51            Self::Dark
52        } else {
53            Self::Light
54        }
55    }
56}
57
58/// Follow the system preference for as long as the page is open: `told` is
59/// called whenever the browser changes its mind, which is what keeps
60/// [`Mode::System`] actually following rather than sampling once at startup.
61///
62/// The listener outlives this call and is never taken down — it is the page's,
63/// and the page ends with the tab.
64pub fn watch(window: &Window, mut told: impl FnMut(Prefers) + 'static) {
65    let Ok(Some(query)) = window.match_media(DARK) else {
66        return;
67    };
68    let heard = Closure::<dyn FnMut(web_sys::MediaQueryListEvent)>::new(
69        move |event: web_sys::MediaQueryListEvent| {
70            told(if event.matches() {
71                Prefers::Dark
72            } else {
73                Prefers::Light
74            });
75        },
76    );
77    if query
78        .add_event_listener_with_callback("change", heard.as_ref().unchecked_ref())
79        .is_ok()
80    {
81        heard.forget();
82    }
83}
84
85/// Which end of the axis the page is drawn at.
86#[must_use]
87pub fn luminance(preference: Mode, prefers: Prefers) -> Luminance {
88    if preference.is_dark(prefers.system_dark()) {
89        Luminance::Dark
90    } else {
91        Luminance::Light
92    }
93}
94
95/// The class the root element carries so Tailwind's `dark:` variant applies —
96/// `tailwind.css` keys that variant off the class rather than the media query,
97/// which is what lets one reading drive both halves.
98#[must_use]
99pub fn root_class(luminance: Luminance) -> &'static str {
100    if luminance.is_dark() { "dark" } else { "" }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn a_stated_mode_outranks_what_the_browser_prefers() {
109        assert_eq!(luminance(Mode::Light, Prefers::Dark), Luminance::Light);
110        assert_eq!(luminance(Mode::Dark, Prefers::Light), Luminance::Dark);
111    }
112
113    #[test]
114    fn following_the_system_follows_the_query() {
115        assert_eq!(luminance(Mode::System, Prefers::Dark), Luminance::Dark);
116        assert_eq!(luminance(Mode::System, Prefers::Light), Luminance::Light);
117    }
118
119    /// An engine that does not answer the query is not answering "light":
120    /// the app's own default stands.
121    #[test]
122    fn an_unstated_preference_falls_back_to_the_default() {
123        assert_eq!(
124            luminance(Mode::System, Prefers::Unstated),
125            luminance(Mode::default(), Prefers::Unstated),
126        );
127    }
128}