| 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 hover, active, |
| 78 |
selection, row striping, and contrast pairings perceptually in OKLab, so theme |
| 79 |
files stay small and every consuming app derives them identically rather than |
| 80 |
each recomputing its own. |
| 81 |
|
| 82 |
`intent_css_vars()` renders a resolved theme as a `:root { … }` block for web |
| 83 |
consumers; native consumers read RGB tuples off the same resolved tokens. |
| 84 |
|
| 85 |
### Theme ID |
| 86 |
|
| 87 |
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. |
| 88 |
|
| 89 |
## API |
| 90 |
|
| 91 |
|
| 92 |
|
| 93 |
| `list_themes_from_dirs(dirs)` | Scan directories for `.toml` files, return sorted `Vec<ThemeMeta>` | |
| 94 |
| `load_theme(dirs, id)` | Load a theme by ID, returning `ThemeColors` (metadata + color map) | |
| 95 |
| `find_theme_path(dirs, id)` | Find the file path for a theme ID (highest-priority directory wins) | |
| 96 |
| `parse_meta(id, table, is_custom)` | Parse `[meta]` from a TOML table into `ThemeMeta` | |
| 97 |
| `extract_colors(table)` | Flatten color sections into a `HashMap<String, String>` | |
| 98 |
| `validate_theme_id(id)` | Check that an ID contains only safe characters | |
| 99 |
| `bundled_themes_dir()` | The `themes/` directory this crate ships, for build-from-source fallback | |
| 100 |
| `embedded_themes()` | The shipped themes as `(id, toml_source)` pairs, embedded at compile time (no path needed) | |
| 101 |
| `parse_theme_str(id, source, is_custom)` | Parse a theme from a string, for use with `embedded_themes()` | |
| 102 |
| `resolve(theme)` | Resolve authored intents into the full token set, deriving interactive states | |
| 103 |
| `intent_css_vars(tokens)` | Render resolved tokens as a `:root { … }` CSS block | |
| 104 |
|
| 105 |
## Choosing a theme |
| 106 |
|
| 107 |
Loading a theme file was always shared; choosing one was not, and every app |
| 108 |
re-rolled it. These types are the shared half. |
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
| `Variant` | `light` / `dark` / `high-contrast`, as a value. `ThemeMeta::kind()` reads it | |
| 113 |
| `ThemeSelection` | `Follow` or `Fixed(id)` — what the user chose, not what is rendered | |
| 114 |
| `ThemeSelection::parse(stored)` | Read a stored value from any store; absent or `"system"` is `Follow` | |
| 115 |
| `ThemeSelection::as_str()` | The string to persist, whatever the store is | |
| 116 |
| `ThemeSelection::resolve(ambient, defaults, available)` | Turn a selection into a theme ID that exists | |
| 117 |
| `ThemeDefaults::new(light, dark)` | The app's own fallbacks, one per ambient mode | |
| 118 |
| `ThemeDirs` | Build the search path with the tiers named | |
| 119 |
|
| 120 |
The store stays the app's: `localStorage`, a config table, a TOML file. What is |
| 121 |
shared is the string it holds and what that string means, so `"system"` means |
| 122 |
the same thing in all of them, and the key is `theme` everywhere. |
| 123 |
|
| 124 |
`resolve` picks by `Variant`, so an app follows the system into any installed |
| 125 |
theme of the right kind rather than into a hardcoded pair. A `Fixed` ID whose |
| 126 |
theme has been deleted falls back rather than being handed back to fail later. |
| 127 |
|
| 128 |
```rust |
| 129 |
let selection = ThemeSelection::parse(store.get("theme")); |
| 130 |
let defaults = ThemeDefaults::new("flatwhite", "nord"); |
| 131 |
let id = selection.resolve(ambient, &defaults, &list_themes_from_dirs(&dirs)); |
| 132 |
``` |
| 133 |
|
| 134 |
## Directory Priority |
| 135 |
|
| 136 |
`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`). |
| 137 |
|
| 138 |
Build it with `ThemeDirs` rather than by hand. The tiers are named, so the |
| 139 |
order is not the caller's to get backwards: |
| 140 |
|
| 141 |
```rust |
| 142 |
let dirs = ThemeDirs::new() |
| 143 |
.bundled(bundled_themes_dir()) |
| 144 |
.system(Some("/usr/share/myapp/themes".into())) |
| 145 |
.custom(config_dir.map(|c| c.join("themes"))) |
| 146 |
.build(); |
| 147 |
``` |
| 148 |
|
| 149 |
The user's themes win, then the system's, then the app's own. Directories that |
| 150 |
do not exist are dropped, so every tier can be offered unconditionally. Passing |
| 151 |
a hand-built vector still works; one app had it inverted, with a comment |
| 152 |
claiming the opposite of what the loader does, which is what this replaces. |
| 153 |
|
| 154 |
## License |
| 155 |
|
| 156 |
MIT. |
| 157 |
|
| 158 |
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. |
| 159 |
|
| 160 |
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. |
| 161 |
|