Skip to main content

max / goingson

A pinned theme takes effect at once, not at the next launch goingson d76c9fe2, the goingson half of quasicoherent 1d4f288e. Both blockers landed: makeover 3.2.0 emits every theme keyed by a root attribute, and a quasi Screen can carry document-level facts. The sheet stops encoding the choice. It carries the stored selection unkeyed, which is what the first frame paints before a line of script has run, then one block per choice the picker offers behind :root[data-theme="<id>"]. Follow System gets a keyed block too, and it is not an installed theme: without one, picking it after a pinned start would fall through to the unkeyed blocks, and those are the pin. Its block is the same light-then-dark pair the unkeyed default carries when nothing is pinned, so the browser keeps picking. The attribute is written by host.js, which is where the Screen's document facts could not reach. quasi-webview emits Document::root into <html>, and the answer to this write is swapped in by htmx, which parses the response's <html> away and never touches document.documentElement -- so the renderer cannot reach the root of a document that is already open and the picker can. That is host.js's own test for a job: a described word (FieldKind::Theme) and something genuinely impossible for a renderer. Kept in this app rather than lifted into quasi-webview as a thirteenth shipped script, because the consumer count is one. MNW ships a single theme by construction and audiofiles is egui, which has no document. The hint is deleted rather than reworded. What was left to say is that Follow System follows the system, which the option's own label says.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-08-31 00:52 UTC
Signed with PGP, not checked
Commit: a8c8a291fafe7019702ee03af69d384679343f52
Parent: 9c05fb2
8 files changed, +261 insertions, -59 deletions
@@ -6,6 +6,12 @@
6 6
7 7 ## [Unreleased]
8 8
9 + ### Changed
10 + - Picking a theme in Settings > Appearance changes the app's appearance at once.
11 + The stylesheet now carries every theme, each behind a root attribute, so
12 + choosing one sets an attribute rather than waiting for the next launch. The
13 + hint that said otherwise is gone.
14 +
9 15 ### Added
10 16 - Tasks can block each other. An edge records that one task must be completed
11 17 before another can start, and blocked work no longer reads as available:
M Cargo.toml +1 -1
@@ -103,7 +103,7 @@
103 103 # Filesystem watching (db change notifications)
104 104 notify = "8.2"
105 105 notify-debouncer-mini = "0.7"
106 - makeover = "3.1"
106 + makeover = "3.2"
107 107 makeover-tui = "0.41.0"
108 108 toml = "1.1"
109 109
@@ -305,8 +305,10 @@
305 305 → quasi-webview renders it to HTML, escaping as it goes
306 306 ```
307 307
308 - htmx swaps the body on a navigation. The head is not swapped, which is why a
309 - pinned theme change takes effect at the next launch rather than immediately.
308 + htmx swaps the body on a navigation. The head is not swapped and neither is the
309 + root element, which is why the theme sheet carries every theme keyed by a root
310 + attribute rather than the one in force: switching is `host.js` setting the
311 + attribute, not a stylesheet being re-linked.
310 312
311 313 ## Key Design Decisions
312 314
@@ -54,9 +54,12 @@
54 54 **A theme overrides colour only.** Border width, bevel thickness, radius, type
55 55 and spacing are not reachable from a theme.
56 56
57 - Following the system is live: a selection of "system" renders both variants, the
58 - dark one behind `prefers-color-scheme`, and the browser picks. A pinned theme
59 - change takes effect at the next launch.
57 + Every choice the picker offers is live. `/static/theme.css` carries a block per
58 + theme keyed by `:root[data-theme="<id>"]`, plus one for "system" that renders
59 + both variants with the dark one behind `prefers-color-scheme`, so the browser
60 + keeps picking. The blocks for the stored choice are also emitted unkeyed, which
61 + is what the first frame paints. Picking a theme sets the attribute
62 + (`frontend/js/host.js`) and the new block applies at once.
60 63
61 64 ## Type
62 65
@@ -35,7 +35,15 @@
35 35 * have put the second window outside the description: a bespoke branch here, or
36 36 * a menu item no screen can see.
37 37 *
38 - * The test for a third job is the same one: is there a described word it is
38 + * THE THIRD JOB, added 2026-08-30, is the theme picker, and it passes the same
39 + * test. The described word is `FieldKind::Theme`, and reaching the root of a
40 + * document that is already open is genuinely impossible for the renderer: the
41 + * answer to the write is swapped in by htmx, which parses the response's
42 + * `<html>` away and never touches `document.documentElement`. So the sheet
43 + * carries every theme keyed by `data-theme` (see `src/quasi/theming.rs`) and
44 + * this sets the attribute.
45 + *
46 + * The test for a fourth job is the same one: is there a described word it is
39 47 * answering, and is the thing it does genuinely impossible for a renderer?
40 48 */
41 49 (() => {
@@ -122,10 +130,45 @@
122 130 }
123 131 }
124 132
133 + /* The root attribute makeover keys its per-theme blocks on: its own
134 + * `makeover::THEME_ATTRIBUTE`, which the sheet is emitted from, so a change
135 + * there is a change here.
136 + *
137 + * The value is whatever the picker carries and is passed through unread,
138 + * Follow System included -- that choice has a keyed block of its own, so
139 + * there is never a reason to remove the attribute. Removing it would fall
140 + * back to the unkeyed blocks, and those are whatever was pinned when the
141 + * app started. */
142 + const THEME_ATTRIBUTE = 'data-theme';
143 +
144 + /* The key the picker writes, which is this app's own: the select is named
145 + * for the `user_config` row behind it. See `quasi::settings`. */
146 + const THEME_KEY = 'theme';
147 +
148 + /* Apply a theme to the open document.
149 + *
150 + * Nothing is validated. A value naming no theme matches no keyed block and
151 + * the unkeyed ones stay in force, which is the same fallback the sheet
152 + * already gives a pinned theme that has since been uninstalled. */
153 + function wear(value) {
154 + document.documentElement.setAttribute(THEME_ATTRIBUTE, value);
155 + }
156 +
125 157 /* Delegated, because a fragment swap replaces the elements: a listener bound
126 158 * to each control at load would be gone the first time its region answered.
127 159 * The same reason the described screens use htmx rather than per-element
128 160 * wiring. */
161 + document.addEventListener('change', (event) => {
162 + const select = event.target;
163 + if (!select || select.name !== THEME_KEY) return;
164 + /* Before the write, not after it. The request is in flight and its
165 + * answer is the section re-read; waiting for that would put a visible
166 + * delay between the pick and the colour, for no fact the answer
167 + * carries. The store is what the next launch reads, and it is being
168 + * written either way. */
169 + wear(select.value);
170 + });
171 +
129 172 document.addEventListener('click', (event) => {
130 173 const element = event.target.closest('[data-sends], [data-mount]');
131 174 if (!element) return;
@@ -391,23 +391,20 @@
391 391 /// same finding wearing different clothes: the description can say what to do
392 392 /// and cannot reach what the host knows.
393 393 ///
394 - /// # The hint says when, because the two answers differ
394 + /// # There is no hint any more, because there is nothing left to apologise for
395 395 ///
396 - /// The choice is served as a stylesheet, `super::theming`, resolved at startup
397 - /// from this key. Following the system is live, because the browser is what
398 - /// picks between the two variants the sheet carries and it re-picks the moment
399 - /// the desktop changes. A named theme is not: the sheet is rendered once and
400 - /// the document links it once, and applying a new one needs the document
401 - /// reloaded, which nothing in `quasi_router::Response` says. So the hint says
402 - /// so rather than letting a control look inert, which is the state this whole
403 - /// section was in until 2026-08-22.
396 + /// There was one, and it said a named theme took effect the next time GoingsOn
397 + /// started. That was true while the sheet held one theme and the document
398 + /// linked it once. It holds every theme now, keyed by a root attribute
399 + /// (`super::theming`), and picking one sets the attribute, so every choice on
400 + /// this control lands at once and none of them is worth explaining.
401 + ///
402 + /// Deleted rather than reworded. The remaining sentence would have said that
403 + /// Follow System follows the system, which the option's own label says.
404 404 fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
405 405 vec![
406 406 Node::section("Appearance"),
407 - setting(theme_field(state, config).hint(
408 - "Follow System switches with the desktop, straight away. A \
409 - named theme takes effect the next time GoingsOn starts.",
410 - )),
407 + setting(theme_field(state, config)),
411 408 ]
412 409 }
413 410
@@ -27,15 +27,34 @@
27 27 //! picks. That is better than what the script did as well as simpler, because
28 28 //! the OS switching now repaints immediately rather than at the next launch.
29 29 //!
30 + //! # Every theme, and the one in force
31 + //!
32 + //! The sheet carries two things. First the blocks for the stored selection,
33 + //! unkeyed, which is what the document paints before a line of script has run
34 + //! and is why a pinned theme is right on the first frame. Then one block per
35 + //! choice the picker offers, keyed by `makeover::THEME_ATTRIBUTE` on the root
36 + //! element, so a choice can be applied by setting an attribute instead of by
37 + //! re-linking a stylesheet.
38 + //!
39 + //! The keyed set includes `makeover::FOLLOW`, which is not an installed theme
40 + //! and needs a block anyway: without one, picking Follow System after a pinned
41 + //! start would fall back to the unkeyed blocks, and those are the pin. Its
42 + //! block is the pair the unkeyed default carries when nothing is pinned --
43 + //! light, then dark behind the media query -- so the browser keeps picking.
44 + //!
45 + //! `frontend/js/host.js` is what writes the attribute; see its third job. It
46 + //! has to be a script because the answer to the write is swapped into the page
47 + //! by htmx, which parses the response's `<html>` away and never touches
48 + //! `document.documentElement`. So the renderer cannot reach the root of a
49 + //! document that is already open, and the picker can.
50 + //!
30 51 //! # Resolved once
31 52 //!
32 53 //! 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.
54 + //! passthrough that serves it is a closure over no state. The sheet is a
55 + //! function of the theme directories and the stored selection, and the
56 + //! selection only decides which blocks are unkeyed: a change lands through the
57 + //! attribute at once, and is read back here on the next launch.
39 58
40 59 use std::path::PathBuf;
41 60 use std::sync::OnceLock;
@@ -84,32 +103,107 @@
84 103 SHEET.get().map_or("", String::as_str)
85 104 }
86 105
87 - /// The intent tokens for a stored selection, as CSS.
106 + /// The intent tokens for a stored selection, as CSS, followed by a keyed block
107 + /// per choice the picker offers.
88 108 ///
89 - /// `selection` is verbatim from the store: `None` or `"system"` to follow the
90 - /// OS, or a theme id to pin.
109 + /// `selection` is verbatim from the store: `None` or `makeover::FOLLOW` to
110 + /// follow the OS, or a theme id to pin.
91 111 fn sheet(dirs: &[(PathBuf, bool)], selection: Option<&str>) -> String {
92 112 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 - };
113 + let banner = "/* Every theme GoingsOn offers, keyed by the root attribute, with\n \
114 + the stored choice unkeyed on top. Rendered by makeover at\n \
115 + startup. Not a file on disk: see src/quasi/theming.rs. */\n";
100 116
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";
117 + let mut out = String::from(banner);
118 + out.push_str(&unkeyed(
119 + dirs,
120 + &available,
121 + &ThemeSelection::parse(selection),
122 + ));
123 + out.push_str(&keyed(dirs, &available));
124 + out
125 + }
106 126
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.
127 + /// The blocks that apply when the root element names no theme: the stored
128 + /// selection, resolved.
129 + ///
130 + /// What the document paints before any script runs. A pinned theme resolves to
131 + /// itself whichever variant is asked, so the two are equal and the media query
132 + /// would be a second copy of the block above it.
133 + fn unkeyed(
134 + dirs: &[(PathBuf, bool)],
135 + available: &[makeover::ThemeMeta],
136 + chosen: &ThemeSelection,
137 + ) -> String {
138 + let light = vars_for(dirs, available, chosen, Variant::Light);
139 + let dark = vars_for(dirs, available, chosen, Variant::Dark);
109 140 if light == dark {
110 - return format!("{banner}{light}");
141 + return light;
111 142 }
112 - format!("{banner}{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
143 + format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}}}\n")
144 + }
145 +
146 + /// One block per choice the picker offers, each behind the root attribute.
147 + ///
148 + /// Every installed theme, plus [`makeover::FOLLOW`], which is a choice and not
149 + /// a theme: it is emitted as the same light-then-dark pair the unkeyed default
150 + /// carries when nothing is pinned, so picking it hands the decision back to the
151 + /// browser rather than to whatever was pinned when the app started.
152 + ///
153 + /// Ordered by id, then Follow last, so two builds of the same directories emit
154 + /// the same bytes.
155 + fn keyed(dirs: &[(PathBuf, bool)], available: &[makeover::ThemeMeta]) -> String {
156 + let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
157 + ids.sort_unstable();
158 +
159 + let mut out = String::new();
160 + for id in ids {
161 + // A theme that will not load costs its own block and nothing else: the
162 + // directories include a user-writable one, and one bad file there is
163 + // not a reason to serve a sheet with no colours in it.
164 + if let Ok(tokens) = makeover::load_semantic(dirs, id) {
165 + out.push('\n');
166 + out.push_str(&makeover::keyed_intent_css_vars(id, &tokens));
167 + }
168 + }
169 +
170 + let follow = ThemeSelection::Follow;
171 + let light = vars_keyed(dirs, available, &follow, Variant::Light);
172 + let dark = vars_keyed(dirs, available, &follow, Variant::Dark);
173 + out.push('\n');
174 + out.push_str(&light);
175 + if dark != light {
176 + out.push_str("\n@media (prefers-color-scheme: dark) {\n");
177 + out.push_str(&dark);
178 + out.push_str("}\n");
179 + }
180 + out
181 + }
182 +
183 + /// One variant of a selection as an unkeyed `:root` block.
184 + fn vars_for(
185 + dirs: &[(PathBuf, bool)],
186 + available: &[makeover::ThemeMeta],
187 + chosen: &ThemeSelection,
188 + variant: Variant,
189 + ) -> String {
190 + let id = chosen.resolve(variant, &defaults(), available);
191 + makeover::load_semantic(dirs, &id)
192 + .map(|tokens| makeover::intent_css_vars(&tokens))
193 + .unwrap_or_default()
194 + }
195 +
196 + /// One variant of a selection as a block keyed to [`makeover::FOLLOW`].
197 + fn vars_keyed(
198 + dirs: &[(PathBuf, bool)],
199 + available: &[makeover::ThemeMeta],
200 + chosen: &ThemeSelection,
201 + variant: Variant,
202 + ) -> String {
203 + let id = chosen.resolve(variant, &defaults(), available);
204 + makeover::load_semantic(dirs, &id)
205 + .map(|tokens| makeover::keyed_intent_css_vars(makeover::FOLLOW, &tokens))
206 + .unwrap_or_default()
113 207 }
114 208
115 209 #[cfg(test)]
@@ -126,8 +220,12 @@
126 220 }
127 221
128 222 #[test]
129 - fn a_pinned_theme_is_one_root_block_and_no_media_query() {
130 - let css = sheet(&dirs(), Some("goingson"));
223 + fn a_pinned_theme_is_one_unkeyed_block_and_no_media_query() {
224 + let css = unkeyed(
225 + &dirs(),
226 + &makeover::list_themes_from_dirs(&dirs()),
227 + &ThemeSelection::parse(Some("goingson")),
228 + );
131 229 assert!(css.contains(":root {"), "{css}");
132 230 assert!(
133 231 !css.contains("prefers-color-scheme"),
@@ -138,7 +236,11 @@
138 236
139 237 #[test]
140 238 fn following_the_system_renders_both_variants() {
141 - let css = sheet(&dirs(), Some("system"));
239 + let css = unkeyed(
240 + &dirs(),
241 + &makeover::list_themes_from_dirs(&dirs()),
242 + &ThemeSelection::parse(Some(makeover::FOLLOW)),
243 + );
142 244 assert!(
143 245 css.contains("@media (prefers-color-scheme: dark)"),
144 246 "the browser is what picks, because Rust cannot see the \
@@ -147,11 +249,61 @@
147 249 assert_eq!(css.matches(":root {").count(), 2, "{css}");
148 250 }
149 251
252 + /// The whole point of the sheet: every theme is in it, whichever one is
253 + /// stored, so switching is an attribute rather than a second request.
254 + #[test]
255 + fn every_installed_theme_has_a_keyed_block() {
256 + let css = sheet(&dirs(), Some("goingson"));
257 + let installed = makeover::list_themes_from_dirs(&dirs());
258 + assert!(!installed.is_empty(), "the checkout has themes");
259 + for theme in &installed {
260 + let block = format!(":root[{}=\"{}\"]", makeover::THEME_ATTRIBUTE, theme.id);
261 + assert!(css.contains(&block), "{} has no keyed block", theme.id);
262 + }
263 + }
264 +
265 + /// Follow System is a choice and not a theme, and it needs a keyed block
266 + /// for that reason: picking it after a pinned start must hand the decision
267 + /// back to the browser rather than fall through to the pin.
268 + #[test]
269 + fn following_the_system_is_keyed_too_and_carries_both_variants() {
270 + let css = sheet(&dirs(), Some("goingson"));
271 + let block = format!(
272 + ":root[{}=\"{}\"]",
273 + makeover::THEME_ATTRIBUTE,
274 + makeover::FOLLOW
275 + );
276 + assert_eq!(css.matches(&block).count(), 2, "{css}");
277 + // The second of the two is behind the query, which is what makes it
278 + // follow rather than pin the light theme.
279 + let dark = css
280 + .rfind("@media (prefers-color-scheme: dark)")
281 + .expect("a dark query");
282 + assert!(css[dark..].contains(&block), "{}", &css[dark..]);
283 + }
284 +
285 + /// A pin is unkeyed as well as keyed, so the first frame is right before
286 + /// any script has run.
287 + #[test]
288 + fn the_stored_choice_is_what_applies_with_no_attribute_set() {
289 + let pinned = sheet(&dirs(), Some("catppuccin-latte"));
290 + let bare = pinned
291 + .split(&format!(":root[{}", makeover::THEME_ATTRIBUTE))
292 + .next()
293 + .expect("the unkeyed half");
294 + let alone = unkeyed(
295 + &dirs(),
296 + &makeover::list_themes_from_dirs(&dirs()),
297 + &ThemeSelection::parse(Some("catppuccin-latte")),
298 + );
299 + assert!(bare.contains(alone.trim_end()), "{bare}");
300 + }
301 +
150 302 /// An unset key means the same thing as "system": the picker's first choice
151 303 /// is Follow System and an install that has never touched it is following.
152 304 #[test]
153 305 fn an_unset_selection_follows_the_system() {
154 - assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some("system")));
306 + assert_eq!(sheet(&dirs(), None), sheet(&dirs(), Some(makeover::FOLLOW)));
155 307 }
156 308
157 309 /// A theme that was pinned and has since been deleted falls back rather
@@ -163,4 +315,13 @@
163 315 let css = sheet(&dirs(), Some("no-such-theme"));
164 316 assert!(css.contains("--surface-page"), "{css}");
165 317 }
318 +
319 + /// Byte-stable, which is what lets the sheet be rendered once and held.
320 + #[test]
321 + fn the_sheet_is_the_same_bytes_twice() {
322 + assert_eq!(
323 + sheet(&dirs(), Some("goingson")),
324 + sheet(&dirs(), Some("goingson"))
325 + );
326 + }
166 327 }
@@ -1182,13 +1182,3 @@
1182 1182 "{pane}"
1183 1183 );
1184 1184 }
1185 -
1186 - #[tokio::test]
1187 - async fn tmp_dump_appearance() {
1188 - let state = state().await;
1189 - let page = html(get(&state, "/settings/appearance"));
1190 - let at = page
1191 - .find("name=\"theme\"")
1192 - .map_or(0, |i| i.saturating_sub(1200));
1193 - eprintln!("---\n{}\n---", &page[at..(at + 2400).min(page.len())]);
1194 - }