makeover 3.5.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! 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="<id>"]`, 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="<id>"] { … }`.
///
/// 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<String> = 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"
        );
    }
}