ishou-render 0.1.23

ishou — target-specific renderers for the pleme-io design token set
Documentation
//! Stylix base16 renderer — emits the YAML format
//! `nix-community/stylix` consumes for `stylix.base16Scheme`.
//!
//! Closes the foreign-app theme loop: every GTK / GNOME / alacritty /
//! kitty / btop / k9s consumer stylix supports inherits the ishou
//! palette. After the fleet's `darwin-developer` profile swaps
//! `pkgs.base16-schemes/nord.yaml` for this renderer's output, there
//! is exactly **one** Nord in the pleme-io source tree.
//!
//! Output format (per base16 spec — <https://github.com/chriskempson/base16>):
//!
//! ```yaml
//! scheme: "Nord (pleme-io / ishou)"
//! author: "Arctic Ice Studio; mapped by pleme-io ishou"
//! base00: "2e3440"
//! base01: "3b4252"
//! …
//! base0F: "5e81ac"
//! ```
//!
//! Hex values are **lowercase, unprefixed** — stylix doesn't strip a
//! leading `#`, so emitting one breaks the GTK theme generation.
//!
//! ## base16 → ishou Nord mapping
//!
//! The standard base16 contract is *role-named slots*; we map them
//! onto Nord's palette in the conventional Nord base16 way (matches
//! what `nix-community/base16-schemes/nord.yaml` ships, which is the
//! file this renderer displaces).
//!
//! | slot   | ishou colour       | hex     | role |
//! |--------|--------------------|---------|------|
//! | base00 | `polar_night_0`    | 2e3440  | default background |
//! | base01 | `polar_night_1`    | 3b4252  | status bars, line numbers |
//! | base02 | `polar_night_2`    | 434c5e  | selection background |
//! | base03 | `polar_night_3`    | 4c566a  | comments, line highlight |
//! | base04 | `snow_storm_0`     | d8dee9  | dark foreground |
//! | base05 | `snow_storm_1`     | e5e9f0  | default foreground |
//! | base06 | `snow_storm_2`     | eceff4  | light foreground |
//! | base07 | `frost_0`          | 8fbcbb  | light background |
//! | base08 | `aurora_red`       | bf616a  | variables, markup deleted |
//! | base09 | `aurora_orange`    | d08770  | integers, markup link url |
//! | base0A | `aurora_yellow`    | ebcb8b  | classes, markup bold |
//! | base0B | `aurora_green`     | a3be8c  | strings, markup inserted |
//! | base0C | `frost_1`          | 88c0d0  | regexes, escape chars |
//! | base0D | `frost_2`          | 81a1c1  | functions, headings |
//! | base0E | `aurora_purple`    | b48ead  | keywords, markup italic |
//! | base0F | `frost_3`          | 5e81ac  | deprecated, embedded |

use ishou_tokens::{Rgb, TokenSet};

/// Render the canonical pleme-io base16 YAML stylix consumes.
///
/// Pure function — same `TokenSet` always produces byte-identical
/// output. Cross-pinned against the upstream `nix-community/base16-
/// schemes/nord.yaml` by the test suite so the swap is byte-stable
/// for the default theme.
/// The base16 → Nord slot mapping, in ONE place.
///
/// Both render targets below (YAML and the already-parsed Nix attrset) project
/// this. Keeping it as data rather than as two hand-written emission sequences
/// is the point: a divergence between the two would not be a build failure, it
/// would be two files claiming to be the same palette while disagreeing about
/// which colour `base0D` is — visible only as "the desktop looks slightly
/// wrong on one machine".
#[must_use]
pub fn base16_slots(t: &TokenSet) -> [(&'static str, Rgb); 16] {
    let c = &t.color;
    [
        ("base00", c.polar_night_0),
        ("base01", c.polar_night_1),
        ("base02", c.polar_night_2),
        ("base03", c.polar_night_3),
        ("base04", c.snow_storm_0),
        ("base05", c.snow_storm_1),
        ("base06", c.snow_storm_2),
        ("base07", c.frost_0),
        ("base08", c.aurora_red),
        ("base09", c.aurora_orange),
        ("base0A", c.aurora_yellow),
        ("base0B", c.aurora_green),
        ("base0C", c.frost_1),
        ("base0D", c.frost_2),
        ("base0E", c.aurora_purple),
        ("base0F", c.frost_3),
    ]
}

/// Nord base16 metadata. See [`crate::base16_nix::SchemeMeta`] for why every
/// field is load-bearing rather than decorative.
const NORD_META: crate::base16_nix::SchemeMeta = crate::base16_nix::SchemeMeta {
    name: "Nord (pleme-io / ishou)",
    author: "Arctic Ice Studio; mapped by pleme-io ishou",
    variant: "dark",
    slug: "nord-dark",
};

#[must_use]
pub fn render(t: &TokenSet) -> String {
    let mut out = String::new();
    out.push_str("# Generated by ishou-render::stylix — DO NOT EDIT\n");
    out.push_str("# Source of truth: pleme-io/ishou/crates/ishou-tokens/src/color.rs\n");
    out.push_str("# Architecture:    pleme-io/theory/THEME-ARCHITECTURE.md\n");
    out.push_str("scheme: \"Nord (pleme-io / ishou)\"\n");
    out.push_str("author: \"Arctic Ice Studio; mapped by pleme-io ishou\"\n");
    for (slot, rgb) in base16_slots(t) {
        push_base(&mut out, slot, rgb);
    }
    out
}

/// Render Nord base16 as an **already-parsed Nix attrset** — the IFD-free
/// shape.
///
/// This is the target a NixOS/Darwin module should consume. The sibling YAML
/// above can only reach stylix as `{ yaml = <derivation>; }`, which makes
/// stylix `readFile` a derivation mid-evaluation; that build is per-system, so
/// it is a Linux build, and it leaves every NixOS node in the fleet
/// unevaluable from a Mac. See [`crate::base16_nix`] for the full table.
#[must_use]
pub fn render_nix(t: &TokenSet) -> String {
    crate::base16_nix::render(
        &NORD_META,
        &base16_slots(t),
        "pleme-io/ishou/crates/ishou-tokens/src/color.rs",
        "stylix.base16Scheme = import ./rendered/stylix-base16-nord-dark.nix;",
    )
}

fn push_base(out: &mut String, slot: &str, rgb: Rgb) {
    // base16 yaml format: hex *without* `#` prefix, lowercase.
    // `slot` already carries its `base` prefix (see `base16_slots`).
    out.push_str(&format!(
        "{}: \"{:02x}{:02x}{:02x}\"\n",
        slot, rgb.r, rgb.g, rgb.b
    ));
}

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

    #[test]
    fn output_is_deterministic() {
        let t = TokenSet::pleme();
        assert_eq!(render(&t), render(&t));
    }

    #[test]
    fn output_contains_all_16_base_slots() {
        let out = render(&TokenSet::pleme());
        for slot in [
            "base00", "base01", "base02", "base03", "base04", "base05", "base06", "base07",
            "base08", "base09", "base0A", "base0B", "base0C", "base0D", "base0E", "base0F",
        ] {
            assert!(out.contains(&format!("{slot}: \"")), "missing {slot}");
        }
    }

    #[test]
    fn nord_polar_night_0_lands_at_base00() {
        let out = render(&TokenSet::pleme());
        assert!(
            out.contains("base00: \"2e3440\""),
            "base00 must be polar_night_0 (#2e3440); got:\n{out}"
        );
    }

    #[test]
    fn nord_snow_storm_1_lands_at_base05_for_foreground_role() {
        let out = render(&TokenSet::pleme());
        // base05 = default foreground per the base16 spec; Nord maps
        // it to snow_storm_1 (#e5e9f0) — the standard mapping that
        // matches base16-schemes/nord.yaml byte-for-byte.
        assert!(out.contains("base05: \"e5e9f0\""), "got:\n{out}");
    }

    #[test]
    fn nord_aurora_red_lands_at_base08() {
        let out = render(&TokenSet::pleme());
        assert!(out.contains("base08: \"bf616a\""), "got:\n{out}");
    }

    #[test]
    fn hex_is_lowercase_unprefixed_to_match_stylix_contract() {
        let out = render(&TokenSet::pleme());
        // stylix passes the values through to GTK which expects
        // exactly six-char lowercase hex (no `#`). A regression here
        // would break theming across the foreign-app world silently.
        for line in out.lines() {
            if let Some(rest) = line.strip_prefix("base") {
                if let Some(value_part) = rest.split(": ").nth(1) {
                    let v = value_part.trim_matches('"');
                    assert_eq!(v.len(), 6, "wrong length: {line}");
                    assert!(
                        v.chars()
                            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
                        "expected lowercase hex without prefix in: {line}"
                    );
                }
            }
        }
    }

    #[test]
    fn full_byte_for_byte_canonical_pleme_output() {
        // Snapshot of the entire canonical render. Any unintentional
        // change to the Nord palette or to the renderer's formatting
        // fails this test loudly. Update only when the upstream
        // change is deliberate.
        let out = render(&TokenSet::pleme());
        let expected = "\
# Generated by ishou-render::stylix — DO NOT EDIT
# Source of truth: pleme-io/ishou/crates/ishou-tokens/src/color.rs
# Architecture:    pleme-io/theory/THEME-ARCHITECTURE.md
scheme: \"Nord (pleme-io / ishou)\"
author: \"Arctic Ice Studio; mapped by pleme-io ishou\"
base00: \"2e3440\"
base01: \"3b4252\"
base02: \"434c5e\"
base03: \"4c566a\"
base04: \"d8dee9\"
base05: \"e5e9f0\"
base06: \"eceff4\"
base07: \"8fbcbb\"
base08: \"bf616a\"
base09: \"d08770\"
base0A: \"ebcb8b\"
base0B: \"a3be8c\"
base0C: \"88c0d0\"
base0D: \"81a1c1\"
base0E: \"b48ead\"
base0F: \"5e81ac\"
";
        assert_eq!(out, expected);
    }
}