Skip to main content

laterite_core/
theme.rs

1//! Colour mode: light, dark, or follow the system.
2//!
3//! The admin panel needs this, and so does any public site an application
4//! builds, so the mechanism lives here rather than inside either surface. The
5//! admin is one consumer of it, not its owner.
6//!
7//! What is offered is the **mechanism and its contract**, never an appearance:
8//! no icons, no toggle markup, no colour tokens. A site brings its own control
9//! and its own palette and keys them off the contract below.
10//!
11//! # The contract
12//!
13//! ```text
14//! <html data-theme="light">   the resolved mode; write CSS against this
15//! localStorage["lat-mode"]    the reader's choice: light | dark | auto
16//! window.latMode()            read the current choice
17//! window.latSetMode(mode)     persist a choice and apply it
18//! window.latApplyMode(mode)   apply without persisting
19//! ```
20//!
21//! `auto` follows the operating system *and keeps following it*: a page left
22//! open when the system switches at dusk changes with it rather than waiting for
23//! a reload. An explicit `light` or `dark` is the reader overriding the system,
24//! so it is left alone.
25//!
26//! # Using it from a page
27//!
28//! Put [`boot_markup`] in `<head>` before any stylesheet. It must be inline and
29//! blocking: a deferred script runs after first paint, and a dark-mode reader
30//! would see the page flash white first.
31//!
32//! ```rust
33//! # use laterite_core::theme;
34//! let head = format!("<head>{}<link rel=\"stylesheet\" href=\"/site.css\"></head>",
35//!                    theme::boot_markup());
36//! assert!(head.contains("latApplyMode"));
37//! ```
38//!
39//! Then style against the attribute, and wire any control to `latSetMode`:
40//!
41//! ```css
42//! :root            { --bg: white; --fg: black }
43//! [data-theme=dark]{ --bg: black; --fg: white }
44//! ```
45//!
46//! ```js
47//! document.querySelector('#to-dark').onclick = () => latSetMode('dark');
48//! ```
49//!
50//! # Content Security Policy
51//!
52//! An inline script needs `script-src 'unsafe-inline'`, or a nonce or hash. Use
53//! [`BOOT_JS`] to serve the same source as a file instead, accepting that a
54//! separate request may paint before it arrives.
55
56/// The boot script's JavaScript, without a surrounding `<script>` element.
57///
58/// Serve it as a file when a Content Security Policy forbids inline script, or
59/// embed it yourself with a nonce. Otherwise prefer [`boot_markup`].
60pub const BOOT_JS: &str = include_str!("../assets/theme-boot.js");
61
62/// The `localStorage` key holding the reader's choice, for a surface that wants
63/// to read or clear it without going through the script.
64pub const MODE_KEY: &str = "lat-mode";
65
66/// The three modes, as the strings stored under [`MODE_KEY`].
67pub const MODES: [&str; 3] = ["light", "dark", "auto"];
68
69/// The boot script wrapped in a `<script>` element, ready to inline in `<head>`
70/// before any stylesheet.
71///
72/// Returns markup, so a template renders it raw (Askama `|safe`, and the
73/// equivalent elsewhere). The content is a compile-time constant with no caller
74/// input in it, so there is nothing here to escape.
75pub fn boot_markup() -> String {
76    format!("<script>{BOOT_JS}</script>")
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn the_contract_the_docs_promise_is_the_one_the_script_establishes() {
85        // These names are the public surface: a site writes CSS and controls
86        // against them, so renaming one silently is a breaking change.
87        for name in [
88            "latApplyMode",
89            "latSetMode",
90            "latMode",
91            "data-theme",
92            MODE_KEY,
93        ] {
94            assert!(
95                BOOT_JS.contains(name),
96                "the boot script must define `{name}`"
97            );
98        }
99    }
100
101    #[test]
102    fn auto_keeps_following_the_system() {
103        // The bug this exists to prevent: resolving once at load and freezing,
104        // so a page open through dusk never changes.
105        assert!(BOOT_JS.contains("prefers-color-scheme"));
106        assert!(
107            BOOT_JS.contains("addEventListener") && BOOT_JS.contains("addListener"),
108            "must listen for the system changing, with the pre-Safari-14 fallback"
109        );
110    }
111
112    #[test]
113    fn the_markup_is_inlineable() {
114        let markup = boot_markup();
115        assert!(markup.starts_with("<script>") && markup.ends_with("</script>"));
116        // No closing tag inside the source would truncate the element early.
117        assert!(!BOOT_JS.contains("</script"));
118    }
119}