Skip to main content

max / makeover

3.1.0: measure a theme's contrast tier, and order a picker by it The resolution half of the theme picker (makeover-layout 518c650a, ruled by Max on 70028e00). Three apps hand-wrote this control and each derived its own order; two of them lost the grouping entirely when they were described, and audiofiles lost the contrast badge with it. ContrastTier is the measurement: content.muted against surface.page and surface.sunken, worse of the two, bucketed at WCAG's 3:1 and 4.5:1. Measured rather than authored, so a curated palette keeps its identity and an author cannot mis-declare it. theme_options scans and orders in one call; order_theme_options is the second half on its own, because audiofiles embeds its shipped themes at compile time and a directory scan cannot see most of what it offers. Sharing the sort is what stops three apps ordering one picker three ways. The order is by variant, then by measured contrast best-first, then by name. Grouping is left implicit in the order rather than returned as groups: a renderer that draws headings walks the run of one variant, and one that cannot still gets the useful order. Found while building it: the shipped high-contrast theme measures 4.89 on page and 3.53 on sunken, so it misses AA on one ground of two. Filed as a problem rather than fixed here, and deliberately left out of the regression test that guards the two house themes.
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:06 UTC
Signed with PGP, not checked
Commit: d5c024b633ce0b817fe08b5cb58afeb622f85dd6
Parent: 2cd0255
2 files changed, +278 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "3.0.1"
3 + version = "3.1.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M src/lib.rs +277
@@ -1924,6 +1924,181 @@
1924 1924 themes
1925 1925 }
1926 1926
1927 + /// How legible a theme's muted text is, measured rather than declared.
1928 + ///
1929 + /// The worst WCAG contrast ratio of `content.muted` against the two panel
1930 + /// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
1931 + /// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
1932 + /// because a theme that is legible on one panel and not the other is a theme
1933 + /// with an illegible panel.
1934 + ///
1935 + /// It is measured here rather than authored in the theme file for the reason
1936 + /// the whole crate exists: a curated palette keeps its identity and the reader
1937 + /// still gets told what it costs them. An author cannot mis-declare it, and a
1938 + /// theme edited on disk re-measures on the next scan.
1939 + ///
1940 + /// Ordered worst-first, so `sort` puts the most legible theme last and
1941 + /// [`theme_options`] reverses it into what a picker wants at the top.
1942 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
1943 + #[serde(rename_all = "kebab-case")]
1944 + pub enum ContrastTier {
1945 + /// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
1946 + Low,
1947 + /// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
1948 + Standard,
1949 + /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
1950 + High,
1951 + }
1952 +
1953 + impl ContrastTier {
1954 + /// The machine spelling, for a data attribute or a stored value.
1955 + #[must_use]
1956 + pub const fn as_str(self) -> &'static str {
1957 + match self {
1958 + ContrastTier::Low => "low",
1959 + ContrastTier::Standard => "standard",
1960 + ContrastTier::High => "high",
1961 + }
1962 + }
1963 +
1964 + /// Measure a loaded theme.
1965 + ///
1966 + /// A theme missing either ground or the muted content colour reads as
1967 + /// [`Standard`](Self::Standard): the measurement did not happen, and
1968 + /// claiming `Low` would badge a theme for the scan's failure rather than
1969 + /// its own.
1970 + #[must_use]
1971 + pub fn of(theme: &ThemeColors) -> Self {
1972 + let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
1973 + let (Some(muted), Some(page), Some(sunken)) = (
1974 + colour("content.muted"),
1975 + colour("surface.page"),
1976 + colour("surface.sunken"),
1977 + ) else {
1978 + return ContrastTier::Standard;
1979 + };
1980 +
1981 + let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
1982 + if worst >= 4.5 {
1983 + ContrastTier::High
1984 + } else if worst >= 3.0 {
1985 + ContrastTier::Standard
1986 + } else {
1987 + ContrastTier::Low
1988 + }
1989 + }
1990 + }
1991 +
1992 + /// One theme, as a picker offers it.
1993 + ///
1994 + /// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
1995 + /// the variant as a value rather than a string, and the measured contrast tier.
1996 + /// Owned, because it outlives the directory scan that produced it and is held
1997 + /// by an app across the frames or requests that draw the control.
1998 + ///
1999 + /// It carries no `is_custom`. A picker that sorted the user's own themes apart
2000 + /// from the shipped ones would be answering a different question, and
2001 + /// [`ThemeMeta`] is still there for a screen that wants it.
2002 + #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2003 + #[serde(rename_all = "camelCase")]
2004 + pub struct ThemeOption {
2005 + /// The id stored, and the value the picker submits.
2006 + pub id: String,
2007 + /// What the picker reads.
2008 + pub name: String,
2009 + /// Which group it belongs to.
2010 + pub variant: Variant,
2011 + /// How legible its muted text measured.
2012 + pub contrast: ContrastTier,
2013 + }
2014 +
2015 + /// Every installed theme, in the order a picker should offer them.
2016 + ///
2017 + /// This is the half of a theme picker that is not the control: which themes
2018 + /// exist, which group each is in, how legible each one is, and what order that
2019 + /// puts them in. Three apps derived it three ways and two of them lost it
2020 + /// entirely when their pickers were described, which is what makes it the
2021 + /// crate's job rather than each app's.
2022 + ///
2023 + /// # The order
2024 + ///
2025 + /// By variant in [`Variant`]'s own order — light, dark, high contrast — then
2026 + /// by measured contrast **best first**, then by name. The middle key is the one
2027 + /// no app can supply without redoing the work this crate has already done: the
2028 + /// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
2029 + /// has only the names.
2030 + ///
2031 + /// Grouping is left implicit in the order rather than returned as groups. A
2032 + /// renderer that draws headings walks the run of one variant; one that cannot
2033 + /// draw headings still gets the useful order. Handing back
2034 + /// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
2035 + /// flatten what the first wanted, and neither shape is more true.
2036 + ///
2037 + /// # What it costs
2038 + ///
2039 + /// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
2040 + /// metadata, once here for the colours the tier is measured from. Measured
2041 + /// rather than assumed to be cheap: a picker is drawn on a settings screen, the
2042 + /// shipped set is around twenty files, and the alternative is caching a
2043 + /// derived value that a theme edited on disk would then be wrong about.
2044 + /// A theme whose colours will not load keeps its metadata and reads as
2045 + /// [`ContrastTier::Standard`], on the same footing as one missing a ground.
2046 + ///
2047 + /// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
2048 + /// calls [`order_theme_options`], which is this function's second half.
2049 + #[must_use]
2050 + pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
2051 + let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
2052 + .into_iter()
2053 + .map(|meta| {
2054 + let contrast = load_theme(dirs, &meta.id)
2055 + .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
2056 + ThemeOption {
2057 + variant: meta.kind(),
2058 + contrast,
2059 + id: meta.id,
2060 + name: meta.name,
2061 + }
2062 + })
2063 + .collect();
2064 +
2065 + order_theme_options(&mut options);
2066 + options
2067 + }
2068 +
2069 + /// Put an already-collected set into the order a picker offers them in.
2070 + ///
2071 + /// [`theme_options`]' second half, reachable on its own because not every host
2072 + /// resolves its themes by scanning a directory. audiofiles embeds its shipped
2073 + /// set at compile time and reads only its custom themes off disk, so a
2074 + /// directory scan cannot see most of what it offers, and the alternative to
2075 + /// this being public was that app re-deriving the sort — which is exactly the
2076 + /// three-apps-three-orders state the picker was described to end.
2077 + ///
2078 + /// The order is by variant in [`Variant`]'s own order, then by measured
2079 + /// contrast **best first**, then by name.
2080 + pub fn order_theme_options(options: &mut [ThemeOption]) {
2081 + options.sort_by(|a, b| {
2082 + variant_order(a.variant)
2083 + .cmp(&variant_order(b.variant))
2084 + .then(b.contrast.cmp(&a.contrast))
2085 + .then_with(|| a.name.cmp(&b.name))
2086 + });
2087 + }
2088 +
2089 + /// Where a variant sits in a picker, light first.
2090 + ///
2091 + /// Not `Variant as usize`: the declaration order of an enum is not a promise
2092 + /// about how it reads, and a member inserted for a fourth variant would
2093 + /// silently reorder every picker in the tree.
2094 + const fn variant_order(variant: Variant) -> u8 {
2095 + match variant {
2096 + Variant::Light => 0,
2097 + Variant::Dark => 1,
2098 + Variant::HighContrast => 2,
2099 + }
2100 + }
2101 +
1927 2102 /// Find a theme file by ID in the given directories.
1928 2103 ///
1929 2104 /// Checks directories in reverse order so the highest-priority directory wins.
@@ -3826,4 +4001,106 @@
3826 4001 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
3827 4002 }
3828 4003 }
4004 +
4005 + #[test]
4006 + fn theme_options_groups_by_variant_light_first() {
4007 + let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4008 + let options = theme_options(&dirs);
4009 + assert!(!options.is_empty(), "the shipped set is not empty");
4010 +
4011 + let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect();
4012 + let mut sorted = order.clone();
4013 + sorted.sort_unstable();
4014 + assert_eq!(
4015 + order, sorted,
4016 + "every variant should occupy one run, light first"
4017 + );
4018 + }
4019 +
4020 + #[test]
4021 + fn theme_options_puts_the_most_legible_theme_first_in_its_group() {
4022 + let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4023 + let options = theme_options(&dirs);
4024 +
4025 + for pair in options.windows(2) {
4026 + let (a, b) = (&pair[0], &pair[1]);
4027 + if a.variant != b.variant {
4028 + continue;
4029 + }
4030 + assert!(
4031 + a.contrast >= b.contrast,
4032 + "within {}, {} ({:?}) should not follow {} ({:?})",
4033 + a.variant,
4034 + b.id,
4035 + b.contrast,
4036 + a.id,
4037 + a.contrast
4038 + );
4039 + if a.contrast == b.contrast {
4040 + assert!(
4041 + a.name <= b.name,
4042 + "ties break by name: {} then {}",
4043 + a.name,
4044 + b.name
4045 + );
4046 + }
4047 + }
4048 + }
4049 +
4050 + #[test]
4051 + fn theme_options_carries_every_theme_the_scan_found() {
4052 + let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4053 + let mut scanned: Vec<String> = list_themes_from_dirs(&dirs)
4054 + .into_iter()
4055 + .map(|meta| meta.id)
4056 + .collect();
4057 + let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect();
4058 + scanned.sort();
4059 + offered.sort();
4060 + assert_eq!(scanned, offered, "ordering must not drop a theme");
4061 + }
4062 +
4063 + #[test]
4064 + fn a_theme_that_cannot_be_measured_reads_as_standard() {
4065 + // Not Low: a missing ground is the scan failing, and badging the theme
4066 + // for that would tell the reader something untrue about the theme.
4067 + let theme = ThemeColors {
4068 + meta: ThemeMeta {
4069 + id: "unmeasurable".to_string(),
4070 + name: "Unmeasurable".to_string(),
4071 + variant: "dark".to_string(),
4072 + is_custom: false,
4073 + },
4074 + colors: HashMap::new(),
4075 + };
4076 + assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
4077 + }
4078 +
4079 + #[test]
4080 + fn the_house_themes_measure_high() {
4081 + // The two we author. Measured 2026-08-28: goingson 6.18/7.01 and
4082 + // audiofiles 6.80/4.78 against page and sunken. A change that drops
4083 + // either below AA is a regression in a theme we control, which is
4084 + // exactly what this crate now knows how to see.
4085 + //
4086 + // `high-contrast` is deliberately not in this list. It measures
4087 + // 4.89/3.53 and therefore reads as Standard: its muted text misses AA
4088 + // on its own sunken panel. That is a finding about the theme file, not
4089 + // about the measurement, and it is filed rather than asserted away.
4090 + let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4091 + for id in ["goingson", "audiofiles"] {
4092 + let theme = load_theme(&dirs, id).expect("shipped");
4093 + assert_eq!(
4094 + ContrastTier::of(&theme),
4095 + ContrastTier::High,
4096 + "{id} is one of ours and should meet AA on both grounds"
4097 + );
4098 + }
4099 + }
4100 +
4101 + #[test]
4102 + fn contrast_tiers_order_worst_first() {
4103 + assert!(ContrastTier::Low < ContrastTier::Standard);
4104 + assert!(ContrastTier::Standard < ContrastTier::High);
4105 + }
3829 4106 }