Skip to main content

max / goingson

Resolve the theme selection through makeover, not a hardcoded pair Step 5 of the shared theming convention. The frontend held `dark ? 'catppuccin-mocha' : 'goingson'`, so following the system could only ever reach those two: a user who imported a dark theme and set the OS to dark still got Catppuccin. A new `resolve_theme` command hands the stored selection and the ambient mode to makeover, which matches against `ThemeMeta.variant` across everything installed and falls back when a pinned theme has since been deleted. Splits rendering from choosing on the JS side. `applyTheme` paints an id, `loadTheme` pins one, and `renderSelection` turns the stored value into an id to paint, so `system` no longer round-trips through a concrete id on its way to the store. `theme_dirs` goes through makeover's `ThemeDirs`, dropping the copy that was byte-identical to Balanced Breakfast's. Callers now name the config key `theme`. The `goingson-theme` alias stays in config.js as the one-time read of the old localStorage name.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 17:58 UTC
Signed with PGP, not checked
Commit: 85b9f7773f32da5c642c1bc819089c8a5e6137e3
Parent: 8d783e2
4 files changed, +136 insertions, -56 deletions
@@ -292,6 +292,7 @@
292 292 // Themes
293 293 $crate::commands::list_themes,
294 294 $crate::commands::get_theme,
295 + $crate::commands::resolve_theme,
295 296 $crate::commands::get_custom_themes_dir,
296 297 $crate::commands::import_theme,
297 298 $crate::commands::export_theme,
@@ -91,7 +91,7 @@
91 91 // Section Renderers
92 92
93 93 async function renderAppearance(container) {
94 - const savedTheme = GoingsOn.config.get('goingson-theme') || 'system';
94 + const savedTheme = GoingsOn.config.get('theme') || 'system';
95 95 const { light: lightThemes, dark: darkThemes, highContrast: highContrastThemes } = await GoingsOn.themes.getByType();
96 96
97 97 const highContrastGroup = highContrastThemes.length > 0
@@ -72,22 +72,32 @@
72 72 }
73 73
74 74 /**
75 - * Load and apply a theme by ID, saving the selection to localStorage.
76 - * @param {string} themeId - Theme ID to load
75 + * Fetch and apply a theme by ID. Does not touch the stored selection: what
76 + * is rendered and what the user chose are separate, and `system` renders as
77 + * a concrete id without becoming one.
78 + * @param {string} themeId - Theme ID to render
77 79 */
78 - async function loadTheme(themeId) {
80 + async function applyTheme(themeId) {
79 81 const theme = await fetchTheme(themeId);
80 82 if (!theme) {
81 83 console.warn(`Theme not found: ${themeId}, falling back to goingson`);
82 84 if (themeId !== 'goingson') {
83 - return loadTheme('goingson');
85 + return applyTheme('goingson');
84 86 }
85 87 return;
86 88 }
87 89
88 90 applyColors(theme.intents);
89 91 GoingsOn.state.set('currentThemeId', themeId);
90 - GoingsOn.config.set('goingson-theme', themeId);
92 + }
93 +
94 + /**
95 + * Pin a theme: store it as the selection, then render it.
96 + * @param {string} themeId - Theme ID to pin
97 + */
98 + async function loadTheme(themeId) {
99 + GoingsOn.config.set('theme', themeId);
100 + await applyTheme(themeId);
91 101
92 102 // Update the theme selector if it exists
93 103 const selector = document.getElementById('theme-selector');
@@ -108,7 +118,32 @@
108 118 }
109 119
110 120 /**
111 - * Load theme from localStorage or use system preference
121 + * Resolve the stored selection against the current system appearance and
122 + * render the result.
123 + *
124 + * The pairing is the backend's, via makeover: the frontend supplies the one
125 + * thing only it can see (`prefers-color-scheme`) and the resolver matches it
126 + * against `variant` across every installed theme. This used to be
127 + * `dark ? 'catppuccin-mocha' : 'goingson'` right here, which meant following
128 + * the system could never reach a theme the user had imported.
129 + * @param {string|null} selection - Stored selection: 'system' or a theme ID
130 + */
131 + async function renderSelection(selection) {
132 + let id;
133 + try {
134 + id = await invoke('resolve_theme', {
135 + selection,
136 + ambient: getSystemThemePreference(),
137 + });
138 + } catch (e) {
139 + console.error('Failed to resolve theme:', e);
140 + id = 'goingson';
141 + }
142 + await applyTheme(id);
143 + }
144 +
145 + /**
146 + * Load the stored selection and render it.
112 147 */
113 148 async function loadThemeFromStorage() {
114 149 // The chosen theme lives in the config store now; wait for the cache.
@@ -116,27 +151,15 @@
116 151 // Pre-fetch the theme list so it's cached for the settings UI
117 152 await fetchThemeList();
118 153
119 - const savedTheme = GoingsOn.config.get('goingson-theme');
120 - if (savedTheme === 'system') {
121 - await applySystemTheme();
122 - } else if (savedTheme) {
123 - await loadTheme(savedTheme);
124 - } else {
125 - await applySystemTheme();
126 - }
154 + await renderSelection(GoingsOn.config.get('theme'));
127 155 }
128 156
129 157 /**
130 - * Apply theme based on system preference
158 + * Follow the system: store the standing instruction, then render it.
131 159 */
132 160 async function applySystemTheme() {
133 - const preference = getSystemThemePreference();
134 - if (preference === 'dark') {
135 - await loadTheme('catppuccin-mocha');
136 - } else {
137 - await loadTheme('goingson');
138 - }
139 - GoingsOn.config.set('goingson-theme', 'system');
161 + GoingsOn.config.set('theme', 'system');
162 + await renderSelection('system');
140 163 }
141 164
142 165 /**
@@ -183,8 +206,8 @@
183 206 // Listen for system theme changes
184 207 if (window.matchMedia) {
185 208 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
186 - if (GoingsOn.config.get('goingson-theme') === 'system') {
187 - applySystemTheme();
209 + if (GoingsOn.config.get('theme') === 'system') {
210 + renderSelection('system');
188 211 }
189 212 });
190 213 }
@@ -218,8 +241,10 @@
218 241 * Export the current theme TOML file via native save dialog.
219 242 */
220 243 async function exportTheme() {
244 + // Always a concrete id: `currentThemeId` is what is rendered, never the
245 + // `system` selection.
221 246 const currentId = GoingsOn.state.currentThemeId;
222 - if (!currentId || currentId === 'system') return;
247 + if (!currentId) return;
223 248 try {
224 249 const { save } = window.__TAURI__.dialog;
225 250 const path = await save({
@@ -7,37 +7,34 @@
7 7 use super::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 Balanced Breakfast.
13 16 fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
14 - let mut dirs = Vec::new();
17 + ThemeDirs::new()
18 + // Bundled themes, packaged with the app in production.
19 + .bundled(app.path().resource_dir().ok().map(|d| d.join("themes")))
20 + // Dev fallback: the tree build.rs materialized from makeover. A `cargo
21 + // run` has no resource dir; in production this is redundant and
22 + // harmless, and `ThemeDirs` drops it when it does not exist.
23 + .bundled(Some(
24 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
25 + ))
26 + .custom(app.path().app_config_dir().ok().map(|d| d.join("themes")))
27 + .build()
28 + }
15 29
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 - }
39 -
40 - dirs
30 + /// The themes GoingsOn falls back to when the user is following the system
31 + /// rather than pinning one.
32 + ///
33 + /// App identity, which is why it stays here and not in makeover: what "the
34 + /// light one" means is this app's answer. Everything around it (the encoding,
35 + /// the resolution, the variant matching) is shared.
36 + fn defaults() -> ThemeDefaults {
37 + ThemeDefaults::new("goingson", "catppuccin-mocha")
41 38 }
42 39
43 40 #[tauri::command]
@@ -62,6 +59,34 @@
62 59 makeover::load_semantic(&dirs, &id).map_err(ApiError::internal)
63 60 }
64 61
62 + /// Turn a stored selection into the theme id to render.
63 + ///
64 + /// `selection` is what the user chose, verbatim from the store: `"system"` to
65 + /// follow, or a theme id to pin. `ambient` is what the OS reports right now,
66 + /// which only the frontend can see (`prefers-color-scheme`).
67 + ///
68 + /// The pairing lives here rather than in the frontend, and that is the point of
69 + /// the change. The JS used to hold `dark ? 'catppuccin-mocha' : 'goingson'`, so
70 + /// following the system could only ever reach those two: a user who imported a
71 + /// dark theme and set the OS to dark still got Catppuccin. makeover resolves
72 + /// against `ThemeMeta.variant` and what is actually installed, so following now
73 + /// reaches any theme of the right kind, and a pinned theme that has since been
74 + /// deleted falls back instead of failing to load.
75 + #[tauri::command]
76 + #[instrument(skip_all)]
77 + #[allow(
78 + clippy::needless_pass_by_value,
79 + reason = "Tauri command handler: AppHandle and payload args are supplied by value per the #[tauri::command] contract; command parameters cannot be borrowed"
80 + )]
81 + pub fn resolve_theme(app: AppHandle, selection: Option<String>, ambient: String) -> String {
82 + let available = makeover::list_themes_from_dirs(&theme_dirs(&app));
83 + ThemeSelection::parse(selection.as_deref()).resolve(
84 + Variant::from(ambient.as_str()),
85 + &defaults(),
86 + &available,
87 + )
88 + }
89 +
65 90 #[tauri::command]
66 91 #[instrument(skip_all)]
67 92 #[allow(
@@ -107,6 +132,35 @@
107 132
108 133 #[cfg(test)]
109 134 mod tests {
110 - // Core theme logic tests live in makeover crate.
111 - // App-specific tests here only if needed for theme_dirs behavior.
135 + use super::*;
136 +
137 + // The resolver and the search-path precedence are makeover's, tested there.
138 + // What is this app's to get right is `defaults()`: the two ids it names as
139 + // its light/dark fallbacks have to be themes that actually ship, or
140 + // following the system resolves to a theme that then fails to load. The
141 + // frontend used to hardcode the same two strings, where nothing checked
142 + // them at all.
143 + #[test]
144 + fn the_default_theme_ids_are_ones_the_app_ships() {
145 + let shipped: Vec<String> =
146 + std::fs::read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"))
147 + .expect("themes/ exists; build.rs materializes it")
148 + .flatten()
149 + .filter_map(|entry| {
150 + let path = entry.path();
151 + (path.extension()? == "toml")
152 + .then(|| path.file_stem()?.to_str().map(str::to_string))
153 + .flatten()
154 + })
155 + .collect();
156 +
157 + let defaults = defaults();
158 + for variant in [Variant::Light, Variant::Dark] {
159 + let id = defaults.for_variant(variant);
160 + assert!(
161 + shipped.iter().any(|s| s == id),
162 + "default {variant} theme `{id}` is not in themes/: {shipped:?}",
163 + );
164 + }
165 + }
112 166 }