# 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 ```rust 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 = /themes } ``` ## Theme File Format Colors are declared by **intent** (the role a color plays), not by hue. See `themes/` for the 31 bundled themes. ```toml [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 hover, active, selection, row striping, and contrast pairings perceptually in OKLab, so theme files stay small and every consuming app derives them identically rather than each recomputing its own. `intent_css_vars()` renders a resolved theme as a `:root { … }` block for web consumers; native consumers read RGB tuples off the same resolved tokens. ### 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` | | `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` | | `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 | ## 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. ```rust 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: ```rust 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](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.