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}