Skip to main content

max / audiofiles

Take the described theme picker, and get the grouping and the badge 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-immediate 0.39.0, quasi 0.75. This app was the second consumer of goingson's "a set of choices cannot be grouped" finding, with the stronger case: draw_appearance_section grouped by variant AND sorted within each group by measured contrast, and the port flattened the first and deleted the second outright. Both are back, and neither is this app's work any more. ui::theme's ContrastTier, its badge spelling and theme_contrast_tier are gone. The enum is makeover::ContrastTier, the badge is makeover_layout::Contrast::badge so three renderers cannot invent three of them, and the comparison is makeover::ContrastTier::of -- the crate that resolved the colours, and therefore the only party not parsing every theme file twice. What is left here is contrast_tier, the seam: this app's themes are not all on disk, so it hands the loader the source it holds. The order is makeover::order_theme_options', which is why that half of theme_options is public: a directory scan cannot see the themes this app embeds at compile time, but the sort applies to any set. Two tests changed and one of them is the parity harness: FieldKind::Theme reaches the accessibility tree as the ComboBox it is, so it joins Select and Radio under Role::Choice. Absent from that arm it fell to Role::Text and the shipped picker came back as an unmatched offering.
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:27 UTC
Signed with PGP, not checked
Commit: 613dd2e90ff85847854fcfea188d2eb0d53bb282
Parent: be473de
8 files changed, +221 insertions, -125 deletions
M Cargo.lock +31 -31
@@ -1272,7 +1272,7 @@
1272 1272 "libc",
1273 1273 "option-ext",
1274 1274 "redox_users",
1275 - "windows-sys 0.59.0",
1275 + "windows-sys 0.61.2",
1276 1276 ]
1277 1277
1278 1278 [[package]]
@@ -1617,7 +1617,7 @@
1617 1617 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1618 1618 dependencies = [
1619 1619 "libc",
1620 - "windows-sys 0.59.0",
1620 + "windows-sys 0.61.2",
1621 1621 ]
1622 1622
1623 1623 [[package]]
@@ -3057,9 +3057,9 @@
3057 3057
3058 3058 [[package]]
3059 3059 name = "makeover"
3060 - version = "3.0.0"
3060 + version = "3.1.0"
3061 3061 source = "registry+https://github.com/rust-lang/crates.io-index"
3062 - checksum = "3e6da78c376074e48eb2324394004afc847e674e05ed2ea4bec1331073990b3f"
3062 + checksum = "d0fba4022268d1d73423bb16126dfdaa714a63bd4ff24f826dd645255eb0aad4"
3063 3063 dependencies = [
3064 3064 "include_dir",
3065 3065 "serde",
@@ -3074,9 +3074,9 @@
3074 3074
3075 3075 [[package]]
3076 3076 name = "makeover-immediate"
3077 - version = "0.38.0"
3077 + version = "0.39.0"
3078 3078 source = "registry+https://github.com/rust-lang/crates.io-index"
3079 - checksum = "7ee9cb9fe796db8803518e8726e01708fafd64fc52595fd7d8f040fb9f4345fe"
3079 + checksum = "628f2e478a4bd00a305cc7b43ee1bdc229ccd8dd9c7077ba7d7cc1f85ba6994c"
3080 3080 dependencies = [
3081 3081 "egui",
3082 3082 "egui_extras",
@@ -3086,9 +3086,9 @@
3086 3086
3087 3087 [[package]]
3088 3088 name = "makeover-layout"
3089 - version = "0.37.0"
3089 + version = "0.38.0"
3090 3090 source = "registry+https://github.com/rust-lang/crates.io-index"
3091 - checksum = "369bc3c3e0ee91d24b3552f48f1ecb1764bca953725fb817df33b2dc80f2d34f"
3091 + checksum = "73ad4a03ed87470b838bd84be586739682f0a1210db1d7ad4a0146cb9ef7734b"
3092 3092
3093 3093 [[package]]
3094 3094 name = "makeover-timing"
@@ -3225,7 +3225,7 @@
3225 3225 "once_cell",
3226 3226 "png",
3227 3227 "thiserror 2.0.20",
3228 - "windows-sys 0.60.2",
3228 + "windows-sys 0.61.2",
3229 3229 ]
3230 3230
3231 3231 [[package]]
@@ -3310,7 +3310,7 @@
3310 3310 source = "registry+https://github.com/rust-lang/crates.io-index"
3311 3311 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3312 3312 dependencies = [
3313 - "windows-sys 0.59.0",
3313 + "windows-sys 0.61.2",
3314 3314 ]
3315 3315
3316 3316 [[package]]
@@ -3413,7 +3413,7 @@
3413 3413 source = "registry+https://github.com/rust-lang/crates.io-index"
3414 3414 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
3415 3415 dependencies = [
3416 - "proc-macro-crate 1.3.1",
3416 + "proc-macro-crate 3.5.0",
3417 3417 "proc-macro2",
3418 3418 "quote",
3419 3419 "syn 2.0.119",
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.74.0"
4259 + version = "0.75.0"
4260 4260 dependencies = [
4261 4261 "docengine",
4262 4262 "egui",
@@ -4267,7 +4267,7 @@
4267 4267
4268 4268 [[package]]
4269 4269 name = "quasi-router"
4270 - version = "0.74.0"
4270 + version = "0.75.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -4667,7 +4667,7 @@
4667 4667 "errno",
4668 4668 "libc",
4669 4669 "linux-raw-sys 0.12.1",
4670 - "windows-sys 0.59.0",
4670 + "windows-sys 0.61.2",
4671 4671 ]
4672 4672
4673 4673 [[package]]
@@ -4724,7 +4724,7 @@
4724 4724 "security-framework",
4725 4725 "security-framework-sys",
4726 4726 "webpki-root-certs",
4727 - "windows-sys 0.59.0",
4727 + "windows-sys 0.61.2",
4728 4728 ]
4729 4729
4730 4730 [[package]]
@@ -5120,7 +5120,7 @@
5120 5120 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5121 5121 dependencies = [
5122 5122 "libc",
5123 - "windows-sys 0.60.2",
5123 + "windows-sys 0.61.2",
5124 5124 ]
5125 5125
5126 5126 [[package]]
@@ -5714,10 +5714,10 @@
5714 5714 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
5715 5715 dependencies = [
5716 5716 "fastrand",
5717 - "getrandom 0.3.4",
5717 + "getrandom 0.4.3",
5718 5718 "once_cell",
5719 5719 "rustix 1.1.4",
5720 - "windows-sys 0.59.0",
5720 + "windows-sys 0.61.2",
5721 5721 ]
5722 5722
5723 5723 [[package]]
@@ -6124,7 +6124,7 @@
6124 6124 "once_cell",
6125 6125 "png",
6126 6126 "thiserror 2.0.20",
6127 - "windows-sys 0.60.2",
6127 + "windows-sys 0.61.2",
6128 6128 ]
6129 6129
6130 6130 [[package]]
@@ -6156,7 +6156,7 @@
6156 6156 dependencies = [
6157 6157 "memoffset",
6158 6158 "tempfile",
6159 - "windows-sys 0.60.2",
6159 + "windows-sys 0.61.2",
6160 6160 ]
6161 6161
6162 6162 [[package]]
@@ -6748,7 +6748,7 @@
6748 6748 source = "registry+https://github.com/rust-lang/crates.io-index"
6749 6749 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
6750 6750 dependencies = [
6751 - "windows-sys 0.59.0",
6751 + "windows-sys 0.61.2",
6752 6752 ]
6753 6753
6754 6754 [[package]]
@@ -7556,25 +7556,21 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 - [[patch.unused]]
7560 - name = "quasi-type"
7561 - version = "0.1.1"
7562 -
7563 7559 [[patch.unused]]
7564 7560 name = "quasi-axum"
7565 - version = "0.74.0"
7561 + version = "0.75.0"
7566 7562
7567 7563 [[patch.unused]]
7568 7564 name = "quasi-basics"
7569 - version = "0.74.0"
7565 + version = "0.75.0"
7570 7566
7571 7567 [[patch.unused]]
7572 7568 name = "quasi-http"
7573 - version = "0.74.0"
7569 + version = "0.75.0"
7574 7570
7575 7571 [[patch.unused]]
7576 7572 name = "quasi-notifs"
7577 - version = "0.74.0"
7573 + version = "0.75.0"
7578 7574
7579 7575 [[patch.unused]]
7580 7576 name = "quasi-store"
@@ -7582,11 +7578,11 @@
7582 7578
7583 7579 [[patch.unused]]
7584 7580 name = "quasi-tauri"
7585 - version = "0.74.0"
7581 + version = "0.75.0"
7586 7582
7587 7583 [[patch.unused]]
7588 7584 name = "quasi-webview"
7589 - version = "0.74.0"
7585 + version = "0.75.0"
7590 7586
7591 7587 [[patch.unused]]
7592 7588 name = "kberg"
@@ -7599,3 +7595,7 @@
7599 7595 [[patch.unused]]
7600 7596 name = "painhours"
7601 7597 version = "0.1.0"
7598 +
7599 + [[patch.unused]]
7600 + name = "quasi-type"
7601 + version = "0.1.2"
M Cargo.toml +5 -5
@@ -17,8 +17,8 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.37.0"
21 - makeover-immediate = "0.38.0"
20 + makeover-layout = "0.38.0"
21 + makeover-immediate = "0.39.0"
22 22 # The time axis. Minor rather than exact patch: it re-exports nothing and no
23 23 # type of its crosses a renderer boundary, so it is under the same rule as
24 24 # makeover-geometry above rather than the layout/immediate pair below.
@@ -26,8 +26,8 @@
26 26 # The described screens, which are audiofiles-browser's screens. By git URL with
27 27 # a version requirement, per the tree's rule for cross-repo deps. They were
28 28 # behind a `quasi` feature until 2026-08-25, when the last flip landed.
29 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.74" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.75" }
31 31 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
32 32 egui_extras = { version = "0.35", default-features = false }
33 33 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -72,7 +72,7 @@
72 72 libc = "0.2"
73 73 midir = "0.11"
74 74 tagtree = { git = "https://makenot.work/git/max/makenotwork.git", version = "0.4" }
75 - makeover = "3.0"
75 + makeover = "3.1"
76 76 # The invariant half of the design system: relational spacing. makeover
77 77 # resolves colour, which a theme may override; this carries what no theme may.
78 78 makeover-geometry = "0.7"
@@ -7053,18 +7053,31 @@
7053 7053
7054 7054 /// A theme the host resolved, as the description needs to name it.
7055 7055 ///
7056 - /// Three strings rather than the app's own `ThemeMeta`, so the described screen
7057 - /// does not depend on the shape of the theme loader: what a `Choice` needs is a
7058 - /// value and something to show, and the variant is what the grouping finding is
7059 - /// about.
7056 + /// Its own type rather than the app's own `ThemeMeta`, so the described screen
7057 + /// does not depend on the shape of the theme loader.
7058 + ///
7059 + /// The variant and the contrast tier are values rather than strings as of
7060 + /// makeover-layout 0.38.0, which is the release that gave the picker somewhere
7061 + /// to put them. Before it, the grouping finding recorded here said a set of
7062 + /// choices cannot be grouped and the variant had to ride in the label; the tier
7063 + /// this app measured for its egui picker was lost outright by the port. Both
7064 + /// are carried again, and neither is derived twice: `makeover` resolves them
7065 + /// and this hands them across.
7060 7066 #[derive(Debug, Clone, PartialEq, Eq)]
7061 7067 pub struct ThemeChoice {
7062 7068 /// The id stored under `ConfigKey::Theme`.
7063 7069 pub id: String,
7064 7070 /// What the picker shows.
7065 7071 pub name: String,
7066 - /// `dark`, `light` or `high-contrast`.
7067 - pub variant: String,
7072 + /// Which group the picker files it under.
7073 + pub variant: makeover::Variant,
7074 + /// How legible its muted text measured, for the badge beside the name.
7075 + ///
7076 + /// A host fact on the same footing as [`source`](Self::source): it comes
7077 + /// off the theme's resolved colours, so the party that loaded the theme is
7078 + /// the only one holding it, and a description that recomputed it would be
7079 + /// parsing every theme file a second time.
7080 + pub contrast: makeover::ContrastTier,
7068 7081 /// This theme's TOML, when the host could read it.
7069 7082 ///
7070 7083 /// Here rather than behind a capability method for the reason the rest of
@@ -2817,15 +2817,41 @@
2817 2817 .map_err(|error| error.message.clone())
2818 2818 }
2819 2819
2820 - /// The themes the host has resolved, as the description names them.
2820 + /// The themes the host has resolved, in the order a picker offers them.
2821 + ///
2822 + /// The order is `makeover::order_theme_options`' and not this app's, which is
2823 + /// the point of it being reachable on its own: audiofiles embeds its shipped
2824 + /// themes at compile time and reads only its custom ones off disk, so
2825 + /// `makeover::theme_options` cannot see most of what this offers and the scan
2826 + /// half is no use here. The sort half is, and sharing it is what stops three
2827 + /// apps ordering one picker three ways.
2828 + ///
2829 + /// The contrast tier is measured here rather than carried in the theme file,
2830 + /// which is `makeover::ContrastTier`'s own position: an author cannot
2831 + /// mis-declare it, and a theme edited on disk re-measures on the next read.
2832 + /// This app had its own copy of that measurement for its egui picker and the
2833 + /// quasi port dropped it; what is left is one implementation, in the crate that
2834 + /// resolves the colours.
2821 2835 fn themes() -> Vec<ThemeChoice> {
2822 - theme::list_themes()
2836 + let mut options: Vec<makeover::ThemeOption> = theme::list_themes()
2823 2837 .into_iter()
2824 - .map(|meta| ThemeChoice {
2825 - source: theme::export_theme_content(&meta.id),
2838 + .map(|meta| makeover::ThemeOption {
2839 + variant: meta.kind(),
2840 + contrast: theme::contrast_tier(&meta.id),
2826 2841 id: meta.id,
2827 2842 name: meta.name,
2828 - variant: meta.variant,
2843 + })
2844 + .collect();
2845 + makeover::order_theme_options(&mut options);
2846 +
2847 + options
2848 + .into_iter()
2849 + .map(|option| ThemeChoice {
2850 + source: theme::export_theme_content(&option.id),
2851 + id: option.id,
2852 + name: option.name,
2853 + variant: option.variant,
2854 + contrast: option.contrast,
2829 2855 })
2830 2856 .collect()
2831 2857 }
@@ -239,7 +239,13 @@
239 239 use layout::FieldKind as K;
240 240 match kind {
241 241 K::Checkbox => Role::Check,
242 - K::Select | K::Radio => Role::Choice,
242 + // `Theme` joins them at makeover-layout 0.38.0: it is a box you open,
243 + // and `makeover_immediate::control_shape` sends it to `Control::Themed`
244 + // -- a `ComboBox`, which reaches the tree as the same thing a `Select`
245 + // does. Absent from this arm it fell to `Role::Text`, which is what the
246 + // shipped picker being reported as an unmatched choice would have
247 + // meant.
248 + K::Select | K::Radio | K::Theme => Role::Choice,
243 249 // A slider and an interval's two ends. A bare `Number` is not here:
244 250 // `makeover_immediate::control_shape` sends everything it cannot draw
245 251 // natively to `Control::Typed`, so a number is a well you type into and
@@ -72,16 +72,24 @@
72 72 //! Import Theme and the library mirror are [`advanced`](super::advanced), which
73 73 //! is why this module opens the section and hands the rest over.
74 74 //!
75 - //! # The finding this port adds
75 + //! # The finding this port added, and how it was answered
76 76 //!
77 - //! **A set of choices cannot be grouped.** `draw_appearance_section` builds its
77 + //! **A set of choices cannot be grouped.** `draw_appearance_section` built its
78 78 //! theme picker as four groups (Dark, Light, High Contrast, plus Follow the
79 - //! system) and badges each theme with a contrast tier. [`Choice`] is a value and
80 - //! a label, so the described version puts the variant in the label and loses the
81 - //! structure. That is goingson's own settings finding, and audiofiles is its
79 + //! system) and badged each theme with a contrast tier. `Choice` is a value and
80 + //! a label, so the described version put the variant in the label and lost the
81 + //! structure. That was goingson's own settings finding, and audiofiles was its
82 82 //! **second consumer** with a stronger case: goingson grouped four `<optgroup>`s
83 - //! in a webview, and this one groups *and* sorts within each group by measured
84 - //! contrast.
83 + //! in a webview, and this one grouped *and* sorted within each group by
84 + //! measured contrast.
85 + //!
86 + //! The answer was not a group on the choice, which is what both filings
87 + //! proposed. Grouping was counted across the tree on 2026-08-28 and it recurs
88 + //! at exactly one live site, so what recurs is the theme picker rather than
89 + //! option lists that group. makeover-layout 0.38.0 describes the picker itself
90 + //! (`FieldKind::Theme`, ruled by Max on `70028e00`), and both facts come back:
91 + //! the grouping as a described order, and the tier as a value this app no
92 + //! longer measures for itself.
85 93 //!
86 94 //! # One write route for the whole screen
87 95 //!
@@ -92,10 +100,14 @@
92 100
93 101 use audiofiles_core::config_key::ConfigKey;
94 102 use quasi_router::layout::FieldKind;
103 + use quasi_router::layout::{Contrast, ThemeVariant};
95 104 use quasi_router::{
96 - Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
97 - Slot,
105 + Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot,
98 106 };
107 + // Aliased: this module's `super::ThemeChoice` is the host's own resolved theme,
108 + // and the router's is how a description names one. Both are in scope here
109 + // because this function is exactly the seam between them.
110 + use quasi_router::ThemeChoice as DescribedTheme;
99 111
100 112 use super::Panels;
101 113
@@ -288,32 +300,72 @@
288 300 /// A `Field` and not a `Node::Select`, which is worth saying because the wrong
289 301 /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of
290 302 /// a handful of choices, and a theme picker is thirty-odd options that has to
291 - /// collapse. That is a dropdown, which is `FieldKind::Select`. The vocabulary
292 - /// draws the line at how many there are and whether they fold away, not at what
293 - /// the thing means.
303 + /// collapse. That is a dropdown, and as of makeover-layout 0.38.0 it is
304 + /// `FieldKind::Theme` rather than `FieldKind::Select`.
294 305 ///
295 - /// Flat, and the finding in the module header is why: the shipped picker groups
296 - /// by variant and sorts by contrast tier within each group, and `Choice` carries
297 - /// a value and a label. The variant goes in the label so the fact survives; the
298 - /// structure does not.
306 + /// # The finding in the module header is closed
307 + ///
308 + /// It said a set of choices cannot be grouped, and recorded this app as the
309 + /// **second consumer** of goingson's finding with the stronger case: the
310 + /// shipped egui picker grouped by variant *and* sorted by contrast tier within
311 + /// each group, and `Choice` is a value and a label, so the described version
312 + /// flattened it and lost the tier.
313 + ///
314 + /// The answer was not a group on the choice. Grouping was measured across the
315 + /// tree on 2026-08-28 and it recurs at exactly one live site, so the thing that
316 + /// recurs is this picker rather than option lists that group. `Field::theme`
317 + /// takes entries carrying their variant and their measured tier as values, and
318 + /// both facts come back.
319 + ///
320 + /// Nothing here groups and nothing here sorts. `panel::themes` hands the list
321 + /// over in `makeover::order_theme_options`' order, which is the order the
322 + /// renderer reads the groups out of.
299 323 fn appearance(state: &Panels<'_>) -> Node {
300 324 let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
301 - let options = state
325 + let themes = state
302 326 .themes
303 327 .iter()
304 328 .map(|theme| {
305 - Choice::new(
329 + DescribedTheme::new(
306 330 theme.id.clone(),
307 - format!("{} ({})", theme.name, theme.variant),
331 + theme.name.clone(),
332 + variant_of(theme.variant),
333 + tier_of(theme.contrast),
308 334 )
309 335 })
310 336 .collect();
311 - let mut field = Field::select(ConfigKey::Theme.as_str(), "Theme", options)
337 + let mut field = Field::theme(ConfigKey::Theme.as_str(), "Theme", themes)
312 338 .changes(Action::post("/settings/config/theme"));
313 339 field.value = chosen;
314 340 Node::Field(Box::new(field))
315 341 }
316 342
343 + /// `makeover`'s variant as the description layer's own.
344 + ///
345 + /// The seam the layering costs, and it is three arms. `makeover-layout` takes
346 + /// no dependencies by charter, so it cannot name `makeover::Variant`, and a
347 + /// renderer that groups a picker needs the groups as values. Exhaustive rather
348 + /// than wildcarded on purpose: a fourth ambient mode should stop the build here
349 + /// rather than quietly file itself under dark.
350 + fn variant_of(variant: makeover::Variant) -> ThemeVariant {
351 + match variant {
352 + makeover::Variant::Light => ThemeVariant::Light,
353 + makeover::Variant::Dark => ThemeVariant::Dark,
354 + makeover::Variant::HighContrast => ThemeVariant::HighContrast,
355 + }
356 + }
357 +
358 + /// `makeover`'s measured tier as the description layer's own.
359 + ///
360 + /// [`variant_of`]'s twin, for its reason and with its exhaustiveness.
361 + fn tier_of(tier: makeover::ContrastTier) -> Contrast {
362 + match tier {
363 + makeover::ContrastTier::Low => Contrast::Low,
364 + makeover::ContrastTier::Standard => Contrast::Standard,
365 + makeover::ContrastTier::High => Contrast::High,
366 + }
367 + }
368 +
317 369 /// A boolean setting as a checkbox that writes when it changes.
318 370 ///
319 371 /// `Field::changes` rather than a form: this screen has no submit and never
@@ -597,7 +597,8 @@
597 597 ThemeChoice {
598 598 id: "audiofiles".into(),
599 599 name: "audiofiles".into(),
600 - variant: "light".into(),
600 + variant: makeover::Variant::Light,
601 + contrast: makeover::ContrastTier::High,
601 602 source: Some("[color]\nink = \"#111111\"\n".into()),
602 603 },
603 604 // No source, which is the built-in-with-nothing-to-read case: Export
@@ -605,7 +606,8 @@
605 606 ThemeChoice {
606 607 id: "nord".into(),
607 608 name: "Nord".into(),
608 - variant: "dark".into(),
609 + variant: makeover::Variant::Dark,
610 + contrast: makeover::ContrastTier::Low,
609 611 source: None,
610 612 },
611 613 ]
@@ -1032,18 +1034,28 @@
1032 1034 })
1033 1035 .expect("the screen offers a theme picker");
1034 1036
1035 - assert_eq!(picker.kind, quasi_router::layout::FieldKind::Select);
1037 + // Grouping arrived, so this is the assertion that had to change. It read
1038 + // `FieldKind::Select`, `options.len() == 2`, and that the variant "survived
1039 + // only by riding in the label" -- the finding asserted as the loss it was.
1040 + // makeover-layout 0.38.0's `Field::theme` is what closed it.
1041 + assert_eq!(picker.kind, quasi_router::layout::FieldKind::Theme);
1036 1042 assert_eq!(picker.value.as_deref(), Some("nord"));
1037 - assert_eq!(picker.options.len(), 2);
1038 - // The finding, asserted rather than described in prose: the variant is in
1039 - // the label because `Choice` has nowhere else to put it. When grouping
1040 - // arrives, this assertion is what should have to change.
1043 + // No `Choice`es at all: a theme picker offers themes, and a renderer
1044 + // walking `options` for one would draw an empty control.
1045 + assert!(picker.options.is_empty());
1046 + assert_eq!(picker.themes.len(), 2);
1047 +
1048 + // Both facts the flat list could not carry, now values rather than prose.
1049 + let nord = picker
1050 + .themes
1051 + .iter()
1052 + .find(|theme| theme.id == "nord")
1053 + .expect("the host resolved it");
1054 + assert_eq!(nord.variant, quasi_router::layout::ThemeVariant::Dark);
1055 + assert_eq!(nord.contrast, quasi_router::layout::Contrast::Low);
1041 1056 assert!(
1042 - picker
1043 - .options
1044 - .iter()
1045 - .any(|choice| choice.label.contains("dark")),
1046 - "the variant survived only by riding in the label"
1057 + !nord.name.contains("dark"),
1058 + "the variant is a member now, not a suffix on the name"
1047 1059 );
1048 1060 }
1049 1061
@@ -328,7 +328,7 @@
328 328 // Color-math primitives live in `super::color`; the theme calls them through
329 329 // that seam so the makeover↔egui adapter can be pulled into its own crate if
330 330 // another project ever needs it.
331 - use super::color::{contrast_ratio, is_light, lerp_color};
331 + use super::color::{is_light, lerp_color};
332 332
333 333 // --- Public accessors ---
334 334
@@ -601,51 +601,38 @@
601 601 .map(|(_, content)| (*content).to_string())
602 602 }
603 603
604 - /// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of
605 - /// `content_muted` against the theme's darkest and lightest panel backgrounds. We
606 - /// surface this in the picker rather than overriding curated palettes' colors,
607 - /// so users can choose informed while the themes keep their identity.
608 - #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
609 - pub enum ContrastTier {
610 - /// Muted text below the 3:1 WCAG floor for large text / UI components.
611 - Low,
612 - /// Muted text meets 3:1 but not the 4.5:1 normal-text bar.
613 - Standard,
614 - /// Muted text meets WCAG AA (>= 4.5:1) on every panel surface.
615 - High,
616 - }
617 -
618 - impl ContrastTier {
619 - /// Short badge shown next to the theme name.
620 - pub fn badge(self) -> &'static str {
621 - match self {
622 - ContrastTier::High => "AA",
623 - ContrastTier::Standard => "OK",
624 - ContrastTier::Low => "low",
625 - }
626 - }
627 - }
628 -
629 - /// Load a theme's full color set by id (bundled or custom on disk).
630 - fn theme_colors_for(id: &str) -> Option<ThemeColors> {
631 - parse_theme(&theme_source(id)?).ok()
632 - }
633 -
634 - /// Compute the muted-text contrast tier for a theme id. Defaults to `Standard`
635 - /// if the theme can't be loaded.
636 - pub fn theme_contrast_tier(id: &str) -> ContrastTier {
637 - let Some(c) = theme_colors_for(id) else {
638 - return ContrastTier::Standard;
604 + /// How legible a theme's muted text is, for the badge beside its name.
605 + ///
606 + /// # The measurement moved to `makeover`, and the enum with it
607 + ///
608 + /// This was `ContrastTier` and `theme_contrast_tier` here: an enum, a badge
609 + /// spelling, and the WCAG comparison of `content.muted` against the theme's two
610 + /// panel grounds. All three were this app's alone, drawn in
611 + /// `draw_appearance_section` and lost outright when that picker was described,
612 + /// because a `Choice` is a value and a label and there was nowhere to put a
613 + /// tier.
614 + ///
615 + /// makeover-layout 0.38.0's theme picker is where it went. The enum is
616 + /// `makeover::ContrastTier`, the badge spelling is
617 + /// `makeover_layout::Contrast::badge` so that three renderers cannot invent
618 + /// three of them, and the comparison is `makeover::ContrastTier::of` — which is
619 + /// the crate that resolved the colours, and is therefore the only party not
620 + /// parsing every theme file a second time to learn what it already knew.
621 + ///
622 + /// What is left here is the seam: this app's themes are not all on disk, so it
623 + /// hands the loader the source it holds rather than a directory.
624 + ///
625 + /// `Standard` for a theme whose source is missing or will not parse, which is
626 + /// `ContrastTier::of`'s own answer for a theme it cannot measure: the scan
627 + /// failed, and badging the theme `Low` for that would say something untrue
628 + /// about the theme.
629 + pub fn contrast_tier(id: &str) -> makeover::ContrastTier {
630 + let Some(source) = theme_source(id) else {
631 + return makeover::ContrastTier::Standard;
639 632 };
640 - let worst = contrast_ratio(c.content_muted, c.surface_page)
641 - .min(contrast_ratio(c.content_muted, c.surface_sunken));
642 - if worst >= 4.5 {
643 - ContrastTier::High
644 - } else if worst >= 3.0 {
645 - ContrastTier::Standard
646 - } else {
647 - ContrastTier::Low
648 - }
633 + makeover::parse_theme_str(id, &source, false)
634 + .as_ref()
635 + .map_or(makeover::ContrastTier::Standard, makeover::ContrastTier::of)
649 636 }
650 637
651 638 /// List all available themes (bundled + custom). Custom themes override bundled by ID.