Skip to main content

ishou_render/
vellum.rs

1//! Vellum render targets — the stylix base16/base24 schemes + an SVG
2//! palette preview, all sourced from the BORN `VellumPalette` tokens.
3//!
4//! Unlike the legacy `stylix` renderer (which maps the Nord `TokenSet`),
5//! these targets construct `VellumPalette::vellum()` internally — the
6//! Vellum tokens are not part of the Nord `TokenSet`, they are their
7//! own BORN source. The base16/base24 YAML is emitted through
8//! `serde_yaml` over an `IndexMap` — TYPED EMISSION, no
9//! string-concatenated YAML. The SVG preview is a brand-asset target
10//! (one chip per token, in band order).
11//!
12//! Output (per base16 spec — <https://github.com/chriskempson/base16>):
13//!
14//! ```yaml
15//! system: base16
16//! name: Vellum
17//! author: pleme-io (ishou)
18//! variant: dark
19//! slug: vellum
20//! palette:
21//!   base00: 16140e
22//!   …
23//! ```
24//!
25//! Hex values are **lowercase, unprefixed** — stylix passes them through
26//! to GTK which expects six-char lowercase hex (no `#`).
27
28use indexmap::IndexMap;
29use ishou_tokens::{Rgb, VellumPalette};
30use serde::Serialize;
31
32/// Lowercase, unprefixed six-char hex — the stylix/base16 wire format.
33fn slot_hex(rgb: Rgb) -> String {
34    format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
35}
36
37/// The base16/base24 scheme object serde_yaml serializes. `palette` is
38/// an `IndexMap` so slot order is the authored slot order, not hash
39/// order.
40#[derive(Serialize)]
41struct Scheme {
42    system: &'static str,
43    name: &'static str,
44    author: &'static str,
45    variant: &'static str,
46    slug: &'static str,
47    palette: IndexMap<String, String>,
48}
49
50fn scheme(system: &'static str, palette: IndexMap<String, String>) -> Scheme {
51    Scheme {
52        system,
53        name: "Vellum",
54        author: "pleme-io (ishou)",
55        variant: "dark",
56        slug: "vellum",
57        palette,
58    }
59}
60
61const HEADER: &str = "# Generated by ishou-render::vellum — DO NOT EDIT\n\
62     # Source of truth: pleme-io/ishou/crates/ishou-tokens/src/vellum.rs\n\
63     # Vellum — the fleet theme (warm aged-paper Nord-matte)\n";
64
65/// Render the Vellum **base16** stylix scheme YAML.
66///
67/// Pure — `VellumPalette::vellum()` is deterministic, so this is too.
68/// serde_yaml owns the escaping; the slot order is the canonical order.
69#[must_use]
70pub fn render_base16() -> String {
71    let p = VellumPalette::vellum();
72    let mut palette = IndexMap::new();
73    for (slot, rgb) in p.base16() {
74        palette.insert(slot.to_string(), slot_hex(rgb));
75    }
76    let body =
77        serde_yaml::to_string(&scheme("base16", palette)).expect("Scheme is always serializable");
78    format!("{HEADER}{body}")
79}
80
81/// Render the Vellum base16 scheme as a **Nix attrset** — the form that
82/// costs no import-from-derivation.
83///
84/// ── ★ WHY THIS EXISTS AND THE YAML DOES NOT SUFFICE ──
85/// stylix's `base16.nix` classifies its input with
86/// `is-not-parsed = builtins.isAttrs scheme && !(scheme ? "yaml")`. The
87/// fleet passed `{ yaml = <derivation>; }`, which sets `is-y2a-args`, so
88/// stylix does `readFile <drv>` — an IFD. That forces a BUILD during
89/// evaluation, and since the consumers are NixOS nodes the build is a
90/// linux one: `nix eval .#nixosConfigurations.<node>` becomes impossible
91/// from a Mac. The nixos-gnome-desktop profile's own comment records the
92/// cost — the same IFD in the FONTS slot "made ggg's config UNEVALUABLE
93/// from a Mac", which is why fonts were moved to a precomputed render and
94/// why this is that move's missing twin.
95///
96/// A literal attrset takes the `is-not-parsed` branch instead: stylix
97/// treats it as an already-parsed colour set and never reads a file. No
98/// derivation, no build, no platform coupling.
99///
100/// Emitted through the typed `nix_ast` printer rather than by formatting
101/// strings — `format!()` of Nix syntax is banned (theory/NIX-AST.md), and
102/// the failure it prevents is silent: an unbalanced brace or a missing
103/// semicolon ships as a file that parses to the wrong thing.
104#[must_use]
105pub fn render_base16_nix() -> String {
106    use crate::nix_ast::{AttrEntry, NixFile, attrset, str_};
107
108    let p = VellumPalette::vellum();
109    let meta = scheme("base16", IndexMap::new());
110
111    // The METADATA is not decoration — omitting it silently renames things.
112    //
113    // base16.nix's `input-meta` defaults every absent field:
114    // `scheme`/`author` -> "untitled", `variant` -> "unspecified". Those
115    // strings are interpolated into generated artefact NAMES —
116    // `base16-${slug}` (fish), `"Base16 ${scheme-name}"` (zed),
117    // `${slug}-gnome-shell-theme` — so a metadata-less attrset keeps every
118    // colour byte-identical while turning `base16-vellum` into
119    // `base16-untitled` and renaming a pile of store paths.
120    //
121    // `name` rather than `scheme`: base16.nix's
122    // `convert-scheme-to-common-format` maps `name` -> `scheme`, and `name`
123    // is what the YAML render beside this one already emits, so the two
124    // stay spellable the same way.
125    let mut entries: Vec<AttrEntry> = vec![
126        AttrEntry::new("system", str_(meta.system)),
127        AttrEntry::new("name", str_(meta.name)),
128        AttrEntry::new("author", str_(meta.author)),
129        AttrEntry::new("variant", str_(meta.variant)),
130        AttrEntry::new("slug", str_(meta.slug)),
131    ];
132    entries.extend(
133        p.base16()
134            .into_iter()
135            // Hex WITHOUT a leading `#`, matching the YAML this replaces —
136            // stylix's own scheme format is bare hex, and a `#` here would
137            // be a silent colour change rather than a parse error.
138            .map(|(slot, rgb)| AttrEntry::new(slot, str_(slot_hex(rgb)))),
139    );
140
141    NixFile::new(
142        [
143            "Generated by ishou-render::vellum — DO NOT EDIT",
144            "Source of truth: pleme-io/ishou/crates/ishou-tokens/src/vellum.rs",
145            "",
146            "Vellum base16 as an ALREADY-PARSED attrset. Consumed as:",
147            "  stylix.base16Scheme = import ./rendered/stylix-base16-vellum.nix;",
148            "",
149            "The `{ yaml = <derivation>; }` form this replaces made stylix",
150            "readFile a derivation — an IFD — which forced a linux build during",
151            "evaluation and left every NixOS node unevaluable from a Mac.",
152        ],
153        attrset(entries),
154    )
155    .render()
156}
157
158/// Render the Vellum **base24** stylix scheme YAML (base16 + the
159/// real two-tier brights, base10–17).
160#[must_use]
161pub fn render_base24() -> String {
162    let p = VellumPalette::vellum();
163    let mut palette = IndexMap::new();
164    for (slot, rgb) in p.base24() {
165        palette.insert(slot.to_string(), slot_hex(rgb));
166    }
167    let body =
168        serde_yaml::to_string(&scheme("base24", palette)).expect("Scheme is always serializable");
169    format!("{HEADER}{body}")
170}
171
172/// Render an SVG palette preview — one labelled chip per BORN token, in
173/// band order. A brand-asset target for docs / design review.
174#[must_use]
175pub fn render_svg_palette() -> String {
176    let p = VellumPalette::vellum();
177    let entries = p.entries();
178    let cols = 6usize;
179    let chip = 96i32;
180    let pad = 12i32;
181    let label_h = 22i32;
182    let rows = entries.len().div_ceil(cols) as i32;
183    let width = cols as i32 * (chip + pad) + pad;
184    let height = rows * (chip + label_h + pad) + pad;
185
186    let mut chips = String::new();
187    for (i, (name, rgb)) in entries.iter().enumerate() {
188        let col = (i % cols) as i32;
189        let row = (i / cols) as i32;
190        let x = pad + col * (chip + pad);
191        let y = pad + row * (chip + label_h + pad);
192        let hex = rgb.hex();
193        // Pick a readable label colour: light on dark chips, dark on
194        // light chips, by computed luminance.
195        let lum = 0.2126 * f64::from(rgb.r) + 0.7152 * f64::from(rgb.g) + 0.0722 * f64::from(rgb.b);
196        let text_fill = if lum > 140.0 { "#16140E" } else { "#F4EFE2" };
197        chips.push_str(&format!(
198            "  <rect x=\"{x}\" y=\"{y}\" width=\"{chip}\" height=\"{chip}\" rx=\"8\" fill=\"{hex}\"/>\n  \
199             <text x=\"{tx}\" y=\"{ty}\" font-family=\"monospace\" font-size=\"9\" fill=\"{text_fill}\">{hex}</text>\n  \
200             <text x=\"{x}\" y=\"{ly}\" font-family=\"monospace\" font-size=\"10\" fill=\"#E2DBC8\">{name}</text>\n",
201            tx = x + 6,
202            ty = y + chip - 8,
203            ly = y + chip + 15,
204        ));
205    }
206
207    format!(
208        "<!-- ishou Vellum palette preview (generated) -->\n\
209         <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" width=\"{width}\" height=\"{height}\">\n  \
210         <rect width=\"{width}\" height=\"{height}\" fill=\"#16140E\"/>\n{chips}</svg>\n"
211    )
212}
213
214// ─── skim / fzf `--color` string ────────────────────────────────────────────
215
216/// Resolve a Vellum token to its **uppercase** `#RRGGBB` hex — the skim
217/// `--color` wire format mirrors the hand-authored `skim-tab::NORD_COLORS`,
218/// which uses uppercase hex.
219fn tok(p: &VellumPalette, name: &str) -> String {
220    p.get(name)
221        .unwrap_or_else(|| panic!("vellum token `{name}` missing"))
222        .hex()
223}
224
225/// Render the skim/fzf `--color=k:v,…` string from the BORN `VellumPalette`.
226///
227/// Byte-equivalent to the hand-authored `pleme-io/skim-tab::NORD_COLORS`:
228/// every colour resolves from a typed token, the `:bold`/`:underlined`
229/// attribute suffixes are the picker's fixed UX contract. The role→token
230/// map is the single source of truth — change a Vellum token and every
231/// picker on the fleet follows.
232///
233/// Role → token:
234/// - `fg`      → `snow1`        (base05, the text fg)
235/// - `bg`      → `night0`       (base00, the parchment ground)
236/// - `hl`      → `ice_cyan`     (base0C, match highlight) `:bold:underlined`
237/// - `fg+`     → `snow3`        (base07, selected-line fg) `:bold`
238/// - `bg+`     → `night2`       (the selected-line surface)
239/// - `hl+`     → `cyan_bright`  (ANSI-14, selected-match) `:bold:underlined`
240/// - `info`    → `shadow0`      (the dim ANSI-8 tier)
241/// - `prompt`  → `aurora_green` (base0B, the prompt)
242/// - `pointer` → `green_bright` (the cursor/green signature)
243/// - `marker`  → `solar_magenta`(base0E, multi-select)
244/// - `spinner` → `ice_steel`    (base0D)
245/// - `header`  → `ice_steel`    (base0D)
246/// - `border`  → `shadow0`      (the dim tier)
247/// - `query`   → `snow3`        (base07) `:bold`
248#[must_use]
249pub fn render_skim() -> String {
250    let p = VellumPalette::vellum();
251    // (key, token, attr-suffix) — order is the skim-tab authored order.
252    let rows: [(&str, &str, &str); 14] = [
253        ("fg", "snow1", ""),
254        ("bg", "night0", ""),
255        ("hl", "ice_cyan", ":bold:underlined"),
256        ("fg+", "snow3", ":bold"),
257        ("bg+", "night2", ""),
258        ("hl+", "cyan_bright", ":bold:underlined"),
259        ("info", "shadow0", ""),
260        ("prompt", "aurora_green", ""),
261        ("pointer", "green_bright", ""),
262        ("marker", "solar_magenta", ""),
263        ("spinner", "ice_steel", ""),
264        ("header", "ice_steel", ""),
265        ("border", "shadow0", ""),
266        ("query", "snow3", ":bold"),
267    ];
268    rows.iter()
269        .map(|(key, token, attr)| format!("{key}:{}{attr}", tok(&p, token)))
270        .collect::<Vec<_>>()
271        .join(",")
272}
273
274// ─── escriba theme lisp ──────────────────────────────────────────────────────
275
276/// A `(defhighlight …)` row — a group plus the typed token roles it paints.
277/// `link` is mutually exclusive with the colour/attr fields (escriba's
278/// `HighlightSpec` honours `link` first).
279struct HiRow {
280    group: &'static str,
281    /// fg token name (resolved through `VellumPalette::get`), or `""`.
282    fg: &'static str,
283    /// bg token name, or `""`.
284    bg: &'static str,
285    bold: bool,
286    italic: bool,
287    /// `:link "<group>"` — when set, colour fields are skipped.
288    link: &'static str,
289}
290
291impl HiRow {
292    const fn fg(group: &'static str, fg: &'static str) -> Self {
293        Self {
294            group,
295            fg,
296            bg: "",
297            bold: false,
298            italic: false,
299            link: "",
300        }
301    }
302    const fn bg(group: &'static str, bg: &'static str) -> Self {
303        Self {
304            group,
305            fg: "",
306            bg,
307            bold: false,
308            italic: false,
309            link: "",
310        }
311    }
312    const fn fg_bg(group: &'static str, fg: &'static str, bg: &'static str) -> Self {
313        Self {
314            group,
315            fg,
316            bg,
317            bold: false,
318            italic: false,
319            link: "",
320        }
321    }
322    const fn link(group: &'static str, link: &'static str) -> Self {
323        Self {
324            group,
325            fg: "",
326            bg: "",
327            bold: false,
328            italic: false,
329            link,
330        }
331    }
332    const fn b(mut self) -> Self {
333        self.bold = true;
334        self
335    }
336    const fn i(mut self) -> Self {
337        self.italic = true;
338        self
339    }
340}
341
342/// Lowercase, `#`-prefixed six-char hex — escriba's `defpalette` /
343/// `defhighlight` wire format (the hand-authored `vellum.lisp` uses
344/// lowercase hex).
345fn lc_hex(p: &VellumPalette, name: &str) -> String {
346    let h = tok(p, name); // "#RRGGBB" uppercase
347    format!("#{}", h[1..].to_ascii_lowercase())
348}
349
350/// A typed `(defX :k v …)` line writer — one keyword/value pair per
351/// emitted slot, joined with single spaces, wrapped in parens. Keeps the
352/// emission off ad-hoc concatenation (TYPED EMISSION).
353fn lisp_form(head: &str, kvs: &[(&str, String)]) -> String {
354    use std::fmt::Write as _;
355    let mut s = String::new();
356    write!(s, "({head}").expect("write to String");
357    for (k, v) in kvs {
358        write!(s, " {k} {v}").expect("write to String");
359    }
360    s.push(')');
361    s
362}
363
364/// Render the escriba Vellum theme `*.lisp` — a `(deftheme …)` +
365/// `(defpalette …)` + the `(defhighlight …)` forms over escriba's
366/// `CANONICAL_GROUPS`, all sourced from the BORN `VellumPalette`.
367///
368/// Mirrors `pleme-io/escriba/escriba/configs/vellum.lisp` so escriba can
369/// later `include` the generated file. Every colour resolves through a
370/// typed token; the diff backgrounds use the byte-exact GLASS blend
371/// tokens (`*_glass`), so they can never drift from the blend recipes.
372#[must_use]
373pub fn render_escriba_lisp() -> String {
374    use std::fmt::Write as _;
375    let p = VellumPalette::vellum();
376
377    let mut out = String::new();
378    out.push_str(
379        "; escriba — Vellum theme (the fleet default)\n\
380         ; Generated by ishou-render::vellum::render_escriba_lisp — DO NOT EDIT\n\
381         ; Source of truth: pleme-io/ishou/crates/ishou-tokens/src/vellum.rs\n\
382         ; Vellum — warm aged-paper Nord-matte; every hex is a BORN ishou token.\n\n",
383    );
384
385    // ─ Theme select ─
386    writeln!(
387        out,
388        "{}",
389        lisp_form("deftheme", &[(":preset", "\"vellum\"".to_string())])
390    )
391    .expect("write");
392    out.push('\n');
393
394    // ─ Palette — base16 slots, lowercase hex. base02 carries night2
395    //   (the escriba lisp's selected-line surface), NOT the violet
396    //   selection blend — matching the hand-authored file. ─
397    let palette_kvs: Vec<(&str, String)> = vec![
398        (":name", "\"vellum\"".to_string()),
399        (":base00", format!("\"{}\"", lc_hex(&p, "night0"))),
400        (":base01", format!("\"{}\"", lc_hex(&p, "night1"))),
401        (":base02", format!("\"{}\"", lc_hex(&p, "night2"))),
402        (":base03", format!("\"{}\"", lc_hex(&p, "shadow1"))),
403        (":base04", format!("\"{}\"", lc_hex(&p, "snow0"))),
404        (":base05", format!("\"{}\"", lc_hex(&p, "snow1"))),
405        (":base06", format!("\"{}\"", lc_hex(&p, "snow2"))),
406        (":base07", format!("\"{}\"", lc_hex(&p, "snow3"))),
407        (":base08", format!("\"{}\"", lc_hex(&p, "aurora_red"))),
408        (":base09", format!("\"{}\"", lc_hex(&p, "ember"))),
409        (":base0a", format!("\"{}\"", lc_hex(&p, "first_light"))),
410        (":base0b", format!("\"{}\"", lc_hex(&p, "aurora_green"))),
411        (":base0c", format!("\"{}\"", lc_hex(&p, "ice_cyan"))),
412        (":base0d", format!("\"{}\"", lc_hex(&p, "ice_steel"))),
413        (":base0e", format!("\"{}\"", lc_hex(&p, "solar_magenta"))),
414        (":base0f", format!("\"{}\"", lc_hex(&p, "dusk_bronze"))),
415    ];
416    writeln!(out, "{}", lisp_form("defpalette", &palette_kvs)).expect("write");
417    out.push('\n');
418
419    // ─ Highlights — group → token-role map. Mirrors the hand-authored
420    //   vellum.lisp group set (escriba's CANONICAL_GROUPS + the
421    //   tree-sitter overrides). ─
422    let rows: &[HiRow] = &[
423        // Syntax
424        HiRow::fg_bg("Normal", "snow1", "night0"),
425        HiRow::fg("Comment", "shadow1").i(),
426        HiRow::fg("String", "aurora_green"),
427        HiRow::fg("Number", "solar_magenta"),
428        HiRow::fg("Boolean", "solar_magenta"),
429        HiRow::fg("Function", "ice_steel").b(),
430        HiRow::fg("Keyword", "solar_magenta").i(),
431        HiRow::fg("Statement", "solar_magenta"),
432        HiRow::fg("Conditional", "solar_magenta"),
433        HiRow::fg("Repeat", "solar_magenta"),
434        HiRow::fg("Operator", "solar_magenta"),
435        HiRow::fg("Type", "first_light"),
436        HiRow::fg("Structure", "first_light"),
437        HiRow::fg("Identifier", "snow1"),
438        HiRow::fg("Constant", "ember"),
439        HiRow::fg("PreProc", "ember"),
440        HiRow::fg("Macro", "ember"),
441        HiRow::fg("Special", "first_light"),
442        // UI
443        HiRow::bg("CursorLine", "night1"),
444        HiRow::bg("CursorColumn", "night1"),
445        HiRow::fg("LineNr", "shadow1"),
446        HiRow::bg("SignColumn", "night0"),
447        HiRow::bg("Visual", "night2"),
448        HiRow::bg("VisualNOS", "night2"),
449        HiRow::fg_bg("Search", "night0", "first_light"),
450        HiRow::fg_bg("IncSearch", "night0", "ember").b(),
451        HiRow::fg("MatchParen", "ember").b(),
452        HiRow::fg_bg("StatusLine", "snow1", "night1"),
453        HiRow::fg_bg("StatusLineNC", "shadow1", "night0"),
454        HiRow::fg_bg("TabLine", "shadow1", "night0"),
455        HiRow::bg("TabLineFill", "night0"),
456        HiRow::fg_bg("TabLineSel", "night0", "ice_cyan").b(),
457        HiRow::fg("VertSplit", "night3"),
458        HiRow::fg_bg("Pmenu", "snow1", "night1"),
459        HiRow::fg_bg("PmenuSel", "night0", "ice_cyan").b(),
460        HiRow::bg("PmenuSbar", "night1"),
461        HiRow::bg("PmenuThumb", "shadow1"),
462        HiRow::fg_bg("NormalFloat", "snow1", "night1"),
463        HiRow::fg_bg("FloatBorder", "ice_steel", "night1"),
464        // Diagnostics
465        HiRow::fg("DiagnosticError", "aurora_red").b(),
466        HiRow::fg("DiagnosticWarn", "first_light"),
467        HiRow::fg("DiagnosticInfo", "ice_cyan"),
468        HiRow::fg("DiagnosticHint", "aurora_green"),
469        // Git (gitsigns parity) + diff backgrounds (the GLASS blends)
470        HiRow::fg("GitSignsAdd", "aurora_green"),
471        HiRow::fg("GitSignsChange", "first_light"),
472        HiRow::fg("GitSignsDelete", "aurora_red"),
473        HiRow::bg("DiffAdd", "green_glass"),
474        HiRow::bg("DiffChange", "amber_glass"),
475        HiRow::bg("DiffDelete", "red_glass"),
476        HiRow::bg("DiffText", "steel_glass"),
477        // Tree-sitter semantic overrides
478        HiRow::link("@function.call", "Function"),
479        HiRow::link("@variable", "Identifier"),
480        HiRow::fg("@parameter", "snow1").i(),
481        HiRow::fg("@comment.todo", "first_light").b(),
482        HiRow::fg("@comment.note", "ice_cyan").b(),
483        HiRow::fg("@comment.warning", "ember").b(),
484    ];
485
486    for r in rows {
487        let mut kvs: Vec<(&str, String)> = vec![(":group", format!("\"{}\"", r.group))];
488        if r.link.is_empty() {
489            if !r.fg.is_empty() {
490                kvs.push((":fg", format!("\"{}\"", lc_hex(&p, r.fg))));
491            }
492            if !r.bg.is_empty() {
493                kvs.push((":bg", format!("\"{}\"", lc_hex(&p, r.bg))));
494            }
495            if r.bold {
496                kvs.push((":bold", "#t".to_string()));
497            }
498            if r.italic {
499                kvs.push((":italic", "#t".to_string()));
500            }
501        } else {
502            kvs.push((":link", format!("\"{}\"", r.link)));
503        }
504        writeln!(out, "{}", lisp_form("defhighlight", &kvs)).expect("write");
505    }
506
507    out
508}
509
510#[cfg(test)]
511mod tests {
512
513    /// The nix attrset and the YAML scheme are the SAME palette.
514    ///
515    /// They are two renders of one source, and the whole point of the nix
516    /// one is that consumers can switch to it — so a divergence would be a
517    /// silent colour change on every fleet desktop, visible to nobody until
518    /// someone noticed their terminal looked wrong.
519    #[test]
520    fn the_nix_render_carries_the_same_palette_as_the_yaml() {
521        let yaml = super::render_base16();
522        let nixs = super::render_base16_nix();
523        for (slot, rgb) in VellumPalette::vellum().base16() {
524            let hex = super::slot_hex(rgb);
525            assert!(
526                yaml.contains(&format!("{slot}: {hex}")),
527                "yaml missing {slot}"
528            );
529            assert!(
530                nixs.contains(&format!("{slot} = \"{hex}\";")),
531                "nix missing {slot}"
532            );
533        }
534    }
535
536    /// The scheme's IDENTITY survives the YAML -> attrset move.
537    ///
538    /// base16.nix defaults every absent metadata field — `scheme`/`author`
539    /// to "untitled", `variant` to "unspecified" — and those strings are
540    /// interpolated into generated artefact NAMES (`base16-${slug}`,
541    /// `"Base16 ${scheme-name}"`, `${slug}-gnome-shell-theme`). So a
542    /// metadata-less attrset is not a smaller scheme; it is an ANONYMOUS
543    /// one, byte-identical in colour and renaming a pile of store paths.
544    /// Nothing breaks, which is exactly why nothing would have caught it.
545    #[test]
546    fn the_nix_render_keeps_the_scheme_identity_not_just_the_colours() {
547        let out = super::render_base16_nix();
548        for (k, v) in [
549            ("system", "base16"),
550            ("name", "Vellum"),
551            ("author", "pleme-io (ishou)"),
552            ("variant", "dark"),
553            ("slug", "vellum"),
554        ] {
555            assert!(
556                out.contains(&format!("{k} = \"{v}\";")),
557                "missing {k}; without it base16.nix names the scheme \"untitled\""
558            );
559        }
560    }
561
562    /// stylix decides how to read a scheme with
563    /// `is-not-parsed = builtins.isAttrs scheme && !(scheme ? "yaml")`.
564    /// The whole IFD-avoidance rests on this file being a bare attrset with
565    /// no `yaml` key — a `yaml = ` anywhere in it would silently restore
566    /// the readFile path and the linux build with it.
567    #[test]
568    fn the_nix_render_is_a_bare_attrset_with_no_yaml_key() {
569        let out = super::render_base16_nix();
570        let body: String = out.lines().filter(|l| !l.starts_with('#')).collect();
571        assert!(
572            body.trim_start().starts_with('{'),
573            "must be an attrset: {body}"
574        );
575        assert!(
576            !body.contains("yaml"),
577            "a `yaml` key would put stylix back on the IFD path"
578        );
579        // A lambda would make it `import path { … }`, not `import path`.
580        assert!(!body.contains(':'), "must not be a function: {body}");
581    }
582    use super::*;
583
584    #[test]
585    fn base16_is_deterministic() {
586        assert_eq!(render_base16(), render_base16());
587    }
588
589    #[test]
590    fn skim_string_is_deterministic() {
591        assert_eq!(render_skim(), render_skim());
592    }
593
594    #[test]
595    fn skim_string_matches_the_authored_vellum_string() {
596        // The exact byte sequence `pleme-io/skim-tab::NORD_COLORS` ships
597        // (joined with `,` — no trailing newline). Generated from the
598        // typed Vellum tokens.
599        let expected = "\
600fg:#E2DBC8,\
601bg:#16140E,\
602hl:#94BBB8:bold:underlined,\
603fg+:#F4EFE2:bold,\
604bg+:#2B2820,\
605hl+:#A6CBC8:bold:underlined,\
606info:#6E6857,\
607prompt:#A9BB8C,\
608pointer:#ADD7A3,\
609marker:#B8A1B9,\
610spinner:#99AABE,\
611header:#99AABE,\
612border:#6E6857,\
613query:#F4EFE2:bold";
614        assert_eq!(render_skim(), expected, "skim --color string drifted");
615    }
616
617    #[test]
618    fn escriba_lisp_is_deterministic() {
619        assert_eq!(render_escriba_lisp(), render_escriba_lisp());
620    }
621
622    #[test]
623    fn escriba_lisp_has_palette_and_key_highlights() {
624        let out = render_escriba_lisp();
625        // defpalette base00 = night0, lowercase.
626        assert!(
627            out.contains("(defpalette :name \"vellum\" :base00 \"#16140e\""),
628            "defpalette base00 missing/drifted:\n{out}"
629        );
630        // A handful of canonical groups, exact emitted lines.
631        for line in [
632            "(defhighlight :group \"Normal\" :fg \"#e2dbc8\" :bg \"#16140e\")",
633            "(defhighlight :group \"Comment\" :fg \"#90897b\" :italic #t)",
634            "(defhighlight :group \"String\" :fg \"#a9bb8c\")",
635            "(defhighlight :group \"Function\" :fg \"#99aabe\" :bold #t)",
636            "(defhighlight :group \"DiffAdd\" :bg \"#4d543e\")",
637            "(defhighlight :group \"@function.call\" :link \"Function\")",
638        ] {
639            assert!(out.contains(line), "missing line `{line}`\n{out}");
640        }
641        // Every CANONICAL group label appears as an emitted group.
642        for g in [
643            "Normal",
644            "Comment",
645            "String",
646            "Number",
647            "Boolean",
648            "Function",
649            "Keyword",
650            "Statement",
651            "Type",
652            "Constant",
653            "Special",
654            "Visual",
655            "Search",
656            "DiagnosticError",
657            "GitSignsAdd",
658        ] {
659            assert!(
660                out.contains(&format!(":group \"{g}\"")),
661                "missing group {g}\n{out}"
662            );
663        }
664    }
665
666    #[test]
667    fn base16_has_all_16_slots_lowercase_unprefixed() {
668        let out = render_base16();
669        for slot in [
670            "base00", "base01", "base02", "base03", "base04", "base05", "base06", "base07",
671            "base08", "base09", "base0A", "base0B", "base0C", "base0D", "base0E", "base0F",
672        ] {
673            assert!(out.contains(slot), "missing {slot}");
674        }
675        // base00 is night0 (#16140E → 16140e), unprefixed lc.
676        assert!(out.contains("16140e"), "base00 night0 missing:\n{out}");
677        // No leading `#` on any value (would break GTK theme-gen).
678        assert!(!out.contains(": #"), "found prefixed hex:\n{out}");
679    }
680
681    #[test]
682    fn base16_slots_match_the_born_palette() {
683        let p = VellumPalette::vellum();
684        let out = render_base16();
685        for (slot, rgb) in p.base16() {
686            let want = format!("{slot}: {}", slot_hex(rgb));
687            assert!(
688                out.contains(&want),
689                "slot {slot} drifted; want `{want}`\n{out}"
690            );
691        }
692    }
693
694    #[test]
695    fn base24_has_the_real_brights() {
696        let out = render_base24();
697        // base14 = green_bright (#ADD7A3), base12 = red_bright (#D49088).
698        assert!(
699            out.contains("base14: add7a3"),
700            "base14 green_bright:\n{out}"
701        );
702        assert!(out.contains("base12: d49088"), "base12 red_bright:\n{out}");
703        assert!(out.contains("system: base24"), "wrong system tag:\n{out}");
704    }
705
706    #[test]
707    fn svg_palette_is_deterministic_and_covers_every_token() {
708        let a = render_svg_palette();
709        assert_eq!(a, render_svg_palette());
710        let p = VellumPalette::vellum();
711        for (name, _) in p.entries() {
712            assert!(a.contains(name), "svg missing token {name}");
713        }
714    }
715}