Skip to main content

max / goingson

The theme the user picked is the theme the window renders Settings > Appearance wrote a theme id and nothing read it. js/themes.js had applied the resolved intent set to :root with setProperty and went with the SPA, so from the swap until now the app rendered the goingson titular theme whatever the picker said, and the :root block in styles.css had quietly stopped being a first-paint default and become the theme. No script comes back. The MNW server answers the same question with none: resolve the theme through makeover::intent_css_vars and let the result override the stylesheet's own :root. Its module header calls that the single TOML-to-CSS mapping shared with GoingsOn and audiofiles, so this is that mapping in a Tauri window, served at /static/theme.css rather than inlined in a head the app does not build per request. Following the system needed no ambient argument in the end, and is better for it. commands::themes::resolve_theme takes one because prefers-color-scheme is a browser fact Rust cannot see; a stylesheet can, so "system" is not resolved here at all. Both variants are rendered, the dark one behind the media query, and the browser picks. The desktop switching now repaints immediately, where the script had needed a restart. A named theme still does not. The sheet is resolved at startup, beside the two Late states, because the passthrough serving it is a closure over no state; applying a new one needs the document reloaded and nothing in quasi_router::Response says that, since Goto is an htmx navigation that swaps the body and leaves the head. So the hint says which is which rather than letting the control look inert a second way. The live-repaint question is quasi's and is filed there. styles.css's :root is a real fallback again, and its comment says so: the window before the state arrives is what it covers, and a document served in it renders on the stock tokens. Closes 8738cbde.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 23:48 UTC
Commit: 409bc159dc6d258786d1d38cede64bb7079339cb
Parent: b7b2f00
8 files changed, +244 insertions, -14 deletions
M Cargo.lock +4 -4
@@ -8493,6 +8493,10 @@
8493 8493 "winnow 1.0.4",
8494 8494 ]
8495 8495
8496 + [[patch.unused]]
8497 + name = "ops-status"
8498 + version = "0.1.0"
8499 +
8496 8500 [[patch.unused]]
8497 8501 name = "quasi-axum"
8498 8502 version = "0.56.0"
@@ -8508,7 +8512,3 @@
8508 8512 [[patch.unused]]
8509 8513 name = "quasi-store"
8510 8514 version = "0.1.0"
8511 -
8512 - [[patch.unused]]
8513 - name = "ops-status"
8514 - version = "0.1.0"
@@ -424,6 +424,12 @@
424 424 // and before the UI can create attachments; race-free here.
425 425 blob_gc::reconcile(&state.db, &state.data_dir).await;
426 426 let state = Arc::new(state);
427 + // The chosen theme, resolved here for the same reason the
428 + // theme search path is resolved in `AppState::new`: the
429 + // passthrough that serves it is a closure over no state, and
430 + // this is where there is a state to read. See
431 + // `quasi::theming`.
432 + quasi::theming::install(&state);
427 433 // Closes the 503 window the deferred protocol opens, before
428 434 // anything can put a window up.
429 435 #[cfg(not(target_os = "android"))]
@@ -368,6 +368,12 @@
368 368 // and before the UI can create attachments; race-free here.
369 369 blob_gc::reconcile(&state.db, &state.data_dir).await;
370 370 let state = Arc::new(state);
371 + // The chosen theme, resolved here for the same reason the
372 + // theme search path is resolved in `AppState::new`: the
373 + // passthrough that serves it is a closure over no state, and
374 + // this is where there is a state to read. See
375 + // `quasi::theming`.
376 + goingson_desktop::quasi::theming::install(&state);
371 377 // Before the window is built, so the 503 window the deferred
372 378 // protocol opens closes here rather than at first request.
373 379 quasi_state.set(Arc::clone(&state));
@@ -82,15 +82,17 @@
82 82 shapes (border width, radius scale, offset shadows) carry over intact. */
83 83
84 84 /* --- INTENT LAYER
85 - Resolved intent tokens for the "goingson" titular theme. These were the
86 - first-paint defaults under the SPA, which had js/themes.js apply the
87 - chosen theme over them at runtime. That script went with the swap and
88 - nothing applies a theme now, so this block is not a default: it is the
89 - theme the app renders. Settings > Appearance still writes a choice, and
90 - nothing reads it (filed 2026-08-22).
85 + The "goingson" titular theme, and the fallback rather than the theme.
86 + css/theme.css loads after this one and carries whatever the user chose,
87 + resolved by makeover at startup; see src/quasi/theming.rs. What this
88 + block covers is the window before that: a document served while the
89 + state is still arriving gets an empty theme sheet, and these are what it
90 + renders with.
91 91
92 - Only the intents this stylesheet reads are here. A rule that reads a new
93 - one needs its value added here too, or the declaration is invalid. */
92 + Only the intents this stylesheet reads are here, which the build now
93 + enforces rather than asks for. A rule that reads a new one needs its
94 + value added here too, or the declaration is invalid until theme.css
95 + lands. */
94 96 --surface-page: #AEB6DC;
95 97 --surface-raised: #D9DDF4;
96 98 --surface-sunken: #BAC2E6;
@@ -2,6 +2,8 @@
2 2 //!
3 3 //! The window loads from the `quasi` scheme, so the protocol handler sees every
4 4 //! request the document makes: five stylesheets, three scripts and three fonts.
5 + //! Four of the stylesheets are embedded and one, the theme, is rendered at
6 + //! startup from the stored choice.
5 7 //! None of them is a route, and [`quasi_tauri::Protocol::passthrough`] is where
6 8 //! they belong.
7 9 //!
@@ -21,6 +23,13 @@
21 23 /// Takes the path alone. A stylesheet is a static thing at an address, so the
22 24 /// verb is not part of the question.
23 25 pub fn get(path: &str) -> Option<Served> {
26 + // The one address here whose bytes are not in the binary: the theme is a
27 + // config value resolved at startup, so it is rendered rather than embedded.
28 + // See [`super::theming`].
29 + if path == super::theming::ADDRESS {
30 + return Some(Served::new(CSS, super::theming::css().as_bytes()));
31 + }
32 +
24 33 let (content_type, body): (&str, &[u8]) = match path {
25 34 // The cascade, in the order index.html links it: geometry and
26 35 // typography before the sheet that reads their custom properties.
@@ -103,6 +112,30 @@
103 112 }
104 113 }
105 114
115 + /// The theme is the one address whose body is not in the binary, so it is
116 + /// held apart from the list above rather than added to it.
117 + ///
118 + /// Empty here, and that is the answer rather than a gap in the test. The
119 + /// sheet is rendered by `theming::install` from the stored choice, and no
120 + /// test process has an `AppState` to install from. A document served in
121 + /// that window renders on the stock intent tokens `css/styles.css`
122 + /// declares, which is what those are for.
123 + ///
124 + /// What this holds is the half that would otherwise fail silently: the
125 + /// address answers at all. A 404 here is an unstyled window, and the
126 + /// address is named in three places.
127 + #[test]
128 + fn the_theme_is_served_even_before_it_is_installed() {
129 + let path = crate::quasi::theming::ADDRESS;
130 + let served = get(path).unwrap_or_else(|| panic!("{path} is not served"));
131 + assert_eq!(served.content_type, super::CSS);
132 + assert_eq!(
133 + served.body,
134 + crate::quasi::theming::css().as_bytes(),
135 + "the body is whatever theming holds, installed or not"
136 + );
137 + }
138 +
106 139 #[test]
107 140 fn a_route_address_is_not_an_asset_address() {
108 141 // The passthrough runs before the router and wins, so anything the
@@ -104,6 +104,7 @@
104 104 pub mod shell;
105 105 pub mod task_list;
106 106 pub mod tasks;
107 + pub mod theming;
107 108 pub mod time_tracking;
108 109 pub mod weekly_review;
109 110
@@ -330,6 +331,9 @@
330 331 .styled("/static/geometry.css")
331 332 .styled("/static/layout.css")
332 333 .styled("/static/styles.css")
334 + // Last, so the chosen theme's intent tokens override the stock ones
335 + // ยง3 of styles.css declares. See [`theming`].
336 + .styled(theming::ADDRESS)
333 337 // Not vendored, so asking for it would be one 404 per document.
334 338 .without_hyperscript()
335 339 // The host half of `Action::by_host`, which is how a file gets picked.
@@ -319,12 +319,25 @@
319 319 /// on quasicoherent alongside the About section's host facts, because it is the
320 320 /// same finding wearing different clothes: the description can say what to do
321 321 /// and cannot reach what the host knows.
322 + ///
323 + /// # The hint says when, because the two answers differ
324 + ///
325 + /// The choice is served as a stylesheet, `super::theming`, resolved at startup
326 + /// from this key. Following the system is live, because the browser is what
327 + /// picks between the two variants the sheet carries and it re-picks the moment
328 + /// the desktop changes. A named theme is not: the sheet is rendered once and
329 + /// the document links it once, and applying a new one needs the document
330 + /// reloaded, which nothing in `quasi_router::Response` says. So the hint says
331 + /// so rather than letting a control look inert, which is the state this whole
332 + /// section was in until 2026-08-22.
322 333 fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
323 334 vec![
324 335 Node::section("Appearance"),
325 336 setting(
326 - choice_field(config, "theme", "Theme", theme_choices(state))
327 - .hint("Choose a color theme for the interface."),
337 + choice_field(config, "theme", "Theme", theme_choices(state)).hint(
338 + "Follow System switches with the desktop, straight away. A \
339 + named theme takes effect the next time GoingsOn starts.",
340 + ),
328 341 ),
329 342 ]
330 343 }
@@ -1,0 +1,166 @@
1 + //! The chosen theme, as a stylesheet the document links.
2 + //!
3 + //! # What this restores
4 + //!
5 + //! Settings > Appearance wrote a theme id and nothing read it. `js/themes.js`
6 + //! had applied the resolved intent set to `:root` with `setProperty`, and it
7 + //! went with the SPA at the swap, so from then until 2026-08-22 the app
8 + //! rendered the "goingson" titular theme whatever the picker said. The `:root`
9 + //! block in `css/styles.css` had stopped being a first-paint default and become
10 + //! the theme, which is the state its own comment denied.
11 + //!
12 + //! # Why a stylesheet rather than a script
13 + //!
14 + //! The MNW server answers the same question with no script at all: resolve the
15 + //! theme to its intent tokens through `makeover::intent_css_vars`, and let the
16 + //! result override the stylesheet's own `:root`. That is one TOML-to-CSS
17 + //! mapping shared by the three apps, and its own header says so. This is that
18 + //! answer in a Tauri window, served at an address instead of inlined in a
19 + //! `<head>` the app does not build per request.
20 + //!
21 + //! # Following the system, without a frontend to ask
22 + //!
23 + //! `commands::themes::resolve_theme` needs an `ambient` argument because
24 + //! `prefers-color-scheme` is a browser fact and Rust cannot see it. A
25 + //! stylesheet can. So a selection of "system" is not resolved here at all: both
26 + //! variants are rendered, the dark one behind the media query, and the browser
27 + //! picks. That is better than what the script did as well as simpler, because
28 + //! the OS switching now repaints immediately rather than at the next launch.
29 + //!
30 + //! # Resolved once
31 + //!
32 + //! Filled from `install` at startup, beside the two `Late` states, because the
33 + //! passthrough that serves it is a closure over no state. So **a pinned theme
34 + //! change takes effect at the next launch**, and following the system is live.
35 + //! Applying a pinned change without relaunching needs the document reloaded,
36 + //! and nothing in `quasi_router::Response` says that: `Goto` is an htmx
37 + //! navigation, which swaps the body and leaves the head alone. Filed on
38 + //! quasicoherent.
39 +
40 + use std::path::PathBuf;
41 + use std::sync::OnceLock;
42 +
43 + use makeover::{ThemeDefaults, ThemeSelection, Variant};
44 +
45 + use crate::state::AppState;
46 +
47 + /// The rendered sheet, resolved at startup.
48 + static SHEET: OnceLock<String> = OnceLock::new();
49 +
50 + /// The address the document links, and the one `assets` answers.
51 + pub const ADDRESS: &str = "/static/theme.css";
52 +
53 + /// The config key Appearance writes.
54 + const KEY: &str = "theme";
55 +
56 + /// The themes GoingsOn falls back to when the user follows the system rather
57 + /// than pinning one.
58 + ///
59 + /// The same pair `commands::themes` names, and for the same reason: what "the
60 + /// light one" means is this app's answer, where everything around it is
61 + /// makeover's.
62 + fn defaults() -> ThemeDefaults {
63 + ThemeDefaults::new("goingson", "catppuccin-mocha")
64 + }
65 +
66 + /// Resolve the stored selection and hold the sheet it renders to.
67 + ///
68 + /// Called once per process, from the same place the protocols' state is handed
69 + /// over. A second call is ignored rather than refused: both entry points build
70 + /// one `AppState` and this follows it.
71 + pub fn install(state: &AppState) {
72 + let selection = crate::commands::all_config(state)
73 + .ok()
74 + .and_then(|config| config.get(KEY).cloned());
75 + let _ = SHEET.set(sheet(&state.theme_dirs, selection.as_deref()));
76 + }
77 +
78 + /// The sheet, or the stock one if a request beats [`install`].
79 + ///
80 + /// The same gap the deferred protocol answers 503 in. A stylesheet has no such
81 + /// answer worth making, and the stock theme is what the fallback would render
82 + /// anyway.
83 + pub fn css() -> &'static str {
84 + SHEET.get().map_or("", String::as_str)
85 + }
86 +
87 + /// The intent tokens for a stored selection, as CSS.
88 + ///
89 + /// `selection` is verbatim from the store: `None` or `"system"` to follow the
90 + /// OS, or a theme id to pin.
91 + fn sheet(dirs: &[(PathBuf, bool)], selection: Option<&str>) -> String {
92 + let available = makeover::list_themes_from_dirs(dirs);
93 + let chosen = ThemeSelection::parse(selection);
94 + let for_variant = |variant| {
95 + let id = chosen.resolve(variant, &defaults(), &available);
96 + makeover::load_semantic(dirs, &id)
97 + .map(|tokens| makeover::intent_css_vars(&tokens))
98 + .unwrap_or_default()
99 + };
100 +
101 + let light = for_variant(Variant::Light);
102 + let dark = for_variant(Variant::Dark);
103 + let banner = "/* The chosen theme's intent tokens, resolved by makeover at\n \
104 + startup from the `theme` config key. Not a file on disk: see\n \
105 + src/quasi/theming.rs. */\n";
106 +
107 + // A pinned theme resolves to itself whichever variant is asked, so the two
108 + // are equal and the media query would be a second copy of the same block.
109 + if light == dark {
110 + return format!("{banner}{light}");
111 + }
112 + format!("{banner}{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
113 + }
114 +
115 + #[cfg(test)]
116 + mod tests {
117 + use super::*;
118 +
119 + /// The tree's own theme directory, which `build.rs` materializes from
120 + /// makeover. Present in a checkout, which is where tests run.
121 + pub(super) fn dirs() -> Vec<(PathBuf, bool)> {
122 + vec![(
123 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
124 + false,
125 + )]
126 + }
127 +
128 + #[test]
129 + fn a_pinned_theme_is_one_root_block_and_no_media_query() {
130 + let css = sheet(&dirs(), Some("goingson"));
131 + assert!(css.contains(":root {"), "{css}");
132 + assert!(
133 + !css.contains("prefers-color-scheme"),
134 + "a pinned theme resolves the same either way, so the query would \
135 + hold a copy of the block above it:\n{css}"
136 + );
137 + }
138 +
139 + #[test]
140 + fn following_the_system_renders_both_variants() {
141 + let css = sheet(&dirs(), Some("system"));
142 + assert!(
143 + css.contains("@media (prefers-color-scheme: dark)"),
144 + "the browser is what picks, because Rust cannot see the \
145 + preference:\n{css}"
146 + );
147 + assert_eq!(css.matches(":root {").count(), 2, "{css}");
148 + }
149 +
150 + /// An unset key means the same thing as "system": the picker's first choice
151 + /// is Follow System and an install that has never touched it is following.
152 + #[test]
153 + fn an_unset_selection_follows_the_system() {
154 + assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some("system")));
155 + }
156 +
157 + /// A theme that was pinned and has since been deleted falls back rather
158 + /// than rendering nothing, which is `ThemeSelection::resolve`'s own
159 + /// promise and is worth holding here because an empty sheet would leave
160 + /// the stylesheet's stale `:root` in charge and look like it worked.
161 + #[test]
162 + fn a_pinned_theme_that_is_gone_falls_back_to_a_real_one() {
163 + let css = sheet(&dirs(), Some("no-such-theme"));
164 + assert!(css.contains("--surface-page"), "{css}");
165 + }
166 + }