max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+244 insertions,
-14 deletions
| @@ -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 | + | } |