Skip to main content

max / makeover

9.0 KB · 228 lines History Blame Raw
1 //! Every theme in one sheet, keyed by a root attribute.
2 //!
3 //! The block above serves one theme: a consumer resolves the chosen id, renders
4 //! `:root`, and links the result. Changing the pin then means rendering a new
5 //! sheet and getting the document to re-link it, which an htmx navigation does
6 //! not do -- so a pinned change landed at the next launch and the screen had to
7 //! apologise for it in a hint.
8 //!
9 //! The fix is to stop encoding the choice in *which* sheet is linked. One sheet
10 //! carries every theme, each behind `:root[data-theme="<id>"]`, and choosing is
11 //! setting an attribute. No reload, no second request, and the picker can
12 //! preview a theme by writing the attribute and undo by writing the old one.
13 //!
14 //! It is a separate emitter rather than a wider `intent_css_vars` because the
15 //! bundle is not free: 31 themes of custom properties, against the one block a
16 //! server-rendered page injects per response. MNW ships a single theme and must
17 //! keep paying for a single theme, so this is opt-in by being its own call.
18
19 use crate::{
20 SemanticTokens, ThemeDefaults, ThemeSelection, Variant, intent_css_declarations,
21 intent_css_vars, list_themes_from_dirs, load_semantic,
22 };
23 use std::path::PathBuf;
24
25 /// The root attribute [`all_themes_css`] keys its blocks on.
26 ///
27 /// Stated here so a consumer's frontend and its stylesheet cannot disagree
28 /// about the spelling; a picker writes this attribute on `document
29 /// .documentElement` and nothing else has to change.
30 pub const THEME_ATTRIBUTE: &str = "data-theme";
31
32 /// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as
33 /// `:root[data-theme="<id>"] { … }`.
34 ///
35 /// The attribute selector outranks the bare `:root` of [`intent_css_vars`],
36 /// including one inside a media query, so a sheet may carry an
37 /// ambient-following default and let a pin override it without `!important`
38 /// and without ordering games.
39 pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
40 format!(
41 ":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
42 intent_css_declarations(tokens)
43 )
44 }
45
46 /// Every theme in `dirs` as one stylesheet: an ambient-following default, then
47 /// a keyed block per theme.
48 ///
49 /// The sheet a consumer links once and never re-links. Setting
50 /// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or
51 /// setting it to anything that names no theme (`"system"`, say), falls back to
52 /// the default blocks, which follow the OS through `prefers-color-scheme` and
53 /// `prefers-contrast`. Those are the same three ambient modes
54 /// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution
55 /// of the same selection agree.
56 ///
57 /// `defaults` names the app's own fallbacks. A high-contrast default is only
58 /// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the
59 /// dark theme is right for a resolution and wrong for a media query, where it
60 /// would answer `prefers-contrast: more` with a theme that is not one.
61 ///
62 /// Themes that fail to load are skipped rather than failing the sheet: a
63 /// consumer's custom directory is user-writable, and one unparseable file
64 /// there should cost that file's block and nothing else.
65 ///
66 /// Blocks are ordered by id so the output is byte-stable, which is what lets a
67 /// caller cache it or compare two builds.
68 pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
69 let available = list_themes_from_dirs(dirs);
70 let mut out = String::new();
71
72 let mut default_block = |variant: Variant, query: Option<&str>| {
73 let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
74 let Ok(tokens) = load_semantic(dirs, &id) else {
75 return;
76 };
77 match query {
78 None => out.push_str(&intent_css_vars(&tokens)),
79 Some(query) => {
80 out.push_str("\n@media (");
81 out.push_str(query);
82 out.push_str(") {\n");
83 out.push_str(&intent_css_vars(&tokens));
84 out.push_str("}\n");
85 }
86 }
87 };
88
89 default_block(Variant::Light, None);
90 default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
91 if defaults.names_high_contrast() {
92 default_block(Variant::HighContrast, Some("prefers-contrast: more"));
93 }
94
95 let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
96 ids.sort_unstable();
97 for id in ids {
98 if let Ok(tokens) = load_semantic(dirs, id) {
99 out.push('\n');
100 out.push_str(&keyed_intent_css_vars(id, &tokens));
101 }
102 }
103
104 out
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110 use crate::fixture::nord_toml;
111 use crate::{bundled_themes_dir, parse_theme_str, resolve};
112 use std::fs;
113
114 // ---- every theme in one sheet ----
115
116 /// The shipped themes, as the search path a consumer hands the emitter.
117 fn shipped() -> Vec<(PathBuf, bool)> {
118 vec![(
119 bundled_themes_dir().expect("makeover ships its themes"),
120 false,
121 )]
122 }
123
124 #[test]
125 fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
126 let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
127 let keyed = keyed_intent_css_vars("nord", &tokens);
128 assert!(
129 keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
130 "{keyed}"
131 );
132 assert_eq!(
133 keyed.replace(":root[data-theme=\"nord\"]", ":root"),
134 intent_css_vars(&tokens),
135 "the two emitters differ only in the selector"
136 );
137 }
138
139 #[test]
140 fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
141 let dirs = shipped();
142 let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
143
144 let keys: Vec<&str> = css
145 .match_indices(":root[data-theme=\"")
146 .map(|(at, prefix)| {
147 let rest = &css[at + prefix.len()..];
148 &rest[..rest.find('"').unwrap()]
149 })
150 .collect();
151
152 let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
153 .into_iter()
154 .map(|meta| meta.id)
155 .collect();
156 expected.sort();
157 assert_eq!(keys, expected, "one block per theme, ordered by id");
158 assert!(
159 keys.len() > 20,
160 "the shipped set is the whole picker: {keys:?}"
161 );
162 }
163
164 /// The property the whole sheet exists for: a pin is an attribute, and it
165 /// beats the ambient default without `!important` or ordering games.
166 #[test]
167 fn the_default_follows_the_system_and_a_pin_outranks_it() {
168 let css = all_themes_css(
169 &shipped(),
170 &ThemeDefaults::new("goingson", "catppuccin-mocha"),
171 );
172
173 assert!(css.starts_with(":root {\n"), "the light default is first");
174 assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
175
176 // Specificity, not order: (0,1,0) for the default against (0,2,0) for
177 // a keyed block. Asserted as the fact that the keyed blocks follow the
178 // defaults, which is the ordering that would matter if they tied.
179 let dark = css.find("prefers-color-scheme").unwrap();
180 let first_key = css.find(":root[data-theme=").unwrap();
181 assert!(dark < first_key, "defaults, then the keyed blocks");
182 }
183
184 /// `for_variant` answers every mode by falling back to dark, so emitting a
185 /// `prefers-contrast` block unconditionally would answer the preference
186 /// with a theme that does not honour it.
187 #[test]
188 fn a_high_contrast_block_appears_only_when_one_was_named() {
189 let dirs = shipped();
190 let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
191 assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
192
193 let named = plain.clone().high_contrast("high-contrast");
194 let css = all_themes_css(&dirs, &named);
195 assert!(
196 css.contains("@media (prefers-contrast: more) {\n:root {\n"),
197 "{css}"
198 );
199 }
200
201 /// A consumer's custom directory is user-writable, so one bad file there
202 /// costs its own block and nothing else.
203 #[test]
204 fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
205 let custom = tempfile::tempdir().unwrap();
206 fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
207 fs::write(
208 custom.path().join("mine.toml"),
209 "[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
210 )
211 .unwrap();
212
213 let mut dirs = shipped();
214 dirs.push((custom.path().to_path_buf(), true));
215 let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
216
217 assert!(
218 css.contains(":root[data-theme=\"mine\"] {"),
219 "a custom theme is switchable too"
220 );
221 assert!(!css.contains("data-theme=\"broken\""), "{css}");
222 assert!(
223 css.contains(":root[data-theme=\"nord\"] {"),
224 "the rest of the sheet survives"
225 );
226 }
227 }
228