//! Every theme in one sheet, keyed by a root attribute. //! //! The block above serves one theme: a consumer resolves the chosen id, renders //! `:root`, and links the result. Changing the pin then means rendering a new //! sheet and getting the document to re-link it, which an htmx navigation does //! not do -- so a pinned change landed at the next launch and the screen had to //! apologise for it in a hint. //! //! The fix is to stop encoding the choice in *which* sheet is linked. One sheet //! carries every theme, each behind `:root[data-theme=""]`, and choosing is //! setting an attribute. No reload, no second request, and the picker can //! preview a theme by writing the attribute and undo by writing the old one. //! //! It is a separate emitter rather than a wider `intent_css_vars` because the //! bundle is not free: 31 themes of custom properties, against the one block a //! server-rendered page injects per response. MNW ships a single theme and must //! keep paying for a single theme, so this is opt-in by being its own call. use crate::{ SemanticTokens, ThemeDefaults, ThemeSelection, Variant, intent_css_declarations, intent_css_vars, list_themes_from_dirs, load_semantic, }; use std::path::PathBuf; /// The root attribute [`all_themes_css`] keys its blocks on. /// /// Stated here so a consumer's frontend and its stylesheet cannot disagree /// about the spelling; a picker writes this attribute on `document /// .documentElement` and nothing else has to change. pub const THEME_ATTRIBUTE: &str = "data-theme"; /// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as /// `:root[data-theme=""] { … }`. /// /// The attribute selector outranks the bare `:root` of [`intent_css_vars`], /// including one inside a media query, so a sheet may carry an /// ambient-following default and let a pin override it without `!important` /// and without ordering games. pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String { format!( ":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n", intent_css_declarations(tokens) ) } /// Every theme in `dirs` as one stylesheet: an ambient-following default, then /// a keyed block per theme. /// /// The sheet a consumer links once and never re-links. Setting /// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or /// setting it to anything that names no theme (`"system"`, say), falls back to /// the default blocks, which follow the OS through `prefers-color-scheme` and /// `prefers-contrast`. Those are the same three ambient modes /// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution /// of the same selection agree. /// /// `defaults` names the app's own fallbacks. A high-contrast default is only /// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the /// dark theme is right for a resolution and wrong for a media query, where it /// would answer `prefers-contrast: more` with a theme that is not one. /// /// Themes that fail to load are skipped rather than failing the sheet: a /// consumer's custom directory is user-writable, and one unparseable file /// there should cost that file's block and nothing else. /// /// Blocks are ordered by id so the output is byte-stable, which is what lets a /// caller cache it or compare two builds. pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String { let available = list_themes_from_dirs(dirs); let mut out = String::new(); let mut default_block = |variant: Variant, query: Option<&str>| { let id = ThemeSelection::Follow.resolve(variant, defaults, &available); let Ok(tokens) = load_semantic(dirs, &id) else { return; }; match query { None => out.push_str(&intent_css_vars(&tokens)), Some(query) => { out.push_str("\n@media ("); out.push_str(query); out.push_str(") {\n"); out.push_str(&intent_css_vars(&tokens)); out.push_str("}\n"); } } }; default_block(Variant::Light, None); default_block(Variant::Dark, Some("prefers-color-scheme: dark")); if defaults.names_high_contrast() { default_block(Variant::HighContrast, Some("prefers-contrast: more")); } let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect(); ids.sort_unstable(); for id in ids { if let Ok(tokens) = load_semantic(dirs, id) { out.push('\n'); out.push_str(&keyed_intent_css_vars(id, &tokens)); } } out } #[cfg(test)] mod tests { use super::*; use crate::fixture::nord_toml; use crate::{bundled_themes_dir, parse_theme_str, resolve}; use std::fs; // ---- every theme in one sheet ---- /// The shipped themes, as the search path a consumer hands the emitter. fn shipped() -> Vec<(PathBuf, bool)> { vec![( bundled_themes_dir().expect("makeover ships its themes"), false, )] } #[test] fn a_keyed_block_carries_the_same_declarations_as_a_root_one() { let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap()); let keyed = keyed_intent_css_vars("nord", &tokens); assert!( keyed.starts_with(":root[data-theme=\"nord\"] {\n"), "{keyed}" ); assert_eq!( keyed.replace(":root[data-theme=\"nord\"]", ":root"), intent_css_vars(&tokens), "the two emitters differ only in the selector" ); } #[test] fn every_installed_theme_gets_a_block_and_they_are_in_id_order() { let dirs = shipped(); let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha")); let keys: Vec<&str> = css .match_indices(":root[data-theme=\"") .map(|(at, prefix)| { let rest = &css[at + prefix.len()..]; &rest[..rest.find('"').unwrap()] }) .collect(); let mut expected: Vec = list_themes_from_dirs(&dirs) .into_iter() .map(|meta| meta.id) .collect(); expected.sort(); assert_eq!(keys, expected, "one block per theme, ordered by id"); assert!( keys.len() > 20, "the shipped set is the whole picker: {keys:?}" ); } /// The property the whole sheet exists for: a pin is an attribute, and it /// beats the ambient default without `!important` or ordering games. #[test] fn the_default_follows_the_system_and_a_pin_outranks_it() { let css = all_themes_css( &shipped(), &ThemeDefaults::new("goingson", "catppuccin-mocha"), ); assert!(css.starts_with(":root {\n"), "the light default is first"); assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n")); // Specificity, not order: (0,1,0) for the default against (0,2,0) for // a keyed block. Asserted as the fact that the keyed blocks follow the // defaults, which is the ordering that would matter if they tied. let dark = css.find("prefers-color-scheme").unwrap(); let first_key = css.find(":root[data-theme=").unwrap(); assert!(dark < first_key, "defaults, then the keyed blocks"); } /// `for_variant` answers every mode by falling back to dark, so emitting a /// `prefers-contrast` block unconditionally would answer the preference /// with a theme that does not honour it. #[test] fn a_high_contrast_block_appears_only_when_one_was_named() { let dirs = shipped(); let plain = ThemeDefaults::new("goingson", "catppuccin-mocha"); assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast")); let named = plain.clone().high_contrast("high-contrast"); let css = all_themes_css(&dirs, &named); assert!( css.contains("@media (prefers-contrast: more) {\n:root {\n"), "{css}" ); } /// A consumer's custom directory is user-writable, so one bad file there /// costs its own block and nothing else. #[test] fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() { let custom = tempfile::tempdir().unwrap(); fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap(); fs::write( custom.path().join("mine.toml"), "[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n", ) .unwrap(); let mut dirs = shipped(); dirs.push((custom.path().to_path_buf(), true)); let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha")); assert!( css.contains(":root[data-theme=\"mine\"] {"), "a custom theme is switchable too" ); assert!(!css.contains("data-theme=\"broken\""), "{css}"); assert!( css.contains(":root[data-theme=\"nord\"] {"), "the rest of the sheet survives" ); } }