Skip to main content

max / goingson

Take the described theme picker, and get the grouping back makeover-layout 0.38.0's Field::theme (518c650a, ruled by Max on 70028e00), plus the version bumps the cascade carries: makeover 3.1, makeover-layout 0.38.0, makeover-webview 0.65.0, makeover-build 0.58.0, quasi 0.75. theme_choices is gone. It built a Vec<Choice> out of list_themes_from_dirs and put the variant in the label, because Choice is a value and a label and the four optgroups the shipped JS drew had nowhere to go. Its doc filed the fix as "a group on the choice, the same shape in every app that has ever filled a select from more than one source", and the measurement says that was wrong twice over: grouping recurs at exactly one live site in the tree, so what recurs is this picker rather than option lists that group. theme_field replaces it. makeover::theme_options resolves the list, already ordered by variant and then by measured contrast, and this hands it straight across through the three-arm variant_of/tier_of seam that the layering costs. Nothing here sorts and nothing here groups. The contrast badge is new to this screen. It was never derivable here: the tier comes off the resolved colours and this app only ever had the names. The test that asserted the loss is the test that changed. It read `!page.contains("optgroup")`; it now asserts the two groups, the data-contrast attribute, and that the variant is no longer bracketed onto the name. commands/form.rs says themes: &[] for the reason it says accept: &[] -- the JS form spec cannot name a theme kind, so it never carries one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01AAbx8dxVmuVeyKbKoRrUL2
Author: Max Johnson <me@maxj.phd> · 2026-08-28 19:25 UTC
Signed with PGP, not checked
Commit: 975dc627908f6848dafb35716f7127c6da3846f2
Parent: a84cea3
6 files changed, +182 insertions, -96 deletions
M Cargo.lock +41 -41
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.59.0",
1390 + "windows-sys 0.61.2",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.59.0",
1631 + "windows-sys 0.61.2",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -3433,9 +3433,9 @@
3433 3433
3434 3434 [[package]]
3435 3435 name = "makeover"
3436 - version = "3.0.0"
3436 + version = "3.1.0"
3437 3437 source = "registry+https://github.com/rust-lang/crates.io-index"
3438 - checksum = "3e6da78c376074e48eb2324394004afc847e674e05ed2ea4bec1331073990b3f"
3438 + checksum = "d0fba4022268d1d73423bb16126dfdaa714a63bd4ff24f826dd645255eb0aad4"
3439 3439 dependencies = [
3440 3440 "include_dir",
3441 3441 "serde",
@@ -3444,9 +3444,9 @@
3444 3444
3445 3445 [[package]]
3446 3446 name = "makeover-build"
3447 - version = "0.57.0"
3447 + version = "0.58.0"
3448 3448 source = "registry+https://github.com/rust-lang/crates.io-index"
3449 - checksum = "0f8d7b3e487f34397f239cd34820a6f0a02fdd57bdd8c72af851c4e0dd8810ef"
3449 + checksum = "2d1bd0bb12d887506c9366326d6e3d0fe32226c8f3e92c1972b0d12ad317aa3a"
3450 3450 dependencies = [
3451 3451 "makeover",
3452 3452 "makeover-geometry",
@@ -3462,9 +3462,9 @@
3462 3462
3463 3463 [[package]]
3464 3464 name = "makeover-layout"
3465 - version = "0.37.0"
3465 + version = "0.38.0"
3466 3466 source = "registry+https://github.com/rust-lang/crates.io-index"
3467 - checksum = "369bc3c3e0ee91d24b3552f48f1ecb1764bca953725fb817df33b2dc80f2d34f"
3467 + checksum = "73ad4a03ed87470b838bd84be586739682f0a1210db1d7ad4a0146cb9ef7734b"
3468 3468
3469 3469 [[package]]
3470 3470 name = "makeover-timing"
@@ -3477,9 +3477,9 @@
3477 3477
3478 3478 [[package]]
3479 3479 name = "makeover-touch"
3480 - version = "0.27.0"
3480 + version = "0.28.0"
3481 3481 source = "registry+https://github.com/rust-lang/crates.io-index"
3482 - checksum = "b180e22917189e05d291bfec0e930b5e6c2952dc7bf6e2f7a2262b903431cc72"
3482 + checksum = "10693ddd1a7c5aad0289bcee2bb02c25cef10e292820a60a2813d21411217a83"
3483 3483 dependencies = [
3484 3484 "makeover-geometry",
3485 3485 "makeover-layout",
@@ -3487,9 +3487,9 @@
3487 3487
3488 3488 [[package]]
3489 3489 name = "makeover-tui"
3490 - version = "0.37.0"
3490 + version = "0.38.0"
3491 3491 source = "registry+https://github.com/rust-lang/crates.io-index"
3492 - checksum = "b5e2ea93b84f2cb85fde760d360ff4c095320db242a0d0c08cbaf2f396a8de8e"
3492 + checksum = "0b2702bf3d12d4f978607a6209d5ebc9786e46adb13b2ee2c704881b391ec1bd"
3493 3493 dependencies = [
3494 3494 "makeover",
3495 3495 "makeover-layout",
@@ -3499,9 +3499,9 @@
3499 3499
3500 3500 [[package]]
3501 3501 name = "makeover-webview"
3502 - version = "0.64.0"
3502 + version = "0.65.0"
3503 3503 source = "registry+https://github.com/rust-lang/crates.io-index"
3504 - checksum = "6b91db6e169bc8b7c7225e17b6bc83f9c6f1879eb3bc6f1554e96cb621c84e5c"
3504 + checksum = "899e27bcbcf2d8de779c0404ca5e1e354d9ff0fa9d834f1855c5202d0271c80f"
3505 3505 dependencies = [
3506 3506 "makeover-geometry",
3507 3507 "makeover-layout",
@@ -3783,7 +3783,7 @@
3783 3783 source = "registry+https://github.com/rust-lang/crates.io-index"
3784 3784 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3785 3785 dependencies = [
3786 - "windows-sys 0.59.0",
3786 + "windows-sys 0.61.2",
3787 3787 ]
3788 3788
3789 3789 [[package]]
@@ -3891,7 +3891,7 @@
3891 3891 source = "registry+https://github.com/rust-lang/crates.io-index"
3892 3892 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
3893 3893 dependencies = [
3894 - "proc-macro-crate 1.3.1",
3894 + "proc-macro-crate 3.5.0",
3895 3895 "proc-macro2",
3896 3896 "quote",
3897 3897 "syn 2.0.119",
@@ -4717,7 +4717,7 @@
4717 4717
4718 4718 [[package]]
4719 4719 name = "quasi-http"
4720 - version = "0.74.0"
4720 + version = "0.75.0"
4721 4721 dependencies = [
4722 4722 "form_urlencoded",
4723 4723 "http",
@@ -4726,7 +4726,7 @@
4726 4726
4727 4727 [[package]]
4728 4728 name = "quasi-notifs"
4729 - version = "0.74.0"
4729 + version = "0.75.0"
4730 4730 dependencies = [
4731 4731 "quasi-router",
4732 4732 "synckit-config",
@@ -4734,14 +4734,14 @@
4734 4734
4735 4735 [[package]]
4736 4736 name = "quasi-router"
4737 - version = "0.74.0"
4737 + version = "0.75.0"
4738 4738 dependencies = [
4739 4739 "makeover-layout",
4740 4740 ]
4741 4741
4742 4742 [[package]]
4743 4743 name = "quasi-tauri"
4744 - version = "0.74.0"
4744 + version = "0.75.0"
4745 4745 dependencies = [
4746 4746 "http",
4747 4747 "quasi-http",
@@ -4751,7 +4751,7 @@
4751 4751
4752 4752 [[package]]
4753 4753 name = "quasi-type"
4754 - version = "0.1.1"
4754 + version = "0.1.2"
4755 4755 dependencies = [
4756 4756 "brotli",
4757 4757 "font-types",
@@ -4767,7 +4767,7 @@
4767 4767
4768 4768 [[package]]
4769 4769 name = "quasi-webview"
4770 - version = "0.74.0"
4770 + version = "0.75.0"
4771 4771 dependencies = [
4772 4772 "docengine",
4773 4773 "makeover-layout",
@@ -5206,7 +5206,7 @@
5206 5206 "errno",
5207 5207 "libc",
5208 5208 "linux-raw-sys",
5209 - "windows-sys 0.59.0",
5209 + "windows-sys 0.61.2",
5210 5210 ]
5211 5211
5212 5212 [[package]]
@@ -5263,7 +5263,7 @@
5263 5263 "security-framework",
5264 5264 "security-framework-sys",
5265 5265 "webpki-root-certs",
5266 - "windows-sys 0.59.0",
5266 + "windows-sys 0.61.2",
5267 5267 ]
5268 5268
5269 5269 [[package]]
@@ -6575,10 +6575,10 @@
6575 6575 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
6576 6576 dependencies = [
6577 6577 "fastrand",
6578 - "getrandom 0.3.4",
6578 + "getrandom 0.4.3",
6579 6579 "once_cell",
6580 6580 "rustix",
6581 - "windows-sys 0.59.0",
6581 + "windows-sys 0.61.2",
6582 6582 ]
6583 6583
6584 6584 [[package]]
@@ -7647,7 +7647,7 @@
7647 7647 source = "registry+https://github.com/rust-lang/crates.io-index"
7648 7648 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7649 7649 dependencies = [
7650 - "windows-sys 0.59.0",
7650 + "windows-sys 0.61.2",
7651 7651 ]
7652 7652
7653 7653 [[package]]
@@ -8505,21 +8505,21 @@
8505 8505 ]
8506 8506
8507 8507 [[patch.unused]]
8508 - name = "quasi-axum"
8509 - version = "0.74.0"
8510 -
8511 - [[patch.unused]]
8512 - name = "quasi-basics"
8513 - version = "0.74.0"
8514 -
8515 - [[patch.unused]]
8516 - name = "quasi-immediate"
8517 - version = "0.74.0"
8518 -
8519 - [[patch.unused]]
8520 - name = "quasi-store"
8508 + name = "ops-status"
8521 8509 version = "0.1.0"
8522 8510
8523 8511 [[patch.unused]]
8524 - name = "ops-status"
8512 + name = "quasi-axum"
8513 + version = "0.75.0"
8514 +
8515 + [[patch.unused]]
8516 + name = "quasi-basics"
8517 + version = "0.75.0"
8518 +
8519 + [[patch.unused]]
8520 + name = "quasi-immediate"
8521 + version = "0.75.0"
8522 +
8523 + [[patch.unused]]
8524 + name = "quasi-store"
8525 8525 version = "0.1.0"
M Cargo.toml +2 -2
@@ -104,8 +104,8 @@
104 104 # Filesystem watching (db change notifications)
105 105 notify = "8.2"
106 106 notify-debouncer-mini = "0.7"
107 - makeover = "3.0"
108 - makeover-tui = "0.37.0"
107 + makeover = "3.1"
108 + makeover-tui = "0.38.0"
109 109 toml = "1.1"
110 110
111 111 # Enums
@@ -18,7 +18,7 @@
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css,
19 19 # and holds both drift checks this build.rs used to carry itself: touch density
20 20 # as of 0.17, breakpoints as of 0.18.
21 - makeover-build = "0.57.0"
21 + makeover-build = "0.58.0"
22 22 # Cuts the two house faces into frontend/fonts/. A git dependency because
23 23 # quasi-type is `publish = false`, which is also why makeover-build cannot do
24 24 # this and only emits the CSS that fetches the result. The rev is the one
@@ -28,15 +28,15 @@
28 28 # The table CSS is generated here too: the columns are this app's, so the
29 29 # shared helper cannot know them, but the tracks and the narrowing rules come
30 30 # from the description rather than from hand-written nth-child cuts.
31 - makeover-webview = "0.64.0"
32 - makeover-layout = "0.37.0"
31 + makeover-webview = "0.65.0"
32 + makeover-layout = "0.38.0"
33 33 # Width. Direct rather than through makeover-webview, because the narrow table
34 34 # pass keys off SizeClass::Compact and a boundary reached transitively is a
35 35 # boundary nobody pinned.
36 36 makeover-geometry = "0.7"
37 37 # Only to write its selection and clock scripts into the frontend. Those two are
38 38 # the whole of `frontend/js/` since the swap.
39 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
40 40
41 41 [dependencies]
42 42 goingson-core = { workspace = true }
@@ -54,10 +54,10 @@
54 54 # dependency's source even with the feature off -- so a public clone could not
55 55 # build at all against a URL it could not reach. quasi went public that day and
56 56 # the URL moved here with it.
57 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
61 61
62 62 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 63 # watcher is desktop app behaviour rather than a described screen, and the
@@ -74,7 +74,7 @@
74 74 # before it has ported its screens cannot take it. `notifications::Notifier` is
75 75 # the eight lines meanwhile, and it goes when the flip makes quasi-router
76 76 # unconditional here.
77 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.74", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.75", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -139,8 +139,8 @@
139 139 # The forms emitter and the description it renders. Runtime deps, not build:
140 140 # form markup depends on the value, the error and the options at the moment a
141 141 # modal opens, so it cannot be materialised the way the stylesheet is.
142 - makeover-webview = "0.64.0"
143 - makeover-layout = "0.37.0"
142 + makeover-webview = "0.65.0"
143 + makeover-layout = "0.38.0"
144 144 # Browser opening
145 145 open = { workspace = true }
146 146
@@ -194,6 +194,13 @@
194 194 // places.
195 195 placeholder: spec.placeholder.as_deref(),
196 196 options: &choices,
197 + // makeover-layout 0.38.0's theme picker, and the same position as
198 + // the file members below: this JS form spec cannot name a theme
199 + // kind, so it never carries themes. The described settings screen
200 + // is where the picker lives (`quasi::settings`), and it builds one
201 + // out of what the host resolved rather than out of a form spec.
202 + themes: &[],
203 + follows: None,
197 204 // makeover-layout 0.31.0's file members, and the same position:
198 205 // the JS form spec carries no accept list and no multiplicity, so
199 206 // an empty list is what this app actually says. It means any file,
@@ -120,8 +120,8 @@
120 120 use std::collections::HashMap;
121 121
122 122 use quasi_notifs::pane;
123 - use quasi_router::layout::Heading;
124 - use quasi_router::screen::{Choice, Field, Row};
123 + use quasi_router::layout::{Contrast, Heading, ThemeVariant};
124 + use quasi_router::screen::{Choice, Field, Row, ThemeChoice};
125 125 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
126 126
127 127 use crate::notifs::NOTIFS;
@@ -296,36 +296,82 @@
296 296
297 297 /// The themes on offer.
298 298 ///
299 - /// # The first finding
299 + /// # The first finding, and it is closed
300 300 ///
301 - /// **A set of choices cannot be grouped.** `renderAppearance` puts the themes in
302 - /// four `<optgroup>`s — System, Light Themes, Dark Themes, High Contrast — and
303 - /// [`Choice`] is a value and a label. A flat list of twenty themes with no
304 - /// grouping is a worse control than the one it replaces, so the variant goes
305 - /// into the label, which keeps the fact and loses the structure.
301 + /// This section used to record that a set of choices cannot be grouped: the
302 + /// shipped JS put the themes in four `<optgroup>`s, [`Choice`] is a value and a
303 + /// label, so the variant went into the label and the structure was lost. The
304 + /// note filed alongside it proposed a group on the choice, "the same shape in
305 + /// every app that has ever filled a select from more than one source".
306 306 ///
307 - /// Small, and filed rather than worked around further: the honest fix is a
308 - /// group on the choice, and it is the same shape in every app that has ever
309 - /// filled a select from more than one source.
307 + /// Both halves were wrong, and the measurement is what says so. Grouping across
308 + /// the tree was counted on 2026-08-28: `optgroup` appears at exactly one live
309 + /// site, in the one app not yet ported, and the non-theme grouping count is
310 + /// zero. So the recurring thing was never option lists that group. It was this
311 + /// picker, hand-written in three apps.
310 312 ///
311 - /// The list itself is read through [`AppState::theme_dirs`], which is the whole
312 - /// reason this section exists at all — see the module header.
313 - fn theme_choices(state: &AppState) -> Vec<Choice> {
314 - let mut choices = vec![Choice::new("system", "Follow System")];
315 - for theme in makeover::list_themes_from_dirs(&state.theme_dirs) {
316 - let variant = match theme.variant.as_str() {
317 - "high-contrast" => "High Contrast",
318 - "light" => "Light",
319 - // makeover's variant is a string and dark is what everything else
320 - // is, which is the same fallback `getThemesByType` makes.
321 - _ => "Dark",
322 - };
323 - choices.push(Choice::new(
324 - &theme.id,
325 - format!("{} ({variant})", theme.name),
326 - ));
313 + /// What landed instead is [`Field::theme`] (makeover-layout 0.38.0, ruled by
314 + /// Max on `70028e00`): a described theme picker whose entries carry their
315 + /// variant and their measured contrast tier as values rather than as prose. The
316 + /// grouping comes back, and the tier arrives with it — a fact this app never
317 + /// had, because deriving it means resolving every theme's colours and this
318 + /// screen only ever had the names.
319 + ///
320 + /// # Nothing here sorts, and nothing here groups
321 + ///
322 + /// [`makeover::theme_options`] returns the list already ordered by variant and
323 + /// then by measured contrast, which is the order the renderer reads the groups
324 + /// out of. Re-sorting here would be this app deciding a question it cannot see
325 + /// the inputs to, and it is the duplication that put three different orders in
326 + /// three apps in the first place.
327 + ///
328 + /// The list is read through [`AppState::theme_dirs`], which is the whole reason
329 + /// this section exists at all — see the module header.
330 + fn theme_field(state: &AppState, config: &HashMap<String, String>) -> Field {
331 + let themes = makeover::theme_options(&state.theme_dirs)
332 + .into_iter()
333 + .map(|theme| {
334 + ThemeChoice::new(
335 + theme.id,
336 + theme.name,
337 + variant_of(theme.variant),
338 + tier_of(theme.contrast),
339 + )
340 + })
341 + .collect();
342 +
343 + // `makeover::FOLLOW` rather than a literal: the sentinel this app stores is
344 + // the one the crate that resolves it reads back, and spelling it here is
345 + // how the two drift.
346 + Field::theme("theme", "Theme", themes)
347 + .following(Choice::new(makeover::FOLLOW, "Follow System"))
348 + .value(value_of(config, "theme"))
349 + }
350 +
351 + /// `makeover`'s variant as the description layer's own.
352 + ///
353 + /// The seam the layering costs, and it is three arms. `makeover-layout` takes
354 + /// no dependencies by charter, so it cannot name `makeover::Variant`, and a
355 + /// renderer that groups a picker needs the groups as values. Exhaustive rather
356 + /// than wildcarded on purpose: a fourth ambient mode should stop the build here
357 + /// rather than quietly file itself under dark.
358 + fn variant_of(variant: makeover::Variant) -> ThemeVariant {
359 + match variant {
360 + makeover::Variant::Light => ThemeVariant::Light,
361 + makeover::Variant::Dark => ThemeVariant::Dark,
362 + makeover::Variant::HighContrast => ThemeVariant::HighContrast,
363 + }
364 + }
365 +
366 + /// `makeover`'s measured tier as the description layer's own.
367 + ///
368 + /// [`variant_of`]'s twin, for its reason and with its exhaustiveness.
369 + fn tier_of(tier: makeover::ContrastTier) -> Contrast {
370 + match tier {
371 + makeover::ContrastTier::Low => Contrast::Low,
372 + makeover::ContrastTier::Standard => Contrast::Standard,
373 + makeover::ContrastTier::High => Contrast::High,
327 374 }
328 - choices
329 375 }
330 376
331 377 /// Appearance.
@@ -358,12 +404,10 @@
358 404 fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
359 405 vec![
360 406 Node::section("Appearance"),
361 - setting(
362 - choice_field(config, "theme", "Theme", theme_choices(state)).hint(
363 - "Follow System switches with the desktop, straight away. A \
407 + setting(theme_field(state, config).hint(
408 + "Follow System switches with the desktop, straight away. A \
364 409 named theme takes effect the next time GoingsOn starts.",
365 - ),
366 - ),
410 + )),
367 411 ]
368 412 }
369 413
@@ -299,20 +299,55 @@
299 299 }
300 300
301 301 #[tokio::test]
302 - async fn the_theme_list_comes_from_the_state_and_carries_its_variant_in_the_label() {
303 - // The first finding, asserted as the loss it is. The JS groups the themes
304 - // in four optgroups; Choice is a value and a label, so the variant goes
305 - // into the label - the fact kept, the structure gone.
302 + async fn the_theme_list_comes_from_the_state_and_keeps_its_grouping() {
303 + // The first finding, closed. This asserted the loss: the shipped JS grouped
304 + // the themes in four optgroups, `Choice` is a value and a label, so the
305 + // variant went into the label and the structure was gone - and the old
306 + // assertion was `!page.contains("optgroup")`.
306 307 //
307 - // It is also the assertion that the host fact reached the handler at all:
308 - // the search path is built from an AppHandle the handler never sees, and
308 + // makeover-layout 0.38.0's `Field::theme` is what gave it back. The
309 + // grouping is a described fact now rather than markup this app writes, and
310 + // the contrast tier arrives with it, which this screen never had at all.
311 + //
312 + // It is still the assertion that the host fact reached the handler: the
313 + // search path is built from an AppHandle the handler never sees, and
309 314 // AppState holds the result.
310 315 let state = state().await;
311 316 let page = html(get(&state, "/settings/appearance"));
312 317
313 - assert!(page.contains("Follow System"));
314 - assert!(page.contains("(Dark)") || page.contains("(Light)"));
315 - assert!(!page.contains("optgroup"));
318 + assert!(page.contains("Follow System"), "{page}");
319 + assert!(page.contains("optgroup"), "{page}");
320 + assert!(page.contains(r#"<optgroup label="Light""#), "{page}");
321 + assert!(page.contains(r#"<optgroup label="Dark""#), "{page}");
322 + // The variant is no longer bracketed onto the name; the badge is what sits
323 + // there now, and it is the fact the flat list could not carry.
324 + assert!(!page.contains("(Dark)"), "{page}");
325 + assert!(page.contains("data-contrast="), "{page}");
326 + }
327 +
328 + #[tokio::test]
329 + async fn the_theme_picker_orders_each_group_by_measured_contrast() {
330 + // The sort no app can do for itself: the tier comes off the resolved
331 + // colours, so a screen holding only names cannot produce this order. It is
332 + // `makeover::theme_options`' and this asserts it survived the description.
333 + let state = state().await;
334 + let page = html(get(&state, "/settings/appearance"));
335 +
336 + let light = page
337 + .find(r#"<optgroup label="Light""#)
338 + .expect("light group");
339 + let dark = page.find(r#"<optgroup label="Dark""#).expect("dark group");
340 + assert!(light < dark, "light comes first");
341 +
342 + // Within the light group, AA before OK before low.
343 + let group = &page[light..dark];
344 + let tier = |mark: &str| group.find(mark);
345 + if let (Some(aa), Some(low)) = (
346 + tier(r#"data-contrast="high""#),
347 + tier(r#"data-contrast="low""#),
348 + ) {
349 + assert!(aa < low, "the most legible theme leads its group: {group}");
350 + }
316 351 }
317 352
318 353 #[tokio::test]