| 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, the `bevel-light` / `bevel-dark` pair that a raised |
| 82 |
surface is lit and shadowed with, and `surface-well`, the content surface cut |
| 83 |
into a raised one. |
| 84 |
|
| 85 |
Each is emitted only when the intents it reads from are present, so a partial |
| 86 |
theme resolves to a partial token set rather than failing. |
| 87 |
|
| 88 |
`surface-well` is the one derivation that inverts by theme: a well is lighter |
| 89 |
than its face on a light theme and darker on a dark one. The direction is read |
| 90 |
off `content`, not off the theme's `variant` field, so a theme whose text is |
| 91 |
dark resolves as a light theme whatever its metadata says. |
| 92 |
|
| 93 |
Bevel and well geometry is not derived here. Thickness, radius, inset depth and |
| 94 |
which side takes which edge are the consuming app's, and only the tones are |
| 95 |
shared. |
| 96 |
|
| 97 |
`intent_css_vars()` renders a resolved theme as a `:root { … }` block for web |
| 98 |
consumers; native consumers read RGB tuples off the same resolved tokens. |
| 99 |
|
| 100 |
### Switching themes without a reload |
| 101 |
|
| 102 |
A sheet that carries one theme encodes the choice in which sheet is linked, so |
| 103 |
changing the pin means re-linking the document. An htmx navigation swaps the |
| 104 |
body and leaves the head alone, so the change lands at the next launch. |
| 105 |
|
| 106 |
`all_themes_css(dirs, defaults)` emits every installed theme into one sheet: |
| 107 |
an ambient-following default in `:root`, behind `prefers-color-scheme` and |
| 108 |
`prefers-contrast`, then one `:root[data-theme="<id>"]` block per theme. |
| 109 |
Choosing a theme is setting `THEME_ATTRIBUTE` on the root element. The |
| 110 |
attribute selector outranks the bare `:root` on specificity, so a pin beats the |
| 111 |
default without `!important`, and an attribute naming no theme (`"system"`, or |
| 112 |
none at all) falls back to following the OS. A picker can preview a theme by |
| 113 |
writing the attribute and undo by writing the old one, with no request either |
| 114 |
way. |
| 115 |
|
| 116 |
It is a separate call because the bundle is not free: the shipped set is 27 KB |
| 117 |
of custom properties (5 KB gzipped) against the one block a server-rendered |
| 118 |
page injects per response. A surface that ships a single theme keeps paying for |
| 119 |
a single theme. |
| 120 |
|
| 121 |
### Terminals without truecolor |
| 122 |
|
| 123 |
`ANSI_16`, `ANSI_256` and `ANSI_240` are the palettes a terminal addresses by |
| 124 |
index, and `quantize` maps a theme color onto the nearest entry of any of them in |
| 125 |
OKLab. `quantize_against` does the same for a color that has to stay legible |
| 126 |
against a known background, such as a border on a page, and it is the wrong |
| 127 |
choice for a pair of colors that must also stay apart from each other, because it |
| 128 |
optimizes each one against the background alone. |
| 129 |
|
| 130 |
Prefer `ANSI_240`, the 6x6x6 cube and the gray ramp. Every emulator lets the user |
| 131 |
repaint the low sixteen, so a match landing there is a match against a color that |
| 132 |
may have moved. Add `ANSI_240_OFFSET` to the returned index to get the one the |
| 133 |
terminal wants. |
| 134 |
|
| 135 |
Color depth decides how much of a theme survives. Two tones a hair apart in |
| 136 |
24-bit round onto one entry at 256 and onto the same gray at 16. |
| 137 |
|
| 138 |
The other direction is `ansi_intent(index, variant)`: which authored intent |
| 139 |
*paints* ANSI slot `index`, for a program that owns a terminal palette rather |
| 140 |
than one drawing into somebody else's. Twelve slots are chromatic and fixed |
| 141 |
(slot 1 is the theme's danger tone in either polarity); the four achromatic ones |
| 142 |
invert with it, because "black" and "white" mean the darkest and lightest tone |
| 143 |
the theme has, and which intent that is flips between a light theme and a dark |
| 144 |
one. A bare Linux console, a terminal emulator and a generated config that all |
| 145 |
consult this agree on what red means; they disagreed for as long as each kept |
| 146 |
its own table. |
| 147 |
|
| 148 |
### Theme ID |
| 149 |
|
| 150 |
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. |
| 151 |
|
| 152 |
## API |
| 153 |
|
| 154 |
|
| 155 |
|
| 156 |
| `list_themes_from_dirs(dirs)` | Scan directories for `.toml` files, return sorted `Vec<ThemeMeta>` | |
| 157 |
| `load_theme(dirs, id)` | Load a theme by ID, returning `ThemeColors` (metadata + color map) | |
| 158 |
| `find_theme_path(dirs, id)` | Find the file path for a theme ID (highest-priority directory wins) | |
| 159 |
| `parse_meta(id, table, is_custom)` | Parse `[meta]` from a TOML table into `ThemeMeta` | |
| 160 |
| `extract_colors(table)` | Flatten color sections into a `HashMap<String, String>` | |
| 161 |
| `validate_theme_id(id)` | Check that an ID contains only safe characters | |
| 162 |
| `bundled_themes_dir()` | The `themes/` directory this crate ships, for build-from-source fallback | |
| 163 |
| `embedded_themes()` | The shipped themes as `(id, toml_source)` pairs, embedded at compile time (no path needed) | |
| 164 |
| `parse_theme_str(id, source, is_custom)` | Parse a theme from a string, for use with `embedded_themes()` | |
| 165 |
| `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states | |
| 166 |
| `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block | |
| 167 |
| `keyed_intent_css_vars(id, tokens)` | The same block keyed by `THEME_ATTRIBUTE`, as `:root[data-theme="<id>"]` | |
| 168 |
| `all_themes_css(dirs, defaults)` | Every installed theme in one sheet: an ambient default plus a keyed block each | |
| 169 |
| `THEME_ATTRIBUTE` | The root attribute the keyed blocks answer to, so a frontend and its sheet cannot disagree | |
| 170 |
| `ansi_intent(index, variant)` | Which authored intent paints ANSI slot 0-15, for a program filling a terminal palette | |
| 171 |
|
| 172 |
## Choosing a theme |
| 173 |
|
| 174 |
Loading a theme file was always shared; choosing one was not, and every app |
| 175 |
re-rolled it. These types are the shared half. |
| 176 |
|
| 177 |
|
| 178 |
|
| 179 |
| `Variant` | `light` / `dark` / `high-contrast`, as a value. `ThemeMeta::kind()` reads it | |
| 180 |
| `ThemeSelection` | `Follow` or `Fixed(id)` — what the user chose, not what is rendered | |
| 181 |
| `ThemeSelection::parse(stored)` | Read a stored value from any store; absent or `"system"` is `Follow` | |
| 182 |
| `ThemeSelection::as_str()` | The string to persist, whatever the store is | |
| 183 |
| `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists | |
| 184 |
| `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode | |
| 185 |
| `ThemeDefaults::names_high_contrast()` | Whether one was named, which a per-mode emitter asks before writing a `prefers-contrast` block | |
| 186 |
| `ThemeDirs` | Build the search path with the tiers named | |
| 187 |
|
| 188 |
The store stays the app's: `localStorage`, a config table, a TOML file. What is |
| 189 |
shared is the string it holds and what that string means, so `"system"` means |
| 190 |
the same thing in all of them, and the key is `theme` everywhere. |
| 191 |
|
| 192 |
`resolve` picks by `Variant`, so an app follows the system into any installed |
| 193 |
theme of the right kind rather than into a hardcoded pair. A `Fixed` ID whose |
| 194 |
theme has been deleted falls back rather than being handed back to fail later. |
| 195 |
|
| 196 |
```rust |
| 197 |
let selection = ThemeSelection::parse(store.get("theme")); |
| 198 |
let defaults = ThemeDefaults::new("flatwhite", "nord"); |
| 199 |
let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs)); |
| 200 |
``` |
| 201 |
|
| 202 |
## Directory Priority |
| 203 |
|
| 204 |
`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`). |
| 205 |
|
| 206 |
Build it with `ThemeDirs` rather than by hand. The tiers are named, so the |
| 207 |
order is not the caller's to get backwards: |
| 208 |
|
| 209 |
```rust |
| 210 |
let dirs = ThemeDirs::new() |
| 211 |
.bundled(bundled_themes_dir()) |
| 212 |
.system(Some("/usr/share/myapp/themes".into())) |
| 213 |
.custom(config_dir.map(|c| c.join("themes"))) |
| 214 |
.build(); |
| 215 |
``` |
| 216 |
|
| 217 |
The user's themes win, then the system's, then the app's own. Directories that |
| 218 |
do not exist are dropped, so every tier can be offered unconditionally. Passing |
| 219 |
a hand-built vector still works; one app had it inverted, with a comment |
| 220 |
claiming the opposite of what the loader does, which is what this replaces. |
| 221 |
|
| 222 |
## License |
| 223 |
|
| 224 |
MIT. |
| 225 |
|
| 226 |
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. |
| 227 |
|
| 228 |
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. |
| 229 |
|