Skip to main content

max / makeover

7.1 KB · 161 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 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 | Function | Description |
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 | Item | Purpose |
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