AkurAI Build
Menu

Public repository / 89d76abbb01a83de2e71d39c6321fc0d7ef581be

AkurAI-Framework / crates/css/src/theme.rs

Latest change a1491cd1a5fe5e958fcc0354752d288c1b7ec368 - release: v0.8.1 by Ólafur Búi Ólafsson

//! Named multi-theme generator — pure `std`, zero dependencies.
//!
//! Emits one `themes.css` holding every theme as a `:root[data-theme="…"]`
//! token block over the framework's existing semantic tokens (`--bg`, `--fg`,
//! `--accent`, `--ok`, `--danger`, …). Components never change: they read the
//! same token names, the active `data-theme` swaps the values.
//!
//! Themes are [base16](https://github.com/tinted-theming/schemes) colour
//! schemes — 16 perceptual slots (`base00`–`base0F`). The mapping below is
//! **polarity-agnostic**: a base16 *light* scheme inverts the `base00`→`base07`
//! ramp itself, so the same slot→token mapping produces a correct light theme
//! with no special-casing. That is what lets one rule cover dark and light.
//!
//! Slot → token (base16 convention):
//! `base00` background · `base01` recessed · `base02` panel · `base03` raised /
//! border / dim · `base04` muted text · `base05` text · `base08` red/danger ·
//! `base0A` yellow/warn · `base0B` green/ok · `base0C` cyan/info ·
//! `base0D` blue/accent · `base0E` magenta/accent-2.
//!
//! The first registered scheme (`akurai`) is also emitted as the bare `:root`
//! default so a page with no `data-theme` set still themes correctly.

/// A parsed base16 scheme: display name, light/dark polarity, and the 16 colours
/// (`palette[0]` = `base00` … `palette[15]` = `base0F`), each a CSS hex string
/// including the leading `#`.
#[derive(Clone)]
pub struct Scheme {
    pub name: String,
    pub variant: Variant,
    pub palette: [String; 16],
}

/// Theme polarity. Drives `color-scheme:` (so form controls/scrollbars match)
/// and the default shadow depth.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Variant {
    Dark,
    Light,
}

impl Variant {
    fn css(self) -> &'static str {
        match self {
            Variant::Dark => "dark",
            Variant::Light => "light",
        }
    }
}

/// One registry row: stable `slug` (the `data-theme` value and switcher key),
/// human `family` for grouping in the picker, and the raw vendored scheme text.
struct Entry {
    slug: &'static str,
    family: &'static str,
    raw: &'static str,
}

/// The shipped themes, in picker order. The first row is the default `:root`.
/// Adding a theme is data-only: vendor a base16 scheme and add a line here.
const SCHEMES: &[Entry] = &[
    Entry {
        slug: "akurai",
        family: "AkurAI",
        raw: include_str!("schemes/akurai.yaml"),
    },
    Entry {
        slug: "akurai-light",
        family: "AkurAI",
        raw: include_str!("schemes/akurai-light.yaml"),
    },
    Entry {
        slug: "claude-code",
        family: "Claude Code",
        raw: include_str!("schemes/claude-code.yaml"),
    },
    Entry {
        slug: "claude-code-light",
        family: "Claude Code",
        raw: include_str!("schemes/claude-code-light.yaml"),
    },
    Entry {
        slug: "nord",
        family: "Nord",
        raw: include_str!("schemes/nord.yaml"),
    },
    Entry {
        slug: "nord-light",
        family: "Nord",
        raw: include_str!("schemes/nord-light.yaml"),
    },
    Entry {
        slug: "catppuccin-mocha",
        family: "Catppuccin",
        raw: include_str!("schemes/catppuccin-mocha.yaml"),
    },
    Entry {
        slug: "catppuccin-latte",
        family: "Catppuccin",
        raw: include_str!("schemes/catppuccin-latte.yaml"),
    },
    Entry {
        slug: "solarized-dark",
        family: "Solarized",
        raw: include_str!("schemes/solarized-dark.yaml"),
    },
    Entry {
        slug: "solarized-light",
        family: "Solarized",
        raw: include_str!("schemes/solarized-light.yaml"),
    },
    Entry {
        slug: "gruvbox-dark",
        family: "Gruvbox",
        raw: include_str!("schemes/gruvbox-dark-medium.yaml"),
    },
    Entry {
        slug: "gruvbox-light",
        family: "Gruvbox",
        raw: include_str!("schemes/gruvbox-light-medium.yaml"),
    },
    Entry {
        slug: "tokyo-night",
        family: "Tokyo Night",
        raw: include_str!("schemes/tokyo-night-dark.yaml"),
    },
    Entry {
        slug: "tokyo-night-light",
        family: "Tokyo Night",
        raw: include_str!("schemes/tokyo-night-light.yaml"),
    },
    Entry {
        slug: "rose-pine",
        family: "Rosé Pine",
        raw: include_str!("schemes/rose-pine.yaml"),
    },
    Entry {
        slug: "rose-pine-dawn",
        family: "Rosé Pine",
        raw: include_str!("schemes/rose-pine-dawn.yaml"),
    },
    Entry {
        slug: "dracula",
        family: "Dracula",
        raw: include_str!("schemes/dracula.yaml"),
    },
];

/// Public metadata for one theme — everything the front-end switcher needs.
pub struct ThemeMeta {
    pub slug: &'static str,
    pub family: &'static str,
    pub label: String,
    pub variant: Variant,
}

/// The theme registry as structured metadata, in picker order.
pub fn registry() -> Vec<ThemeMeta> {
    SCHEMES
        .iter()
        .map(|e| {
            let s = parse(e.raw);
            ThemeMeta {
                slug: e.slug,
                family: e.family,
                label: s.name,
                variant: s.variant,
            }
        })
        .collect()
}

/// The registry as JSON, served at `/themes.json` so the switcher is data-driven
/// and never drifts from the generated CSS. Shape:
/// `[{"slug","family","label","variant"}, …]`.
pub fn registry_json() -> String {
    let mut out = String::from("[");
    for (i, m) in registry().iter().enumerate() {
        if i > 0 {
            out.push(',');
        }
        out.push_str(&format!(
            r#"{{"slug":"{}","family":"{}","label":"{}","variant":"{}"}}"#,
            esc(m.slug),
            esc(m.family),
            esc(&m.label),
            m.variant.css()
        ));
    }
    out.push(']');
    out
}

/// Render the full multi-theme stylesheet. The first scheme is emitted as the
/// bare `:root` default; every scheme (including the first) is also emitted as
/// `:root[data-theme="slug"]` so an explicit selection always wins.
pub fn themes_css() -> String {
    let mut out = String::new();
    out.push_str(
        "/* AkurAI-Framework themes — generated by akurai-css::theme. Do not edit by hand. */\n",
    );
    for (i, e) in SCHEMES.iter().enumerate() {
        let s = parse(e.raw);
        if i == 0 {
            out.push_str(":root{");
            out.push_str(&vars(&s));
            out.push_str("}\n");
        }
        out.push_str(&format!(":root[data-theme=\"{}\"]{{", e.slug));
        out.push_str(&vars(&s));
        out.push_str("}\n");
    }
    out
}

/// Map a parsed scheme's 16 slots to the framework's semantic token set.
fn vars(s: &Scheme) -> String {
    let p = &s.palette; // p[0]=base00 … p[15]=base0F
    let shadow = match s.variant {
        Variant::Dark => "0 24px 60px -28px rgba(0,0,0,.55)",
        Variant::Light => "0 18px 45px -24px rgba(20,22,30,.16)",
    };
    format!(
        "--bg:{bg};--bg-2:{bg2};--panel:{panel};--panel-2:{panel2};\
         --border:{border};--border-2:{border2};\
         --fg:{fg};--muted:{muted};--dim:{dim};\
         --accent:{accent};--accent-2:{accent2};\
         --ok:{ok};--warn:{warn};--info:{info};--danger:{danger};\
         --shadow:{shadow};color-scheme:{scheme};",
        bg = p[0],       // base00
        bg2 = p[1],      // base01
        panel = p[2],    // base02
        panel2 = p[3],   // base03
        border = p[3],   // base03
        border2 = p[4],  // base04
        fg = p[5],       // base05
        muted = p[4],    // base04
        dim = p[3],      // base03
        accent = p[13],  // base0D
        accent2 = p[14], // base0E
        ok = p[11],      // base0B
        warn = p[10],    // base0A
        info = p[12],    // base0C
        danger = p[8],   // base08
        shadow = shadow,
        scheme = s.variant.css(),
    )
}

/// Parse a flat base16 scheme document. Tolerant by design: it reads only the
/// `name:`, `variant:`, and `base00`–`base0F` lines, ignores everything else
/// (`system`, `author`, blank lines, `# comments` trailing a value), and never
/// panics on a malformed line. Unknown/missing slots stay `#000000`.
pub fn parse(text: &str) -> Scheme {
    let mut name = String::new();
    let mut variant = Variant::Dark;
    let mut palette: [String; 16] = Default::default();
    for slot in palette.iter_mut() {
        *slot = "#000000".to_string();
    }

    for line in text.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("name:") {
            name = unquote(rest);
        } else if let Some(rest) = line.strip_prefix("variant:") {
            variant = match unquote(rest).to_ascii_lowercase().as_str() {
                "light" => Variant::Light,
                _ => Variant::Dark,
            };
        } else if let Some(rest) = line.strip_prefix("base") {
            // `00: "#2E3440"  # comment`  →  index 0, value #2E3440
            let Some((idx_str, val_str)) = rest.split_once(':') else {
                continue;
            };
            let Some(idx) = slot_index(idx_str.trim()) else {
                continue;
            };
            let hex = unquote(val_str);
            if !hex.is_empty() {
                palette[idx] = normalize_hex(&hex);
            }
        }
    }

    Scheme {
        name,
        variant,
        palette,
    }
}

/// Two-hex-digit base16 slot id (`"00"`..="0F", case-insensitive) → array index.
fn slot_index(id: &str) -> Option<usize> {
    if id.len() != 2 {
        return None;
    }
    usize::from_str_radix(id, 16).ok().filter(|&n| n < 16)
}

/// Take the first double-quoted token on the line, else the first bare token,
/// stripping a trailing `# comment`. Returns the value without surrounding
/// quotes or whitespace.
fn unquote(s: &str) -> String {
    let s = s.trim();
    if let Some(start) = s.find('"') {
        if let Some(end) = s[start + 1..].find('"') {
            return s[start + 1..start + 1 + end].trim().to_string();
        }
    }
    // No quotes: take up to a comment marker.
    s.split('#')
        .next()
        .unwrap_or("")
        .trim()
        .trim_matches('"')
        .to_string()
}

/// Ensure a leading `#` and lowercase the hex so output is uniform.
fn normalize_hex(h: &str) -> String {
    let h = h.trim();
    let body = h.strip_prefix('#').unwrap_or(h);
    format!("#{}", body.to_ascii_lowercase())
}

/// Minimal JSON string escaping for the registry payload.
fn esc(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_simple_scheme() {
        let s = parse(super::SCHEMES[0].raw);
        assert_eq!(s.name, "AkurAI");
        assert!(matches!(s.variant, Variant::Dark));
        assert_eq!(s.palette[0], "#060912"); // base00 (deep-navy, drive-derived)
        assert_eq!(s.palette[13], "#5b8cff"); // base0D / accent
    }

    #[test]
    fn parses_value_with_trailing_comment() {
        // Dracula's vendored file has `base00: "#282a36"  # Default Background`.
        let dracula = parse(include_str!("schemes/dracula.yaml"));
        assert_eq!(dracula.palette[0], "#282a36");
        assert_eq!(dracula.palette[13], "#bd93f9"); // accent stays clean of comment
    }

    #[test]
    fn light_variant_detected() {
        let latte = parse(include_str!("schemes/catppuccin-latte.yaml"));
        assert!(matches!(latte.variant, Variant::Light));
    }

    #[test]
    fn default_root_and_every_theme_block_present() {
        let css = themes_css();
        assert!(css.contains(":root{--bg:#060912"), "default :root missing");
        // Every registered slug has a data-theme block.
        for e in SCHEMES {
            let needle = format!(":root[data-theme=\"{}\"]{{", e.slug);
            assert!(css.contains(&needle), "missing block for {}", e.slug);
        }
        // Semantic mapping reaches the accent slot.
        assert!(
            css.contains("--accent:#81a1c1"),
            "Nord accent (base0D) not mapped"
        );
    }

    #[test]
    fn color_scheme_tracks_variant() {
        let css = themes_css();
        assert!(css.contains("data-theme=\"claude-code-light\"]{"));
        // The light block must declare color-scheme:light.
        let block = css
            .split("data-theme=\"claude-code-light\"]{")
            .nth(1)
            .unwrap();
        let block = block.split('}').next().unwrap();
        assert!(
            block.contains("color-scheme:light"),
            "light theme not marked"
        );
    }

    #[test]
    fn registry_json_is_wellformed() {
        let json = registry_json();
        assert!(json.starts_with('['));
        assert!(json.ends_with(']'));
        assert!(json.contains(r#""slug":"nord""#));
        assert!(json.contains(r#""variant":"light""#));
        assert!(json.contains(r#""family":"Claude Code""#));
    }

    #[test]
    fn every_theme_maps_all_required_tokens() {
        for e in SCHEMES {
            let s = parse(e.raw);
            let v = vars(&s);
            for tok in [
                "--bg:",
                "--fg:",
                "--accent:",
                "--ok:",
                "--warn:",
                "--danger:",
                "--panel:",
                "--border:",
                "--muted:",
                "color-scheme:",
            ] {
                assert!(v.contains(tok), "{} missing {}", e.slug, tok);
            }
        }
    }
}