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. Every
217/// hand-authored skim `--color` string on the fleet — both of
218/// `skim-tab`'s colour consts, both branches of `nix/lib/skim-theme.nix`
219/// — writes uppercase hex, so the generated string writes it too and
220/// stays byte-comparable against them.
221fn tok(p: &VellumPalette, name: &str) -> String {
222    p.get(name)
223        .unwrap_or_else(|| panic!("vellum token `{name}` missing"))
224        .hex()
225}
226
227/// Render the skim/fzf `--color=k:v,…` string from the BORN `VellumPalette`.
228///
229/// Every colour resolves from a typed token; the `:bold`/`:underlined`
230/// attribute suffixes are the picker's fixed UX contract.
231///
232/// # What this string is, and what it is NOT (measured 2026-08-09)
233///
234/// It is byte-equal to `skim-tab::VELLUM_COLORS` and to the `vellum`
235/// branch of `nix/lib/skim-theme.nix`. **Both of those are saved-but-
236/// dormant.** Neither is what a picker paints: `skim-tab::base_options`
237/// passes `NORD_COLORS` (real Nord, `skim-tab/src/lib.rs:32`, set at
238/// `:196`) and `skim-theme.nix` selects `theme = "nord"`. All fourteen
239/// values differ between the two.
240///
241/// So this renderer generates a palette **nothing currently consumes**,
242/// and the earlier claim here — "byte-equivalent to `NORD_COLORS`" — was
243/// false from the moment skim-tab flipped to Nord (`63b74c7`, "pickers:
244/// classic Nord palette (ghostty parity), Vellum saved"). Do not read
245/// this function as parity with the shipped pickers. There is no parity
246/// to read, and no code path by which a change here reaches one: skim-tab
247/// has **no ishou dependency at all**.
248///
249/// # Orientation: three palettes render in one frost prompt
250///
251/// | Surface | Palette | Source |
252/// |---|---|---|
253/// | frost completion menu | Vellum | `frost-zle/src/lib.rs:203-211`, hand-typed RGB |
254/// | skim-tab pickers (Ctrl-R/T/F, Tab) | real Nord | `skim-tab::NORD_COLORS` |
255/// | `SKIM_DEFAULT_OPTIONS` | Borealis | frostmourne `lisp/61-tools-skim.lisp` — exported, but dead for the pickers: skim-tab drives the skim *library* with an explicit `--color`, so this only ever colours a bare `sk` |
256///
257/// # The destination
258///
259/// One authored ramp, projected. That is `ishou-pente`'s M0, whose exit
260/// criterion is stated as a **deletion** (`crates/ishou-pente/src/lib.rs`):
261/// `nix/lib/skim-theme.nix` and skim-tab's colour consts go away, and both
262/// consumers read the generated string (`nix run .#skim-vellum`) instead.
263/// Until skim-tab grows that dependency, the honest guard below pins this
264/// renderer's own bytes and claims nothing about skim-tab. **Do not
265/// "restore" parity by copying `NORD_COLORS` into this crate** — a copied
266/// constant asserted against itself is a green test that proves nothing,
267/// which is exactly the defect this comment replaces.
268///
269/// Role → token:
270/// - `fg`      → `snow1`        (base05, the text fg)
271/// - `bg`      → `night0`       (base00, the parchment ground)
272/// - `hl`      → `ice_cyan`     (base0C, match highlight) `:bold:underlined`
273/// - `fg+`     → `snow3`        (base07, selected-line fg) `:bold`
274/// - `bg+`     → `night2`       (the selected-line surface)
275/// - `hl+`     → `cyan_bright`  (ANSI-14, selected-match) `:bold:underlined`
276/// - `info`    → `shadow0`      (the dim ANSI-8 tier)
277/// - `prompt`  → `aurora_green` (base0B, the prompt)
278/// - `pointer` → `green_bright` (the cursor/green signature)
279/// - `marker`  → `solar_magenta`(base0E, multi-select)
280/// - `spinner` → `ice_steel`    (base0D)
281/// - `header`  → `ice_steel`    (base0D)
282/// - `border`  → `shadow0`      (the dim tier)
283/// - `query`   → `snow3`        (base07) `:bold`
284#[must_use]
285pub fn render_skim() -> String {
286    let p = VellumPalette::vellum();
287    // (key, token, attr-suffix) — order is the skim-tab authored order.
288    let rows: [(&str, &str, &str); 14] = [
289        ("fg", "snow1", ""),
290        ("bg", "night0", ""),
291        ("hl", "ice_cyan", ":bold:underlined"),
292        ("fg+", "snow3", ":bold"),
293        ("bg+", "night2", ""),
294        ("hl+", "cyan_bright", ":bold:underlined"),
295        ("info", "shadow0", ""),
296        ("prompt", "aurora_green", ""),
297        ("pointer", "green_bright", ""),
298        ("marker", "solar_magenta", ""),
299        ("spinner", "ice_steel", ""),
300        ("header", "ice_steel", ""),
301        ("border", "shadow0", ""),
302        ("query", "snow3", ":bold"),
303    ];
304    rows.iter()
305        .map(|(key, token, attr)| format!("{key}:{}{attr}", tok(&p, token)))
306        .collect::<Vec<_>>()
307        .join(",")
308}
309
310// ─── escriba theme lisp ──────────────────────────────────────────────────────
311
312/// A `(defhighlight …)` row — a group plus the typed token roles it paints.
313/// `link` is mutually exclusive with the colour/attr fields (escriba's
314/// `HighlightSpec` honours `link` first).
315struct HiRow {
316    group: &'static str,
317    /// fg token name (resolved through `VellumPalette::get`), or `""`.
318    fg: &'static str,
319    /// bg token name, or `""`.
320    bg: &'static str,
321    bold: bool,
322    italic: bool,
323    /// `:link "<group>"` — when set, colour fields are skipped.
324    link: &'static str,
325}
326
327impl HiRow {
328    const fn fg(group: &'static str, fg: &'static str) -> Self {
329        Self {
330            group,
331            fg,
332            bg: "",
333            bold: false,
334            italic: false,
335            link: "",
336        }
337    }
338    const fn bg(group: &'static str, bg: &'static str) -> Self {
339        Self {
340            group,
341            fg: "",
342            bg,
343            bold: false,
344            italic: false,
345            link: "",
346        }
347    }
348    const fn fg_bg(group: &'static str, fg: &'static str, bg: &'static str) -> Self {
349        Self {
350            group,
351            fg,
352            bg,
353            bold: false,
354            italic: false,
355            link: "",
356        }
357    }
358    const fn link(group: &'static str, link: &'static str) -> Self {
359        Self {
360            group,
361            fg: "",
362            bg: "",
363            bold: false,
364            italic: false,
365            link,
366        }
367    }
368    const fn b(mut self) -> Self {
369        self.bold = true;
370        self
371    }
372    const fn i(mut self) -> Self {
373        self.italic = true;
374        self
375    }
376}
377
378/// Lowercase, `#`-prefixed six-char hex — escriba's `defpalette` /
379/// `defhighlight` wire format (the hand-authored `vellum.lisp` uses
380/// lowercase hex).
381fn lc_hex(p: &VellumPalette, name: &str) -> String {
382    let h = tok(p, name); // "#RRGGBB" uppercase
383    format!("#{}", h[1..].to_ascii_lowercase())
384}
385
386/// A typed `(defX :k v …)` line writer — one keyword/value pair per
387/// emitted slot, joined with single spaces, wrapped in parens. Keeps the
388/// emission off ad-hoc concatenation (TYPED EMISSION).
389fn lisp_form(head: &str, kvs: &[(&str, String)]) -> String {
390    use std::fmt::Write as _;
391    let mut s = String::new();
392    write!(s, "({head}").expect("write to String");
393    for (k, v) in kvs {
394        write!(s, " {k} {v}").expect("write to String");
395    }
396    s.push(')');
397    s
398}
399
400/// Render the escriba Vellum theme `*.lisp` — a `(deftheme …)` +
401/// `(defpalette …)` + the `(defhighlight …)` forms over escriba's
402/// `CANONICAL_GROUPS`, all sourced from the BORN `VellumPalette`.
403///
404/// Mirrors `pleme-io/escriba/escriba/configs/vellum.lisp` so escriba can
405/// later `include` the generated file. Every colour resolves through a
406/// typed token; the diff backgrounds use the byte-exact GLASS blend
407/// tokens (`*_glass`), so they can never drift from the blend recipes.
408#[must_use]
409pub fn render_escriba_lisp() -> String {
410    use std::fmt::Write as _;
411    let p = VellumPalette::vellum();
412
413    let mut out = String::new();
414    out.push_str(
415        "; escriba — Vellum theme (the fleet default)\n\
416         ; Generated by ishou-render::vellum::render_escriba_lisp — DO NOT EDIT\n\
417         ; Source of truth: pleme-io/ishou/crates/ishou-tokens/src/vellum.rs\n\
418         ; Vellum — warm aged-paper Nord-matte; every hex is a BORN ishou token.\n\n",
419    );
420
421    // ─ Theme select ─
422    writeln!(
423        out,
424        "{}",
425        lisp_form("deftheme", &[(":preset", "\"vellum\"".to_string())])
426    )
427    .expect("write");
428    out.push('\n');
429
430    // ─ Palette — base16 slots, lowercase hex. base02 carries night2
431    //   (the escriba lisp's selected-line surface), NOT the violet
432    //   selection blend — matching the hand-authored file. ─
433    let palette_kvs: Vec<(&str, String)> = vec![
434        (":name", "\"vellum\"".to_string()),
435        (":base00", format!("\"{}\"", lc_hex(&p, "night0"))),
436        (":base01", format!("\"{}\"", lc_hex(&p, "night1"))),
437        (":base02", format!("\"{}\"", lc_hex(&p, "night2"))),
438        (":base03", format!("\"{}\"", lc_hex(&p, "shadow1"))),
439        (":base04", format!("\"{}\"", lc_hex(&p, "snow0"))),
440        (":base05", format!("\"{}\"", lc_hex(&p, "snow1"))),
441        (":base06", format!("\"{}\"", lc_hex(&p, "snow2"))),
442        (":base07", format!("\"{}\"", lc_hex(&p, "snow3"))),
443        (":base08", format!("\"{}\"", lc_hex(&p, "aurora_red"))),
444        (":base09", format!("\"{}\"", lc_hex(&p, "ember"))),
445        (":base0a", format!("\"{}\"", lc_hex(&p, "first_light"))),
446        (":base0b", format!("\"{}\"", lc_hex(&p, "aurora_green"))),
447        (":base0c", format!("\"{}\"", lc_hex(&p, "ice_cyan"))),
448        (":base0d", format!("\"{}\"", lc_hex(&p, "ice_steel"))),
449        (":base0e", format!("\"{}\"", lc_hex(&p, "solar_magenta"))),
450        (":base0f", format!("\"{}\"", lc_hex(&p, "dusk_bronze"))),
451    ];
452    writeln!(out, "{}", lisp_form("defpalette", &palette_kvs)).expect("write");
453    out.push('\n');
454
455    // ─ Highlights — group → token-role map. Mirrors the hand-authored
456    //   vellum.lisp group set (escriba's CANONICAL_GROUPS + the
457    //   tree-sitter overrides). ─
458    let rows: &[HiRow] = &[
459        // Syntax
460        HiRow::fg_bg("Normal", "snow1", "night0"),
461        HiRow::fg("Comment", "shadow1").i(),
462        HiRow::fg("String", "aurora_green"),
463        HiRow::fg("Number", "solar_magenta"),
464        HiRow::fg("Boolean", "solar_magenta"),
465        HiRow::fg("Function", "ice_steel").b(),
466        HiRow::fg("Keyword", "solar_magenta").i(),
467        HiRow::fg("Statement", "solar_magenta"),
468        HiRow::fg("Conditional", "solar_magenta"),
469        HiRow::fg("Repeat", "solar_magenta"),
470        HiRow::fg("Operator", "solar_magenta"),
471        HiRow::fg("Type", "first_light"),
472        HiRow::fg("Structure", "first_light"),
473        HiRow::fg("Identifier", "snow1"),
474        HiRow::fg("Constant", "ember"),
475        HiRow::fg("PreProc", "ember"),
476        HiRow::fg("Macro", "ember"),
477        HiRow::fg("Special", "first_light"),
478        // UI
479        HiRow::bg("CursorLine", "night1"),
480        HiRow::bg("CursorColumn", "night1"),
481        HiRow::fg("LineNr", "shadow1"),
482        HiRow::bg("SignColumn", "night0"),
483        HiRow::bg("Visual", "night2"),
484        HiRow::bg("VisualNOS", "night2"),
485        HiRow::fg_bg("Search", "night0", "first_light"),
486        HiRow::fg_bg("IncSearch", "night0", "ember").b(),
487        HiRow::fg("MatchParen", "ember").b(),
488        HiRow::fg_bg("StatusLine", "snow1", "night1"),
489        HiRow::fg_bg("StatusLineNC", "shadow1", "night0"),
490        HiRow::fg_bg("TabLine", "shadow1", "night0"),
491        HiRow::bg("TabLineFill", "night0"),
492        HiRow::fg_bg("TabLineSel", "night0", "ice_cyan").b(),
493        HiRow::fg("VertSplit", "night3"),
494        HiRow::fg_bg("Pmenu", "snow1", "night1"),
495        HiRow::fg_bg("PmenuSel", "night0", "ice_cyan").b(),
496        HiRow::bg("PmenuSbar", "night1"),
497        HiRow::bg("PmenuThumb", "shadow1"),
498        HiRow::fg_bg("NormalFloat", "snow1", "night1"),
499        HiRow::fg_bg("FloatBorder", "ice_steel", "night1"),
500        // Diagnostics
501        HiRow::fg("DiagnosticError", "aurora_red").b(),
502        HiRow::fg("DiagnosticWarn", "first_light"),
503        HiRow::fg("DiagnosticInfo", "ice_cyan"),
504        HiRow::fg("DiagnosticHint", "aurora_green"),
505        // Git (gitsigns parity) + diff backgrounds (the GLASS blends)
506        HiRow::fg("GitSignsAdd", "aurora_green"),
507        HiRow::fg("GitSignsChange", "first_light"),
508        HiRow::fg("GitSignsDelete", "aurora_red"),
509        HiRow::bg("DiffAdd", "green_glass"),
510        HiRow::bg("DiffChange", "amber_glass"),
511        HiRow::bg("DiffDelete", "red_glass"),
512        HiRow::bg("DiffText", "steel_glass"),
513        // Tree-sitter semantic overrides
514        HiRow::link("@function.call", "Function"),
515        HiRow::link("@variable", "Identifier"),
516        HiRow::fg("@parameter", "snow1").i(),
517        HiRow::fg("@comment.todo", "first_light").b(),
518        HiRow::fg("@comment.note", "ice_cyan").b(),
519        HiRow::fg("@comment.warning", "ember").b(),
520    ];
521
522    for r in rows {
523        let mut kvs: Vec<(&str, String)> = vec![(":group", format!("\"{}\"", r.group))];
524        if r.link.is_empty() {
525            if !r.fg.is_empty() {
526                kvs.push((":fg", format!("\"{}\"", lc_hex(&p, r.fg))));
527            }
528            if !r.bg.is_empty() {
529                kvs.push((":bg", format!("\"{}\"", lc_hex(&p, r.bg))));
530            }
531            if r.bold {
532                kvs.push((":bold", "#t".to_string()));
533            }
534            if r.italic {
535                kvs.push((":italic", "#t".to_string()));
536            }
537        } else {
538            kvs.push((":link", format!("\"{}\"", r.link)));
539        }
540        writeln!(out, "{}", lisp_form("defhighlight", &kvs)).expect("write");
541    }
542
543    out
544}
545
546#[cfg(test)]
547mod tests {
548
549    /// The nix attrset and the YAML scheme are the SAME palette.
550    ///
551    /// They are two renders of one source, and the whole point of the nix
552    /// one is that consumers can switch to it — so a divergence would be a
553    /// silent colour change on every fleet desktop, visible to nobody until
554    /// someone noticed their terminal looked wrong.
555    #[test]
556    fn the_nix_render_carries_the_same_palette_as_the_yaml() {
557        let yaml = super::render_base16();
558        let nixs = super::render_base16_nix();
559        for (slot, rgb) in VellumPalette::vellum().base16() {
560            let hex = super::slot_hex(rgb);
561            assert!(
562                yaml.contains(&format!("{slot}: {hex}")),
563                "yaml missing {slot}"
564            );
565            assert!(
566                nixs.contains(&format!("{slot} = \"{hex}\";")),
567                "nix missing {slot}"
568            );
569        }
570    }
571
572    /// The scheme's IDENTITY survives the YAML -> attrset move.
573    ///
574    /// base16.nix defaults every absent metadata field — `scheme`/`author`
575    /// to "untitled", `variant` to "unspecified" — and those strings are
576    /// interpolated into generated artefact NAMES (`base16-${slug}`,
577    /// `"Base16 ${scheme-name}"`, `${slug}-gnome-shell-theme`). So a
578    /// metadata-less attrset is not a smaller scheme; it is an ANONYMOUS
579    /// one, byte-identical in colour and renaming a pile of store paths.
580    /// Nothing breaks, which is exactly why nothing would have caught it.
581    #[test]
582    fn the_nix_render_keeps_the_scheme_identity_not_just_the_colours() {
583        let out = super::render_base16_nix();
584        for (k, v) in [
585            ("system", "base16"),
586            ("name", "Vellum"),
587            ("author", "pleme-io (ishou)"),
588            ("variant", "dark"),
589            ("slug", "vellum"),
590        ] {
591            assert!(
592                out.contains(&format!("{k} = \"{v}\";")),
593                "missing {k}; without it base16.nix names the scheme \"untitled\""
594            );
595        }
596    }
597
598    /// stylix decides how to read a scheme with
599    /// `is-not-parsed = builtins.isAttrs scheme && !(scheme ? "yaml")`.
600    /// The whole IFD-avoidance rests on this file being a bare attrset with
601    /// no `yaml` key — a `yaml = ` anywhere in it would silently restore
602    /// the readFile path and the linux build with it.
603    #[test]
604    fn the_nix_render_is_a_bare_attrset_with_no_yaml_key() {
605        let out = super::render_base16_nix();
606        let body: String = out.lines().filter(|l| !l.starts_with('#')).collect();
607        assert!(
608            body.trim_start().starts_with('{'),
609            "must be an attrset: {body}"
610        );
611        assert!(
612            !body.contains("yaml"),
613            "a `yaml` key would put stylix back on the IFD path"
614        );
615        // A lambda would make it `import path { … }`, not `import path`.
616        assert!(!body.contains(':'), "must not be a function: {body}");
617    }
618    use super::*;
619
620    #[test]
621    fn base16_is_deterministic() {
622        assert_eq!(render_base16(), render_base16());
623    }
624
625    #[test]
626    fn skim_string_is_deterministic() {
627        assert_eq!(render_skim(), render_skim());
628    }
629
630    /// Pins the Vellum wire bytes this renderer emits.
631    ///
632    /// **What it guards:** a Vellum token edit, a role→token remap, a key
633    /// reorder, or a lost `:bold`/`:underlined` suffix — anything that
634    /// changes the `--color` string `nix run .#skim-vellum` writes into
635    /// the store. That file is the artifact a consumer would source, so
636    /// its bytes are a contract even while no consumer sources it yet.
637    ///
638    /// **What it deliberately does NOT claim:** parity with any palette
639    /// skim-tab actually paints. `skim-tab::base_options` passes
640    /// `NORD_COLORS`; these bytes are `VELLUM_COLORS`, the saved branch.
641    /// This test used to say it pinned `NORD_COLORS` and it never did —
642    /// see `render_skim`'s doc comment for the real relationship and for
643    /// the convergence (`ishou-pente` M0) that would make a genuine
644    /// two-sided parity test possible. Asserting against a `NORD_COLORS`
645    /// copied into this crate would be that lie with extra steps.
646    #[test]
647    fn skim_string_pins_the_born_vellum_wire_bytes() {
648        // Byte-equal (as of 2026-08-09) to `skim-tab::VELLUM_COLORS` and
649        // to skim-theme.nix's `vellum` branch — both dormant. Joined with
650        // `,`, no trailing newline.
651        let expected = "\
652fg:#E2DBC8,\
653bg:#16140E,\
654hl:#94BBB8:bold:underlined,\
655fg+:#F4EFE2:bold,\
656bg+:#2B2820,\
657hl+:#A6CBC8:bold:underlined,\
658info:#6E6857,\
659prompt:#A9BB8C,\
660pointer:#ADD7A3,\
661marker:#B8A1B9,\
662spinner:#99AABE,\
663header:#99AABE,\
664border:#6E6857,\
665query:#F4EFE2:bold";
666        assert_eq!(
667            render_skim(),
668            expected,
669            "Vellum skim --color bytes drifted; the generated \
670             `nix run .#skim-vellum` artifact changed"
671        );
672    }
673
674    #[test]
675    fn escriba_lisp_is_deterministic() {
676        assert_eq!(render_escriba_lisp(), render_escriba_lisp());
677    }
678
679    #[test]
680    fn escriba_lisp_has_palette_and_key_highlights() {
681        let out = render_escriba_lisp();
682        // defpalette base00 = night0, lowercase.
683        assert!(
684            out.contains("(defpalette :name \"vellum\" :base00 \"#16140e\""),
685            "defpalette base00 missing/drifted:\n{out}"
686        );
687        // A handful of canonical groups, exact emitted lines.
688        for line in [
689            "(defhighlight :group \"Normal\" :fg \"#e2dbc8\" :bg \"#16140e\")",
690            "(defhighlight :group \"Comment\" :fg \"#90897b\" :italic #t)",
691            "(defhighlight :group \"String\" :fg \"#a9bb8c\")",
692            "(defhighlight :group \"Function\" :fg \"#99aabe\" :bold #t)",
693            "(defhighlight :group \"DiffAdd\" :bg \"#4d543e\")",
694            "(defhighlight :group \"@function.call\" :link \"Function\")",
695        ] {
696            assert!(out.contains(line), "missing line `{line}`\n{out}");
697        }
698        // Every CANONICAL group label appears as an emitted group.
699        for g in [
700            "Normal",
701            "Comment",
702            "String",
703            "Number",
704            "Boolean",
705            "Function",
706            "Keyword",
707            "Statement",
708            "Type",
709            "Constant",
710            "Special",
711            "Visual",
712            "Search",
713            "DiagnosticError",
714            "GitSignsAdd",
715        ] {
716            assert!(
717                out.contains(&format!(":group \"{g}\"")),
718                "missing group {g}\n{out}"
719            );
720        }
721    }
722
723    #[test]
724    fn base16_has_all_16_slots_lowercase_unprefixed() {
725        let out = render_base16();
726        for slot in [
727            "base00", "base01", "base02", "base03", "base04", "base05", "base06", "base07",
728            "base08", "base09", "base0A", "base0B", "base0C", "base0D", "base0E", "base0F",
729        ] {
730            assert!(out.contains(slot), "missing {slot}");
731        }
732        // base00 is night0 (#16140E → 16140e), unprefixed lc.
733        assert!(out.contains("16140e"), "base00 night0 missing:\n{out}");
734        // No leading `#` on any value (would break GTK theme-gen).
735        assert!(!out.contains(": #"), "found prefixed hex:\n{out}");
736    }
737
738    #[test]
739    fn base16_slots_match_the_born_palette() {
740        let p = VellumPalette::vellum();
741        let out = render_base16();
742        for (slot, rgb) in p.base16() {
743            let want = format!("{slot}: {}", slot_hex(rgb));
744            assert!(
745                out.contains(&want),
746                "slot {slot} drifted; want `{want}`\n{out}"
747            );
748        }
749    }
750
751    #[test]
752    fn base24_has_the_real_brights() {
753        let out = render_base24();
754        // base14 = green_bright (#ADD7A3), base12 = red_bright (#D49088).
755        assert!(
756            out.contains("base14: add7a3"),
757            "base14 green_bright:\n{out}"
758        );
759        assert!(out.contains("base12: d49088"), "base12 red_bright:\n{out}");
760        assert!(out.contains("system: base24"), "wrong system tag:\n{out}");
761    }
762
763    #[test]
764    fn svg_palette_is_deterministic_and_covers_every_token() {
765        let a = render_svg_palette();
766        assert_eq!(a, render_svg_palette());
767        let p = VellumPalette::vellum();
768        for (name, _) in p.entries() {
769            assert!(a.contains(name), "svg missing token {name}");
770        }
771    }
772}