Skip to main content

max / balanced_breakfast

themes: onto makeover's shared selection, deleting the hardcoded pair Step 4 of the makeover extraction. Balanced Breakfast could not follow the system into a theme the user installed, because the frontend held `dark ? 'catppuccin-mocha' : 'flatwhite'` — following resolved to one of two literals, so a dark theme someone imported was unreachable even with the OS in dark mode. That pairing now lives in a resolve_theme command over makeover, which picks by ThemeMeta.variant against what is actually installed, and a pinned theme that has since been deleted falls back instead of failing to load. The two default ids are still this app's to name, in defaults(), with a test that they are themes it actually ships. theme_dirs is built through makeover's ThemeDirs. Same body four apps had by hand, two of them byte-identical; the tiers are named so the precedence is the library's rather than each caller's to get right. The store now holds a selection, not a rendered id: 'system' to follow, or a theme id to pin. Storing only the id is what made the old code unable to tell a standing follow from a pin — an absent value meant follow, so the first save stopped tracking the OS forever. "Follow the system" is now the first option in the picker and what an unconfigured app does. The key is `theme`, the family convention, migrated once from the old `bb-theme` so an existing install keeps its choice. makeover bumped 1.0.0 (resolved to the yanked 1.1.0) to 2.1.0. Pre-existing sync_service test failures (reqwest client construction, unrelated to this change) are left as they were.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 20:40 UTC
Commit: abedd3f10020c9273f79ebc0c6788022f5fb671d
Parent: 1e40478
6 files changed, +177 insertions, -58 deletions
M Cargo.lock +3 -3
@@ -3102,13 +3102,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
3102 3102
3103 3103 [[package]]
3104 3104 name = "makeover"
3105 - version = "1.0.0"
3105 + version = "2.1.0"
3106 3106 source = "registry+https://github.com/rust-lang/crates.io-index"
3107 - checksum = "15a1acf8af277255389ab1d7e53412e442983886413025c2f32f9d1dbde81d33"
3107 + checksum = "a26aeb770bb59143a83a7bffa1150930064b785abbe6b7e99da59697cb592032"
3108 3108 dependencies = [
3109 3109 "include_dir",
3110 3110 "serde",
3111 - "toml 0.8.2",
3111 + "toml 1.1.3+spec-1.1.0",
3112 3112 ]
3113 3113
3114 3114 [[package]]
M Cargo.toml +1 -1
@@ -53,7 +53,7 @@ open = "5"
53 53 # Utilities
54 54 chrono = { version = "0.4.43", features = ["serde"] }
55 55 uuid = { version = "1.20.0", features = ["v4", "serde"] }
56 - makeover = "1.0.0"
56 + makeover = "2.1.0"
57 57 toml = "1.1"
58 58 dirs = "6.0.0"
59 59
@@ -612,7 +612,7 @@ BB.api.sync = {
612 612
613 613 require('../settings-sync');
614 614
615 - describe('BB.sync.openSettings — renderState routing', () => {
615 + describe('BB.sync.openSettings: renderState routing', () => {
616 616 test('not configured shows Connect button', async () => {
617 617 syncStatusResult = { configured: false, authenticated: false };
618 618 const body = document.getElementById('modal-body');
@@ -11,6 +11,51 @@
11 11 let currentThemeId = null;
12 12 let themeCache = {};
13 13
14 + // The key every app in the family stores its theme under. It was `bb-theme`
15 + // here, `goingson-theme` there and `theme` in a third — three names for one
16 + // concept. `LEGACY_KEY` is read once so an existing install keeps its
17 + // choice, and never written.
18 + const KEY = 'theme';
19 + const LEGACY_KEY = 'bb-theme';
20 +
21 + // What "follow the system" is stored as, in every app. makeover parses and
22 + // resolves it; nothing here needs to know more than the string.
23 + const FOLLOW = 'system';
24 +
25 + /**
26 + * The stored selection: 'system', a theme id, or null if never set.
27 + * Migrates the pre-convention key on first read.
28 + * @returns {Promise<string|null>}
29 + */
30 + async function storedSelection() {
31 + const current = await invoke('get_config', { key: KEY });
32 + if (current) return current;
33 +
34 + const legacy = await invoke('get_config', { key: LEGACY_KEY });
35 + if (legacy) {
36 + await invoke('set_config', { key: KEY, value: legacy });
37 + return legacy;
38 + }
39 + return null;
40 + }
41 +
42 + /** The OS light/dark preference, as makeover's variant vocabulary. */
43 + function ambient() {
44 + const dark = window.matchMedia
45 + && window.matchMedia('(prefers-color-scheme: dark)').matches;
46 + return dark ? 'dark' : 'light';
47 + }
48 +
49 + /**
50 + * Apply whatever the stored selection resolves to right now.
51 + * @returns {Promise<void>}
52 + */
53 + async function applySelection() {
54 + const selection = await storedSelection();
55 + const id = await invoke('resolve_theme', { selection, ambient: ambient() });
56 + await applyThemeById(id);
57 + }
58 +
14 59 /**
15 60 * Apply a theme's resolved intent tokens as CSS variables on :root. Each
16 61 * intent key becomes `--{key}` (e.g. `surface-page` -> `--surface-page`);
@@ -27,11 +72,12 @@
27 72 }
28 73
29 74 /**
30 - * Load and apply a theme by ID.
75 + * Render a theme by ID. Does not record anything — see `choose`.
31 76 * @param {string} themeId - Theme identifier (e.g. 'catppuccin-mocha').
77 + * @param {string} [selectorValue] - What the picker should show as current.
32 78 * @returns {Promise<void>}
33 79 */
34 - async function loadTheme(themeId) {
80 + async function applyThemeById(themeId, selectorValue = themeId) {
35 81 let theme = themeCache[themeId];
36 82 if (!theme) {
37 83 try {
@@ -45,25 +91,33 @@
45 91
46 92 applyTheme(theme.intents);
47 93 currentThemeId = themeId;
48 - invoke('set_config', { key: 'bb-theme', value: themeId });
49 94
50 95 const selector = document.getElementById('theme-selector');
51 - if (selector) selector.value = themeId;
96 + if (selector) selector.value = selectorValue;
52 97 }
53 98
54 99 /**
55 - * Load saved theme or default. Follows system dark/light preference if unset.
100 + * Record a choice and apply it. `selection` is 'system' or a theme id — what
101 + * the user picked, which is not the same as what gets rendered.
102 + *
103 + * Storing only the rendered id is what made the old code unable to tell a
104 + * standing "follow the system" from a pin: an absent value meant follow, so
105 + * the moment anything was saved the app stopped tracking the OS forever.
106 + * @param {string} selection
107 + * @returns {Promise<void>}
108 + */
109 + async function choose(selection) {
110 + await invoke('set_config', { key: KEY, value: selection });
111 + const id = await invoke('resolve_theme', { selection, ambient: ambient() });
112 + await applyThemeById(id, selection);
113 + }
114 +
115 + /**
116 + * Apply the stored selection, which is 'system' until told otherwise.
56 117 * @returns {Promise<void>}
57 118 */
58 119 async function init() {
59 - const saved = await invoke('get_config', { key: 'bb-theme' });
60 - if (saved) {
61 - await loadTheme(saved);
62 - } else {
63 - // Default: follow system preference
64 - const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
65 - await loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite');
66 - }
120 + await applySelection();
67 121 }
68 122
69 123 /**
@@ -81,6 +135,7 @@
81 135 return;
82 136 }
83 137
138 + const selection = await storedSelection() ?? FOLLOW;
84 139 const light = themes.filter(t => t.variant === 'light');
85 140 const dark = themes.filter(t => t.variant === 'dark');
86 141 const highContrast = themes.filter(t => t.variant === 'high-contrast');
@@ -96,28 +151,34 @@
96 151 const opt = document.createElement('option');
97 152 opt.value = t.id;
98 153 opt.textContent = t.name;
99 - if (t.id === currentThemeId) opt.selected = true;
154 + if (t.id === selection) opt.selected = true;
100 155 group.appendChild(opt);
101 156 }
102 157 select.appendChild(group);
103 158 };
104 159
160 + // First, because it is what an app nobody has configured does, and
161 + // because it is a choice rather than the absence of one.
162 + const follow = document.createElement('option');
163 + follow.value = FOLLOW;
164 + follow.textContent = 'Follow the system';
165 + if (selection === FOLLOW) follow.selected = true;
166 + select.appendChild(follow);
167 +
105 168 addGroup('Light', light);
106 169 addGroup('Dark', dark);
107 170 if (highContrast.length > 0) addGroup('High Contrast', highContrast);
108 171
109 - select.addEventListener('change', () => loadTheme(select.value));
172 + select.addEventListener('change', () => choose(select.value));
110 173 container.appendChild(select);
111 174 }
112 175
113 - // Listen for system theme changes
176 + // Follow the system when it flips. Re-resolving rather than recomputing a
177 + // pair means this reaches whatever dark theme is installed, including one
178 + // the user imported.
114 179 if (window.matchMedia) {
115 180 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => {
116 - const saved = await invoke('get_config', { key: 'bb-theme' });
117 - if (!saved) {
118 - const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
119 - loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite');
120 - }
181 + if (await storedSelection() === FOLLOW) await applySelection();
121 182 });
122 183 }
123 184
@@ -137,7 +198,9 @@
137 198 const meta = await invoke('import_theme', { path });
138 199 themeCache = {}; // Clear cache so new theme is discoverable
139 200 BB.ui.showToast(`Imported "${meta.name}"`);
140 - await loadTheme(meta.id);
201 + // Importing a theme is choosing it: pinned, not a one-off render,
202 + // or the next system flip would throw it away.
203 + await choose(meta.id);
141 204 } catch (e) {
142 205 BB.ui.showToast('Import failed: ' + (e.message || e), 'error');
143 206 }
@@ -166,7 +229,7 @@
166 229
167 230 BB.themes = {
168 231 init,
169 - load: loadTheme,
232 + load: choose,
170 233 buildSelector,
171 234 importTheme,
172 235 exportTheme,
@@ -7,37 +7,35 @@ use tracing::instrument;
7 7 use super::error::ApiError;
8 8
9 9 pub use makeover::{SemanticTokens, ThemeMeta};
10 + use makeover::{ThemeDefaults, ThemeDirs, ThemeSelection, Variant};
10 11
11 - /// Returns theme directories in priority order (later overrides earlier by ID).
12 - /// Each entry is `(path, is_custom)`.
12 + /// The theme search path: the user's own themes win, then whatever the app
13 + /// bundles. Built through makeover's [`ThemeDirs`] so the precedence is stated
14 + /// once, in the library, rather than in each app that needs it — this function
15 + /// used to be duplicated byte-for-byte in GoingsOn, and the Alloy console had
16 + /// its own copy with the tiers in the wrong order.
13 17 fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
14 - let mut dirs = Vec::new();
15 -
16 - // 1. Bundled themes (production)
17 - if let Ok(resource_dir) = app.path().resource_dir() {
18 - let bundled = resource_dir.join("themes");
19 - if bundled.is_dir() {
20 - dirs.push((bundled, false));
21 - }
22 - }
23 -
24 - // 2. Dev fallback: the themes build.rs materialized from makeover. In a
25 - // dev run there is no bundled resource dir, so this is what supplies the
26 - // stock set; in production it is redundant with (1) and harmless.
27 - let generated = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes");
28 - if generated.is_dir() {
29 - dirs.push((generated, false));
30 - }
31 -
32 - // 3. User custom themes (highest priority)
33 - if let Ok(config_dir) = app.path().app_config_dir() {
34 - let custom = config_dir.join("themes");
35 - if custom.is_dir() {
36 - dirs.push((custom, true));
37 - }
38 - }
18 + ThemeDirs::new()
19 + // Bundled themes, packaged with the app in production.
20 + .bundled(app.path().resource_dir().ok().map(|d| d.join("themes")))
21 + // Dev fallback: the tree build.rs materialized from makeover. A `cargo
22 + // run` has no resource dir; in production this is redundant and
23 + // harmless, and `ThemeDirs` drops it when it does not exist.
24 + .bundled(Some(
25 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
26 + ))
27 + .custom(app.path().app_config_dir().ok().map(|d| d.join("themes")))
28 + .build()
29 + }
39 30
40 - dirs
31 + /// The themes Balanced Breakfast falls back to when the user is following the
32 + /// system rather than pinning one.
33 + ///
34 + /// App identity, which is why it stays here and not in makeover: what "the
35 + /// light one" means is this app's answer. Everything around it — the encoding,
36 + /// the resolution, the variant matching — is shared.
37 + fn defaults() -> ThemeDefaults {
38 + ThemeDefaults::new("flatwhite", "catppuccin-mocha")
41 39 }
42 40
43 41 #[tauri::command]
@@ -62,6 +60,34 @@ pub fn get_theme(app: AppHandle, id: String) -> Result<SemanticTokens, ApiError>
62 60 makeover::load_semantic(&dirs, &id).map_err(ApiError::internal)
63 61 }
64 62
63 + /// Turn a stored selection into the theme id to render.
64 + ///
65 + /// `selection` is what the user chose, verbatim from the store: `"system"` to
66 + /// follow, or a theme id to pin. `ambient` is what the OS reports right now,
67 + /// which only the frontend can see (`prefers-color-scheme`).
68 + ///
69 + /// The pairing lives here rather than in the frontend, and that is the point of
70 + /// the change. The JS used to hold `dark ? 'catppuccin-mocha' : 'flatwhite'`,
71 + /// so following the system could only ever reach those two — a user who
72 + /// imported a dark theme and set the OS to dark still got Catppuccin. makeover
73 + /// resolves against `ThemeMeta.variant` and what is actually installed, so
74 + /// following now reaches any theme of the right kind, and a pinned theme that
75 + /// has since been deleted falls back instead of failing to load.
76 + #[tauri::command]
77 + #[instrument(skip_all)]
78 + #[allow(
79 + clippy::needless_pass_by_value,
80 + reason = "Tauri command handler: the command macro injects AppHandle and deserializes String args by value"
81 + )]
82 + pub fn resolve_theme(app: AppHandle, selection: Option<String>, ambient: String) -> String {
83 + let available = makeover::list_themes_from_dirs(&theme_dirs(&app));
84 + ThemeSelection::parse(selection.as_deref()).resolve(
85 + Variant::from(ambient.as_str()),
86 + &defaults(),
87 + &available,
88 + )
89 + }
90 +
65 91 #[tauri::command]
66 92 #[instrument(skip_all)]
67 93 #[allow(
@@ -107,6 +133,35 @@ pub fn export_theme(app: AppHandle, id: String, path: String) -> Result<(), ApiE
107 133
108 134 #[cfg(test)]
109 135 mod tests {
110 - // Core theme logic tests live in the makeover crate.
111 - // App-specific tests here only if needed for theme_dirs behavior.
136 + use super::*;
137 +
138 + // The resolver and the search-path precedence are makeover's, tested there.
139 + // What is this app's to get right is `defaults()`: the two ids it names as
140 + // its light/dark fallbacks have to be themes that actually ship, or
141 + // following the system resolves to a theme that then fails to load. The
142 + // frontend used to hardcode the same two strings, where nothing checked
143 + // them at all.
144 + #[test]
145 + fn the_default_theme_ids_are_ones_the_app_ships() {
146 + let shipped: Vec<String> =
147 + std::fs::read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"))
148 + .expect("themes/ exists; build.rs materializes it")
149 + .flatten()
150 + .filter_map(|entry| {
151 + let path = entry.path();
152 + (path.extension()? == "toml")
153 + .then(|| path.file_stem()?.to_str().map(str::to_string))
154 + .flatten()
155 + })
156 + .collect();
157 +
158 + let defaults = defaults();
159 + for variant in [Variant::Light, Variant::Dark] {
160 + let id = defaults.for_variant(variant);
161 + assert!(
162 + shipped.iter().any(|s| s == id),
163 + "default {variant} theme `{id}` is not in themes/: {shipped:?}",
164 + );
165 + }
166 + }
112 167 }
@@ -97,6 +97,7 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> {
97 97 commands::list_themes,
98 98 commands::get_theme,
99 99 commands::get_custom_themes_dir,
100 + commands::resolve_theme,
100 101 commands::import_theme,
101 102 commands::export_theme,
102 103 commands::sync_get_tiers,