makeover 2.8.0

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation

makeover

Shared theme loading for the make-family apps. Parses theme metadata and color values from .toml files on disk, resolves them into intent-based tokens, and derives the rest perceptually (OKLab) with WCAG contrast checks.

The crate ships the theme set it loads, in themes/. Consumers get working themes from a clean checkout without depending on any sibling repo.

Used by MNW server, GoingsOn, Balanced Breakfast, audiofiles, and Alloy.

The crate ships 31 themes. bundled_themes_dir() hands back the directory; embedded_themes() hands back the same set as (id, toml_source) pairs with no filesystem path involved, for consumers that embed at compile time or bundle assets at build time.

Usage

use makeover::{load_theme, list_themes_from_dirs, bundled_themes_dir};
use std::path::PathBuf;

// Set up theme directories (later entries override earlier ones)
let bundled = bundled_themes_dir().expect("makeover ships themes/");
let custom = PathBuf::from("/path/to/user/custom-themes");
let dirs = vec![(bundled, false), (custom, true)];

// List available themes (sorted by name)
let themes = list_themes_from_dirs(&dirs);
for t in &themes {
    println!("{} ({}, {})", t.name, t.id, t.variant);
}

// Load a specific theme by ID
let theme = load_theme(&dirs, "catppuccin-mocha").unwrap();
println!("Name: {}", theme.meta.name);         // "Catppuccin Mocha"
println!("Variant: {}", theme.meta.variant);     // "dark"
println!("BG: {}", theme.colors["surface.page"]);        // "#1e1e2e"

// Build-from-source fallback: the themes this crate ships
if let Some(dir) = bundled_themes_dir() {
    // dir = <makeover checkout>/themes
}

Theme File Format

Colors are declared by intent (the role a color plays), not by hue. See themes/ for the 31 bundled themes.

[meta]
name = "Nord"             # Display name; falls back to the filename
variant = "dark"          # "dark", "light", or "high-contrast" (default: "dark")

[surface]                 # Container backgrounds by elevation
page = "#2e3440"
raised = "#3b4252"
sunken = "#434c5e"
overlay = "#3b4252"

[content]                 # Text and ink by emphasis
primary = "#d8dee9"
secondary = "#e5e9f0"
muted = "#616e88"

[action]                  # Interactive / brand color
primary = "#81a1c1"

[status]                  # State semantics
danger = "#bf616a"
success = "#a3be8c"
warning = "#ebcb8b"
info = "#88c0d0"

[line]
border = "#4c566a"

[category]                # Optional: tag and label colors

Derived tokens

Interactive states are not authored. resolve() derives them perceptually in OKLab, so theme files stay small and every consuming app derives them identically rather than each recomputing its own: action-hover, content-on-action, focus-ring, hover-surface, border-strong, the translucent overlay scrim, the bevel-light / bevel-dark pair that a raised surface is lit and shadowed with, and surface-well, the content surface cut into a raised one.

Each is emitted only when the intents it reads from are present, so a partial theme resolves to a partial token set rather than failing.

surface-well is the one derivation that inverts by theme: a well is lighter than its face on a light theme and darker on a dark one. The direction is read off content, not off the theme's variant field, so a theme whose text is dark resolves as a light theme whatever its metadata says.

Bevel and well geometry is not derived here. Thickness, radius, inset depth and which side takes which edge are the consuming app's, and only the tones are shared.

intent_css_vars() renders a resolved theme as a :root { … } block for web consumers; native consumers read RGB tuples off the same resolved tokens.

Terminals without truecolor

ANSI_16, ANSI_256 and ANSI_240 are the palettes a terminal addresses by index, and quantize maps a theme color onto the nearest entry of any of them in OKLab. quantize_against does the same for a color that has to stay legible against a known background, such as a border on a page, and it is the wrong choice for a pair of colors that must also stay apart from each other, because it optimizes each one against the background alone.

Prefer ANSI_240, the 6x6x6 cube and the gray ramp. Every emulator lets the user repaint the low sixteen, so a match landing there is a match against a color that may have moved. Add ANSI_240_OFFSET to the returned index to get the one the terminal wants.

Color depth decides how much of a theme survives. Two tones a hair apart in 24-bit round onto one entry at 256 and onto the same gray at 16.

The other direction is ansi_intent(index, variant): which authored intent paints ANSI slot index, for a program that owns a terminal palette rather than one drawing into somebody else's. Twelve slots are chromatic and fixed (slot 1 is the theme's danger tone in either polarity); the four achromatic ones invert with it, because "black" and "white" mean the darkest and lightest tone the theme has, and which intent that is flips between a light theme and a dark one. A bare Linux console, a terminal emulator and a generated config that all consult this agree on what red means; they disagreed for as long as each kept its own table.

Theme ID

The theme ID is the filename without .toml (e.g., catppuccin-mocha.toml has ID catppuccin-mocha). IDs must contain only alphanumeric characters, hyphens, and underscores. Path traversal characters are rejected.

API

Function Description
list_themes_from_dirs(dirs) Scan directories for .toml files, return sorted Vec<ThemeMeta>
load_theme(dirs, id) Load a theme by ID, returning ThemeColors (metadata + color map)
find_theme_path(dirs, id) Find the file path for a theme ID (highest-priority directory wins)
parse_meta(id, table, is_custom) Parse [meta] from a TOML table into ThemeMeta
extract_colors(table) Flatten color sections into a HashMap<String, String>
validate_theme_id(id) Check that an ID contains only safe characters
bundled_themes_dir() The themes/ directory this crate ships, for build-from-source fallback
embedded_themes() The shipped themes as (id, toml_source) pairs, embedded at compile time (no path needed)
parse_theme_str(id, source, is_custom) Parse a theme from a string, for use with embedded_themes()
resolve(theme) Resolve authored intents into the full token set, deriving interactive states
intent_css_vars(tokens) Render resolved tokens as a :root { … } CSS block
ansi_intent(index, variant) Which authored intent paints ANSI slot 0-15, for a program filling a terminal palette

Choosing a theme

Loading a theme file was always shared; choosing one was not, and every app re-rolled it. These types are the shared half.

Item Purpose
Variant light / dark / high-contrast, as a value. ThemeMeta::kind() reads it
ThemeSelection Follow or Fixed(id) — what the user chose, not what is rendered
ThemeSelection::parse(stored) Read a stored value from any store; absent or "system" is Follow
ThemeSelection::as_str() The string to persist, whatever the store is
ThemeSelection::resolve(ambient, defaults, available) Turn a selection into a theme ID that exists
ThemeDefaults::new(light, dark) The app's own fallbacks, one per ambient mode
ThemeDirs Build the search path with the tiers named

The store stays the app's: localStorage, a config table, a TOML file. What is shared is the string it holds and what that string means, so "system" means the same thing in all of them, and the key is theme everywhere.

resolve picks by Variant, so an app follows the system into any installed theme of the right kind rather than into a hardcoded pair. A Fixed ID whose theme has been deleted falls back rather than being handed back to fail later.

let selection = ThemeSelection::parse(store.get("theme"));
let defaults = ThemeDefaults::new("flatwhite", "nord");
let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs));

Directory Priority

list_themes_from_dirs and load_theme accept a list of (PathBuf, bool) pairs. Later directories override earlier ones by theme ID. The bool marks whether the directory contains user-custom themes (is_custom on ThemeMeta).

Build it with ThemeDirs rather than by hand. The tiers are named, so the order is not the caller's to get backwards:

let dirs = ThemeDirs::new()
    .bundled(bundled_themes_dir())
    .system(Some("/usr/share/myapp/themes".into()))
    .custom(config_dir.map(|c| c.join("themes")))
    .build();

The user's themes win, then the system's, then the app's own. Directories that do not exist are dropped, so every tier can be offered unconditionally. Passing a hand-built vector still works; one app had it inverted, with a comment claiming the opposite of what the loader does, which is what this replaces.

License

MIT.

The themes in themes/ adapt palettes from third-party projects (Catppuccin, Dracula, Nord, gruvbox, Tokyo Night, Rosé Pine, and others). Each upstream, its license, and the exact copyright line that license requires reproducing are recorded in THIRD-PARTY-NOTICES.md, verified against the upstream LICENSE files themselves. Every adapted theme file also carries that information in a header comment, so credit travels with the file.

If an attribution is wrong or you would prefer your work not be included, write to info@makenot.work and it will be corrected or removed.