rio-theme 0.23.0

Theme engine for rustio-admin: turns raw brand colors into a safe, computed tokens.css.
Documentation
//! Serialise `ThemeTokens` into a `tokens.css` string.
//!
//! Output is plain hex — all `color-mix()` math has already been done
//! by the engine. A static file is faster than nested `color-mix()`
//! at parse time and has zero browser-support risk.
//!
//! The `[data-theme="dark"]` block is always emitted even when light
//! and dark are identical; the *structure* anticipates the dark
//! theme's return (§8).
//!
//! The output has two sections inside the same `:root` block:
//!
//! 1. **Canonical brand-* tokens** — the engine's primary output per
//!    §11 of the implementation brief. These are the names the
//!    decision layer reasons in: `--rio-brand-light`, `-dark`,
//!    `-adaptive`, `-surface`, `-accent`, `-hover`, `-active`,
//!    `-tint`, `-text`.
//!
//! 2. **Drop-in compatibility tokens** — the names the live admin
//!    templates already consume (`--rio-accent*`, the surface ladder,
//!    the text ladder, the border ladder, semantic backgrounds).
//!    Without this block the generated file would not actually drop
//!    into the framework's CSS bundle. Brand-derived tokens
//!    (`--rio-accent`, `--rio-accent-hover`, `--rio-accent-soft`,
//!    `--rio-accent-border`, `--rio-bg`, `--rio-border`,
//!    `--rio-info-bg`) come from the resolved `ThemeTokens`; the
//!    slate scaffold (`--rio-surface-*`, `--rio-text-*`,
//!    `--rio-border-soft`, `--rio-border-strong`) is brand-agnostic
//!    by design — mirrors the live `colors.css` so a brand swap does
//!    not move the chrome ladder out from under existing components.

use std::fmt::Write as _;

use crate::color::Color;
use crate::engine::ThemeTokens;

/// Render a drop-in `tokens.css` from a fully resolved `ThemeTokens`.
///
/// The single `:root` block carries both the canonical `--rio-brand-*`
/// vocabulary the engine reasons in, and the legacy `--rio-*` names
/// the live admin templates already consume. See the module docs for
/// the rationale behind keeping both.
pub fn emit(tokens: &ThemeTokens) -> String {
    let mut s = String::new();
    s.push_str("/* Generated by rio-theme. Do not edit by hand. */\n");
    s.push_str(":root {\n");

    // --- Canonical engine output (DESIGN_THEME §11) ---
    s.push_str("  /* canonical brand-* tokens (engine output) */\n");
    line(&mut s, "--rio-brand-light", &tokens.brand_light.to_hex());
    line(&mut s, "--rio-brand-dark", &tokens.brand_dark.to_hex());
    s.push_str("  --rio-brand-adaptive: var(--rio-brand-light);\n");
    line(
        &mut s,
        "--rio-brand-surface",
        &tokens.brand_surface.to_hex(),
    );
    line(&mut s, "--rio-brand-accent", &tokens.brand_accent.to_hex());
    line(
        &mut s,
        "--rio-brand-secondary",
        &tokens.brand_secondary.to_hex(),
    );
    line(&mut s, "--rio-brand-hover", &tokens.brand_hover.to_hex());
    line(&mut s, "--rio-brand-active", &tokens.brand_active.to_hex());
    line(&mut s, "--rio-brand-tint", &tokens.brand_tint.to_hex());
    line(&mut s, "--rio-brand-text", &tokens.brand_text.to_hex());
    line(&mut s, "--rio-muted", &tokens.muted.to_hex());

    // --- Drop-in compatibility for the live admin template ---
    s.push('\n');
    s.push_str("  /* drop-in aliases for the live admin template */\n");

    // Brand-derived aliases. The live admin uses `--rio-accent` for
    // BUTTONS and other large affordances, so it must track the
    // tamed `brand_surface` (canonical "large fills" role), not the
    // raw `brand_accent` (canonical "small touches"). For non-vivid
    // inputs the two are equal so this is a no-op; for neon inputs
    // it's the difference between a usable button and an unreadable
    // one.
    line(&mut s, "--rio-accent", &tokens.brand_surface.to_hex());
    line(&mut s, "--rio-accent-hover", &tokens.brand_hover.to_hex());
    line(
        &mut s,
        "--rio-accent-rgb",
        &rgb_triple(&tokens.brand_surface),
    );
    line(&mut s, "--rio-accent-soft", &tokens.brand_tint.to_hex());
    // accent-border: a mid-light brand tint. Live framework uses
    // this for focus rings + input borders, where the visual job is
    // "lighter than the brand but the same family". Always lightened
    // brand-surface — secondary brand colors don't fit this role
    // (they're for badges/dots, surfaced as `--rio-brand-secondary`).
    line(
        &mut s,
        "--rio-accent-border",
        &tokens.brand_surface.lighten(0.65).to_hex(),
    );
    line(&mut s, "--rio-bg", &tokens.bg.to_hex());

    // Slate scaffold — brand-agnostic. Values lifted from the live
    // `colors.css` so generated themes inherit the same depth metaphor
    // and chrome relationship.
    line(&mut s, "--rio-surface", "#ffffff");
    line(&mut s, "--rio-surface-2", "#f8fafc");
    line(&mut s, "--rio-surface-3", "#f1f5f9");
    line(&mut s, "--rio-surface-chrome", "#0f172a");
    line(&mut s, "--rio-surface-elevated", "#ffffff");

    line(&mut s, "--rio-text-strong", "#0f172a");
    line(&mut s, "--rio-text", "#1e293b");
    line(&mut s, "--rio-text-muted", "#475569");
    line(&mut s, "--rio-text-subtle", "#64748b");

    line(&mut s, "--rio-border-soft", "#e2e8f0");
    line(&mut s, "--rio-border", &tokens.border.to_hex());
    line(&mut s, "--rio-border-strong", "#94a3b8");

    // Semantic foreground + matching soft backgrounds. Backgrounds
    // are computed from the (possibly hue-shifted) foregrounds, so a
    // shifted danger keeps a matching shifted danger-bg.
    line(&mut s, "--rio-success", &tokens.success.to_hex());
    line(&mut s, "--rio-warning", &tokens.warning.to_hex());
    line(&mut s, "--rio-danger", &tokens.danger.to_hex());
    line(
        &mut s,
        "--rio-success-bg",
        &soft_bg(&tokens.success).to_hex(),
    );
    line(
        &mut s,
        "--rio-warning-bg",
        &soft_bg(&tokens.warning).to_hex(),
    );
    line(&mut s, "--rio-danger-bg", &soft_bg(&tokens.danger).to_hex());
    line(&mut s, "--rio-info-bg", &tokens.brand_tint.to_hex());

    // Chart series.
    for (i, c) in tokens.chart.iter().enumerate() {
        let name = format!("--rio-chart-{}", i + 1);
        line(&mut s, &name, &c.to_hex());
    }

    s.push_str("}\n\n");
    s.push_str(":root[data-theme=\"dark\"] {\n");
    s.push_str("  --rio-brand-adaptive: var(--rio-brand-dark);\n");
    s.push_str("}\n");
    s
}

fn line(s: &mut String, name: &str, value: &str) {
    // Fixed two-space indent and a single space after the colon. No
    // alignment by length — golden-file stability beats prettiness.
    let _ = writeln!(s, "  {name}: {value};");
}

/// Space-separated R G B 0..255 triple, matching the live
/// `--rio-accent-rgb` convention (so `rgb(var(--rio-accent-rgb) / 0.2)`
/// keeps working in alpha-tinted overlays).
fn rgb_triple(color: &Color) -> String {
    // Reparse the hex to get the quantized channels — guarantees the
    // RGB triple agrees with the hex value the file already prints.
    let hex = color.to_hex();
    let r = u8::from_str_radix(&hex[1..3], 16).expect("emitted hex is valid");
    let g = u8::from_str_radix(&hex[3..5], 16).expect("emitted hex is valid");
    let b = u8::from_str_radix(&hex[5..7], 16).expect("emitted hex is valid");
    format!("{r} {g} {b}")
}

/// Soft pill background derived from a semantic foreground.
///
/// Starts at 92% white (visually a soft tint in the foreground's hue
/// family) and walks lighter in 1% increments until the foreground
/// clears `AA_NON_TEXT` (3.0) against the resulting background.
/// Bounded at 99% so we never collapse to pure white. The
/// hand-tuned tailwind `-50` colors (`#ECFDF5`, `#FFFBEB`,
/// `#FEF2F2`) pass at slightly lighter mixes than a flat 92% would
/// produce — without the loop, amber warning-on-bg lands at 2.94,
/// below the 3.0 threshold for pill text.
fn soft_bg(fg: &Color) -> Color {
    let white = Color::from_hex("#ffffff").expect("constant");
    let mut amount = 0.92_f64;
    loop {
        let bg = fg.mix(&white, amount);
        if amount >= 0.99
            || crate::contrast::contrast_ratio(fg, &bg) >= crate::contrast::AA_NON_TEXT
        {
            return bg;
        }
        amount += 0.01;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::{resolve_theme, ThemeInput};

    #[test]
    fn emit_contains_every_canonical_brand_token() {
        let css = emit(&resolve_theme(ThemeInput::empty()));
        for name in [
            "--rio-brand-light",
            "--rio-brand-dark",
            "--rio-brand-adaptive",
            "--rio-brand-surface",
            "--rio-brand-accent",
            "--rio-brand-secondary",
            "--rio-brand-hover",
            "--rio-brand-active",
            "--rio-brand-tint",
            "--rio-brand-text",
            "--rio-muted",
        ] {
            assert!(css.contains(name), "missing canonical {name}");
        }
    }

    #[test]
    fn emit_contains_every_live_template_token() {
        // If this list ever drifts from the live `colors.css`, the
        // generated file stops being a drop-in. Update both together.
        let css = emit(&resolve_theme(ThemeInput::empty()));
        for name in [
            "--rio-accent",
            "--rio-accent-hover",
            "--rio-accent-rgb",
            "--rio-accent-soft",
            "--rio-accent-border",
            "--rio-bg",
            "--rio-surface",
            "--rio-surface-2",
            "--rio-surface-3",
            "--rio-surface-chrome",
            "--rio-surface-elevated",
            "--rio-text-strong",
            "--rio-text",
            "--rio-text-muted",
            "--rio-text-subtle",
            "--rio-border-soft",
            "--rio-border",
            "--rio-border-strong",
            "--rio-success",
            "--rio-warning",
            "--rio-danger",
            "--rio-success-bg",
            "--rio-warning-bg",
            "--rio-danger-bg",
            "--rio-info-bg",
        ] {
            assert!(css.contains(name), "missing drop-in alias {name}");
        }
    }

    #[test]
    fn accent_rgb_triple_agrees_with_accent_hex() {
        // The triple is what `rgb(var(--rio-accent-rgb) / α)` uses,
        // so it must quantize to the same bytes as `--rio-accent`'s
        // hex. Drop-in `--rio-accent` aliases `brand_surface` (see
        // the comment in `emit`).
        let tokens = resolve_theme(ThemeInput::empty());
        let css = emit(&tokens);
        let hex = tokens.brand_surface.to_hex();
        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
        let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
        let expected = format!("--rio-accent-rgb: {r} {g} {b};");
        assert!(css.contains(&expected), "expected `{expected}` in:\n{css}");
    }

    #[test]
    fn dark_block_is_always_emitted() {
        let css = emit(&resolve_theme(ThemeInput::empty()));
        assert!(css.contains(":root[data-theme=\"dark\"]"));
    }

    #[test]
    fn soft_bg_always_clears_aa_non_text_against_its_foreground() {
        // Property test for the contrast-aware `soft_bg` loop.
        // Regression for the bug verification surfaced: a flat 92%
        // white mix left amber warning at 2.94 (below AA-large 3.0)
        // against its derived background. Every semantic bg must
        // now clear 3.0 against its fg.
        use crate::color::Color;
        use crate::contrast::{contrast_ratio, AA_NON_TEXT};
        for brand_hex in [
            "#3f6089", "#0d9488", "#39ff14", "#0a1a2e", "#c9572e", "#888888", "#dc2626",
        ] {
            let tokens = resolve_theme(ThemeInput {
                brand_colors: vec![Color::from_hex(brand_hex).unwrap()],
            });
            for (name, fg) in [
                ("success", tokens.success),
                ("warning", tokens.warning),
                ("danger", tokens.danger),
            ] {
                let bg = super::soft_bg(&fg);
                let r = contrast_ratio(&fg, &bg);
                assert!(
                    r >= AA_NON_TEXT - 0.01,
                    "brand {brand_hex}: {name} {} on derived bg {} only {r:.2}",
                    fg.to_hex(),
                    bg.to_hex(),
                );
            }
        }
    }

    #[test]
    fn chart_tokens_index_from_one() {
        use crate::color::Color;
        let css = emit(&resolve_theme(ThemeInput {
            brand_colors: vec![
                Color::from_hex("#3f6089").unwrap(),
                Color::from_hex("#c9572e").unwrap(),
                Color::from_hex("#2e7d5b").unwrap(),
            ],
        }));
        assert!(css.contains("--rio-chart-1"));
        assert!(!css.contains("--rio-chart-0"));
    }
}