Skip to main content

max / makeover

11.1 KB · 229 lines History Blame Raw
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 | Function | Description |
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 | Item | Purpose |
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