| 1 |
# makeover |
| 2 |
|
| 3 |
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. |
| 4 |
|
| 5 |
The crate ships the theme set it loads, in `themes/`. Consumers get working themes from a clean checkout without depending on any sibling repo. |
| 6 |
|
| 7 |
Used by MNW server, GoingsOn, Balanced Breakfast, audiofiles, and Alloy. |
| 8 |
|
| 9 |
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. |
| 10 |
|
| 11 |
## Usage |
| 12 |
|
| 13 |
```rust |
| 14 |
use makeover::{load_theme, list_themes_from_dirs, bundled_themes_dir}; |
| 15 |
use std::path::PathBuf; |
| 16 |
|
| 17 |
// Set up theme directories (later entries override earlier ones) |
| 18 |
let bundled = bundled_themes_dir().expect("makeover ships themes/"); |
| 19 |
let custom = PathBuf::from("/path/to/user/custom-themes"); |
| 20 |
let dirs = vec![(bundled, false), (custom, true)]; |
| 21 |
|
| 22 |
// List available themes (sorted by name) |
| 23 |
let themes = list_themes_from_dirs(&dirs); |
| 24 |
for t in &themes { |
| 25 |
println!("{} ({}, {})", t.name, t.id, t.variant); |
| 26 |
} |
| 27 |
|
| 28 |
// Load a specific theme by ID |
| 29 |
let theme = load_theme(&dirs, "catppuccin-mocha").unwrap(); |
| 30 |
println!("Name: {}", theme.meta.name); // "Catppuccin Mocha" |
| 31 |
println!("Variant: {}", theme.meta.variant); // "dark" |
| 32 |
println!("BG: {}", theme.colors["surface.page"]); // "#1e1e2e" |
| 33 |
|
| 34 |
// Build-from-source fallback: the themes this crate ships |
| 35 |
if let Some(dir) = bundled_themes_dir() { |
| 36 |
// dir = <makeover checkout>/themes |
| 37 |
} |
| 38 |
``` |
| 39 |
|
| 40 |
## Theme File Format |
| 41 |
|
| 42 |
Colors are declared by **intent** (the role a color plays), not by hue. See `themes/` for the 31 bundled themes. |
| 43 |
|
| 44 |
```toml |
| 45 |
[meta] |
| 46 |
name = "Nord" # Display name; falls back to the filename |
| 47 |
variant = "dark" # "dark", "light", or "high-contrast" (default: "dark") |
| 48 |
|
| 49 |
[surface] # Container backgrounds by elevation |
| 50 |
page = "#2e3440" |
| 51 |
raised = "#3b4252" |
| 52 |
sunken = "#434c5e" |
| 53 |
overlay = "#3b4252" |
| 54 |
|
| 55 |
[content] # Text and ink by emphasis |
| 56 |
primary = "#d8dee9" |
| 57 |
secondary = "#e5e9f0" |
| 58 |
muted = "#616e88" |
| 59 |
|
| 60 |
[action] # Interactive / brand color |
| 61 |
primary = "#81a1c1" |
| 62 |
|
| 63 |
[status] # State semantics |
| 64 |
danger = "#bf616a" |
| 65 |
success = "#a3be8c" |
| 66 |
warning = "#ebcb8b" |
| 67 |
info = "#88c0d0" |
| 68 |
|
| 69 |
[line] |
| 70 |
border = "#4c566a" |
| 71 |
|
| 72 |
[category] # Optional: tag and label colors |
| 73 |
``` |
| 74 |
|
| 75 |
### Derived tokens |
| 76 |
|
| 77 |
Interactive states are not authored. `resolve()` derives them perceptually in |
| 78 |
OKLab, so theme files stay small and every consuming app derives them |
| 79 |
identically rather than each recomputing its own: `action-hover`, |
| 80 |
`content-on-action`, `focus-ring`, `hover-surface`, `border-strong`, the |
| 81 |
translucent `overlay` scrim, and the `bevel-light` / `bevel-dark` pair that a |
| 82 |
raised surface is lit and shadowed with. |
| 83 |
|
| 84 |
Each is emitted only when the intents it reads from are present, so a partial |
| 85 |
theme resolves to a partial token set rather than failing. |
| 86 |
|
| 87 |
Bevel geometry is not derived here. Thickness, radius and which side takes which |
| 88 |
edge are the consuming app's, and only the two tones are shared. |
| 89 |
|
| 90 |
`intent_css_vars()` renders a resolved theme as a `:root { … }` block for web |
| 91 |
consumers; native consumers read RGB tuples off the same resolved tokens. |
| 92 |
|
| 93 |
### Terminals without truecolor |
| 94 |
|
| 95 |
`ANSI_16`, `ANSI_256` and `ANSI_240` are the palettes a terminal addresses by |
| 96 |
index, and `quantize` maps a theme color onto the nearest entry of any of them in |
| 97 |
OKLab. `quantize_against` does the same for a color that has to stay legible |
| 98 |
against a known background, such as a border on a page, and it is the wrong |
| 99 |
choice for a pair of colors that must also stay apart from each other, because it |
| 100 |
optimizes each one against the background alone. |
| 101 |
|
| 102 |
Prefer `ANSI_240`, the 6x6x6 cube and the gray ramp. Every emulator lets the user |
| 103 |
repaint the low sixteen, so a match landing there is a match against a color that |
| 104 |
may have moved. Add `ANSI_240_OFFSET` to the returned index to get the one the |
| 105 |
terminal wants. |
| 106 |
|
| 107 |
Color depth decides how much of a theme survives. Two tones a hair apart in |
| 108 |
24-bit round onto one entry at 256 and onto the same gray at 16. |
| 109 |
|
| 110 |
### Theme ID |
| 111 |
|
| 112 |
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. |
| 113 |
|
| 114 |
## API |
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
| `list_themes_from_dirs(dirs)` | Scan directories for `.toml` files, return sorted `Vec<ThemeMeta>` | |
| 119 |
| `load_theme(dirs, id)` | Load a theme by ID, returning `ThemeColors` (metadata + color map) | |
| 120 |
| `find_theme_path(dirs, id)` | Find the file path for a theme ID (highest-priority directory wins) | |
| 121 |
| `parse_meta(id, table, is_custom)` | Parse `[meta]` from a TOML table into `ThemeMeta` | |
| 122 |
| `extract_colors(table)` | Flatten color sections into a `HashMap<String, String>` | |
| 123 |
| `validate_theme_id(id)` | Check that an ID contains only safe characters | |
| 124 |
| `bundled_themes_dir()` | The `themes/` directory this crate ships, for build-from-source fallback | |
| 125 |
| `embedded_themes()` | The shipped themes as `(id, toml_source)` pairs, embedded at compile time (no path needed) | |
| 126 |
| `parse_theme_str(id, source, is_custom)` | Parse a theme from a string, for use with `embedded_themes()` | |
| 127 |
| `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states | |
| 128 |
| `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block | |
| 129 |
|
| 130 |
## Choosing a theme |
| 131 |
|
| 132 |
Loading a theme file was always shared; choosing one was not, and every app |
| 133 |
re-rolled it. These types are the shared half. |
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
| `Variant` | `light` / `dark` / `high-contrast`, as a value. `ThemeMeta::kind()` reads it | |
| 138 |
| `ThemeSelection` | `Follow` or `Fixed(id)` — what the user chose, not what is rendered | |
| 139 |
| `ThemeSelection::parse(stored)` | Read a stored value from any store; absent or `"system"` is `Follow` | |
| 140 |
| `ThemeSelection::as_str()` | The string to persist, whatever the store is | |
| 141 |
| `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists | |
| 142 |
| `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode | |
| 143 |
| `ThemeDirs` | Build the search path with the tiers named | |
| 144 |
|
| 145 |
The store stays the app's: `localStorage`, a config table, a TOML file. What is |
| 146 |
shared is the string it holds and what that string means, so `"system"` means |
| 147 |
the same thing in all of them, and the key is `theme` everywhere. |
| 148 |
|
| 149 |
`resolve` picks by `Variant`, so an app follows the system into any installed |
| 150 |
theme of the right kind rather than into a hardcoded pair. A `Fixed` ID whose |
| 151 |
theme has been deleted falls back rather than being handed back to fail later. |
| 152 |
|
| 153 |
```rust |
| 154 |
let selection = ThemeSelection::parse(store.get("theme")); |
| 155 |
let defaults = ThemeDefaults::new("flatwhite", "nord"); |
| 156 |
let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs)); |
| 157 |
``` |
| 158 |
|
| 159 |
## Directory Priority |
| 160 |
|
| 161 |
`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`). |
| 162 |
|
| 163 |
Build it with `ThemeDirs` rather than by hand. The tiers are named, so the |
| 164 |
order is not the caller's to get backwards: |
| 165 |
|
| 166 |
```rust |
| 167 |
let dirs = ThemeDirs::new() |
| 168 |
.bundled(bundled_themes_dir()) |
| 169 |
.system(Some("/usr/share/myapp/themes".into())) |
| 170 |
.custom(config_dir.map(|c| c.join("themes"))) |
| 171 |
.build(); |
| 172 |
``` |
| 173 |
|
| 174 |
The user's themes win, then the system's, then the app's own. Directories that |
| 175 |
do not exist are dropped, so every tier can be offered unconditionally. Passing |
| 176 |
a hand-built vector still works; one app had it inverted, with a comment |
| 177 |
claiming the opposite of what the loader does, which is what this replaces. |
| 178 |
|
| 179 |
## License |
| 180 |
|
| 181 |
MIT. |
| 182 |
|
| 183 |
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. |
| 184 |
|
| 185 |
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. |
| 186 |
|