makeover 3.5.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
//! Choosing a theme.
//!
//! The file half of this crate was always shared; the *selection* half was not,
//! and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
//! localStorage, Balanced Breakfast treats an absent value as follow-the-system
//! and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
//! in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
//! disagreed about what a variant string means: this crate defaults a missing
//! one to "dark" while alloy_tui parsed an unrecognized one as light.
//!
//! What cannot be shared is the store — localStorage, a synced config table and
//! a TOML file are genuinely different places. What can be shared, and is here,
//! is the *meaning*: one vocabulary for variants, one encoding for "what did the
//! user choose", and one rule for turning that into an id that exists.

use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast};
use serde::Serialize;
use std::path::PathBuf;

// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::parse_meta;

/// A theme's kind, as declared by `meta.variant`.
///
/// Three, not two: one shipped theme is `high-contrast`, and an app that
/// matched on light-or-dark alone would quietly file it under the wrong one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
    Light,
    Dark,
    HighContrast,
}

impl Variant {
    /// The spelling used in a theme file and in [`ThemeMeta::variant`].
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Variant::Light => "light",
            Variant::Dark => "dark",
            Variant::HighContrast => "high-contrast",
        }
    }

    /// Read a variant string, or `None` if it names none of them.
    #[must_use]
    pub fn parse(raw: &str) -> Option<Self> {
        match raw {
            "light" => Some(Variant::Light),
            "dark" => Some(Variant::Dark),
            "high-contrast" => Some(Variant::HighContrast),
            _ => None,
        }
    }
}

impl std::fmt::Display for Variant {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Anything unrecognized reads as dark, which is what [`parse_meta`] already
/// does with a missing one. Consumers that guessed light for an unknown string
/// were disagreeing with the crate that produced it.
impl From<&str> for Variant {
    fn from(raw: &str) -> Self {
        Variant::parse(raw).unwrap_or(Variant::Dark)
    }
}

impl ThemeMeta {
    /// This theme's variant as a value rather than a string.
    #[must_use]
    pub fn kind(&self) -> Variant {
        Variant::from(self.variant.as_str())
    }
}

/// The spelling of "follow whatever the system is doing", in every store.
pub const FOLLOW: &str = "system";

/// What the user chose, as opposed to what is being rendered.
///
/// The distinction is the whole point: `Follow` is a standing instruction that
/// resolves differently as the ambient mode changes, and a `Fixed` id is an
/// answer that does not. An app that stored only the rendered id could not tell
/// the two apart the next time the system flipped to dark.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ThemeSelection {
    /// Track the ambient light/dark mode.
    #[default]
    Follow,
    /// Always this theme.
    Fixed(String),
}

impl ThemeSelection {
    /// Read a stored selection. An empty or absent value is [`Follow`], which
    /// is what an app with nothing saved yet should do.
    ///
    /// [`Follow`]: ThemeSelection::Follow
    #[must_use]
    pub fn parse(raw: Option<&str>) -> Self {
        match raw.map(str::trim) {
            None | Some("" | FOLLOW) => ThemeSelection::Follow,
            Some(id) => ThemeSelection::Fixed(id.to_string()),
        }
    }

    /// The string to persist, whatever the store is.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            ThemeSelection::Follow => FOLLOW,
            ThemeSelection::Fixed(id) => id,
        }
    }

    /// Turn a selection into a theme id that exists.
    ///
    /// `ambient` is the light/dark mode the app learned however it can: a
    /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
    /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
    ///
    /// A `Fixed` id that is no longer on disk falls through to the same path as
    /// `Follow` rather than being returned anyway. Themes are deletable in
    /// three of the four apps, and handing back an id that will fail to load
    /// only moves the error somewhere less helpful.
    ///
    /// The fallback chain is: the app's own default for the ambient mode if it
    /// is installed, then any installed theme of that variant, then the app's
    /// default regardless. The last step means this always returns something,
    /// and an app with no theme directory at all gets the id it ships with and
    /// the load error it would have had anyway.
    #[must_use]
    pub fn resolve(
        &self,
        ambient: Variant,
        defaults: &ThemeDefaults,
        available: &[ThemeMeta],
    ) -> String {
        let installed = |id: &str| available.iter().any(|meta| meta.id == id);

        if let ThemeSelection::Fixed(id) = self
            && installed(id)
        {
            return id.clone();
        }

        let preferred = defaults.for_variant(ambient);
        if installed(preferred) {
            return preferred.to_string();
        }
        available
            .iter()
            .find(|meta| meta.kind() == ambient)
            .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
    }
}

impl std::fmt::Display for ThemeSelection {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// The themes an app falls back to, one per ambient mode.
///
/// App-specific on purpose: which theme is "the app's own" is the app's
/// identity, not this crate's business. What is shared is everything around it.
#[derive(Debug, Clone)]
pub struct ThemeDefaults {
    light: String,
    dark: String,
    high_contrast: Option<String>,
}

impl ThemeDefaults {
    pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
        Self {
            light: light.into(),
            dark: dark.into(),
            high_contrast: None,
        }
    }

    /// Name a theme for a high-contrast ambient mode. Without one, that mode
    /// falls back to the dark default, which is the safer of the two to read.
    #[must_use]
    pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
        self.high_contrast = Some(id.into());
        self
    }

    /// Whether a high-contrast default was named.
    ///
    /// [`for_variant`] answers for every mode by falling back to the dark
    /// theme, which is right for resolving a selection and wrong for emitting
    /// a `prefers-contrast: more` block: that block would then answer the
    /// preference with a theme that does not honour it. A caller that renders
    /// per ambient mode asks this first.
    ///
    /// [`for_variant`]: ThemeDefaults::for_variant
    #[must_use]
    pub const fn names_high_contrast(&self) -> bool {
        self.high_contrast.is_some()
    }

    #[must_use]
    pub fn for_variant(&self, variant: Variant) -> &str {
        match variant {
            Variant::Light => &self.light,
            Variant::Dark => &self.dark,
            Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
        }
    }
}

/// How legible a theme's muted text is, measured rather than declared.
///
/// The worst WCAG contrast ratio of `content.muted` against the two panel
/// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
/// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
/// because a theme that is legible on one panel and not the other is a theme
/// with an illegible panel.
///
/// It is measured here rather than authored in the theme file for the reason
/// the whole crate exists: a curated palette keeps its identity and the reader
/// still gets told what it costs them. An author cannot mis-declare it, and a
/// theme edited on disk re-measures on the next scan.
///
/// Ordered worst-first, so `sort` puts the most legible theme last and
/// [`theme_options`] reverses it into what a picker wants at the top.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContrastTier {
    /// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
    Low,
    /// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
    Standard,
    /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
    High,
}

impl ContrastTier {
    /// The machine spelling, for a data attribute or a stored value.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            ContrastTier::Low => "low",
            ContrastTier::Standard => "standard",
            ContrastTier::High => "high",
        }
    }

    /// Measure a loaded theme.
    ///
    /// A theme missing either ground or the muted content colour reads as
    /// [`Standard`](Self::Standard): the measurement did not happen, and
    /// claiming `Low` would badge a theme for the scan's failure rather than
    /// its own.
    #[must_use]
    pub fn of(theme: &ThemeColors) -> Self {
        let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
        let (Some(muted), Some(page), Some(sunken)) = (
            colour("content.muted"),
            colour("surface.page"),
            colour("surface.sunken"),
        ) else {
            return ContrastTier::Standard;
        };

        let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
        if worst >= 4.5 {
            ContrastTier::High
        } else if worst >= 3.0 {
            ContrastTier::Standard
        } else {
            ContrastTier::Low
        }
    }
}

/// One theme, as a picker offers it.
///
/// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
/// the variant as a value rather than a string, and the measured contrast tier.
/// Owned, because it outlives the directory scan that produced it and is held
/// by an app across the frames or requests that draw the control.
///
/// It carries no `is_custom`. A picker that sorted the user's own themes apart
/// from the shipped ones would be answering a different question, and
/// [`ThemeMeta`] is still there for a screen that wants it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOption {
    /// The id stored, and the value the picker submits.
    pub id: String,
    /// What the picker reads.
    pub name: String,
    /// Which group it belongs to.
    pub variant: Variant,
    /// How legible its muted text measured.
    pub contrast: ContrastTier,
}

/// Every installed theme, in the order a picker should offer them.
///
/// This is the half of a theme picker that is not the control: which themes
/// exist, which group each is in, how legible each one is, and what order that
/// puts them in. Three apps derived it three ways and two of them lost it
/// entirely when their pickers were described, which is what makes it the
/// crate's job rather than each app's.
///
/// # The order
///
/// By variant in [`Variant`]'s own order — light, dark, high contrast — then
/// by measured contrast **best first**, then by name. The middle key is the one
/// no app can supply without redoing the work this crate has already done: the
/// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
/// has only the names.
///
/// Grouping is left implicit in the order rather than returned as groups. A
/// renderer that draws headings walks the run of one variant; one that cannot
/// draw headings still gets the useful order. Handing back
/// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
/// flatten what the first wanted, and neither shape is more true.
///
/// # What it costs
///
/// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
/// metadata, once here for the colours the tier is measured from. Measured
/// rather than assumed to be cheap: a picker is drawn on a settings screen, the
/// shipped set is around twenty files, and the alternative is caching a
/// derived value that a theme edited on disk would then be wrong about.
/// A theme whose colours will not load keeps its metadata and reads as
/// [`ContrastTier::Standard`], on the same footing as one missing a ground.
///
/// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
/// calls [`order_theme_options`], which is this function's second half.
#[must_use]
pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
    let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
        .into_iter()
        .map(|meta| {
            let contrast = load_theme(dirs, &meta.id)
                .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
            ThemeOption {
                variant: meta.kind(),
                contrast,
                id: meta.id,
                name: meta.name,
            }
        })
        .collect();

    order_theme_options(&mut options);
    options
}

/// Put an already-collected set into the order a picker offers them in.
///
/// [`theme_options`]' second half, reachable on its own because not every host
/// resolves its themes by scanning a directory. audiofiles embeds its shipped
/// set at compile time and reads only its custom themes off disk, so a
/// directory scan cannot see most of what it offers, and the alternative to
/// this being public was that app re-deriving the sort — which is exactly the
/// three-apps-three-orders state the picker was described to end.
///
/// The order is by variant in [`Variant`]'s own order, then by measured
/// contrast **best first**, then by name.
pub fn order_theme_options(options: &mut [ThemeOption]) {
    options.sort_by(|a, b| {
        variant_order(a.variant)
            .cmp(&variant_order(b.variant))
            .then(b.contrast.cmp(&a.contrast))
            .then_with(|| a.name.cmp(&b.name))
    });
}

/// Where a variant sits in a picker, light first.
///
/// Not `Variant as usize`: the declaration order of an enum is not a promise
/// about how it reads, and a member inserted for a fourth variant would
/// silently reorder every picker in the tree.
const fn variant_order(variant: Variant) -> u8 {
    match variant {
        Variant::Light => 0,
        Variant::Dark => 1,
        Variant::HighContrast => 2,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bundled_themes_dir;
    use std::collections::HashMap;

    fn meta(id: &str, variant: &str) -> ThemeMeta {
        ThemeMeta {
            id: id.to_string(),
            name: id.to_string(),
            variant: variant.to_string(),
            is_custom: false,
        }
    }

    fn defaults() -> ThemeDefaults {
        ThemeDefaults::new("flatwhite", "nord")
    }

    // The three the shipped themes actually declare.
    #[test]
    fn every_shipped_variant_parses() {
        assert_eq!(Variant::parse("light"), Some(Variant::Light));
        assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
        assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
        assert_eq!(Variant::parse("sepia"), None);
    }

    // parse_meta already defaults a *missing* variant to dark, so an
    // unrecognized one reading as light would have the crate disagreeing with
    // itself. alloy_tui did exactly that before this existed.
    #[test]
    fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
        assert_eq!(Variant::from("sepia"), Variant::Dark);
        assert_eq!(Variant::from(""), Variant::Dark);

        let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
        assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
    }

    #[test]
    fn a_selection_round_trips_through_any_store() {
        for (stored, expect) in [
            (Some("system"), ThemeSelection::Follow),
            (None, ThemeSelection::Follow),
            (Some(""), ThemeSelection::Follow),
            (Some("  "), ThemeSelection::Follow),
            (Some("nord"), ThemeSelection::Fixed("nord".into())),
        ] {
            let parsed = ThemeSelection::parse(stored);
            assert_eq!(parsed, expect, "{stored:?}");
            assert_eq!(
                ThemeSelection::parse(Some(parsed.as_str())),
                expect,
                "what is written reads back as what was meant",
            );
        }
    }

    // Nothing saved is follow-the-system, which is what Balanced Breakfast
    // expressed as an absent value and GoingsOn as a sentinel. Both are now the
    // same thing.
    #[test]
    fn nothing_chosen_yet_is_follow() {
        assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
    }

    #[test]
    fn a_fixed_selection_wins_when_its_theme_is_installed() {
        let available = [meta("nord", "dark"), meta("flatwhite", "light")];
        let fixed = ThemeSelection::Fixed("nord".into());
        assert_eq!(
            fixed.resolve(Variant::Light, &defaults(), &available),
            "nord",
            "a chosen theme is not overridden by the ambient mode",
        );
    }

    // Themes are deletable in three of the four apps. Handing back an id that
    // will fail to load only moves the error somewhere less helpful.
    #[test]
    fn a_fixed_selection_whose_theme_is_gone_falls_back() {
        let available = [meta("nord", "dark"), meta("flatwhite", "light")];
        let fixed = ThemeSelection::Fixed("deleted".into());
        assert_eq!(
            fixed.resolve(Variant::Light, &defaults(), &available),
            "flatwhite",
        );
    }

    #[test]
    fn follow_picks_the_apps_default_for_the_ambient_mode() {
        let available = [meta("nord", "dark"), meta("flatwhite", "light")];
        let follow = ThemeSelection::Follow;
        assert_eq!(
            follow.resolve(Variant::Dark, &defaults(), &available),
            "nord",
        );
        assert_eq!(
            follow.resolve(Variant::Light, &defaults(), &available),
            "flatwhite",
        );
    }

    // The behaviour Balanced Breakfast could not have: following the system
    // into a theme the user installed, when the app's own default is absent.
    #[test]
    fn follow_uses_any_installed_theme_of_the_right_variant() {
        let available = [meta("solarized-light", "light"), meta("mine", "dark")];
        assert_eq!(
            ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
            "mine",
            "the app's `nord` is not installed, but a dark theme is",
        );
    }

    // Always returns something: an app with no theme directory gets the id it
    // ships with, and the load error it would have had anyway.
    #[test]
    fn an_empty_catalog_still_names_the_apps_default() {
        assert_eq!(
            ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
            "nord",
        );
    }

    #[test]
    fn high_contrast_falls_back_to_dark_unless_named() {
        let plain = defaults();
        assert_eq!(plain.for_variant(Variant::HighContrast), "nord");

        let named = defaults().high_contrast("sharp");
        assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
    }

    #[test]
    fn theme_options_groups_by_variant_light_first() {
        let dirs = vec![(bundled_themes_dir().unwrap(), false)];
        let options = theme_options(&dirs);
        assert!(!options.is_empty(), "the shipped set is not empty");

        let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect();
        let mut sorted = order.clone();
        sorted.sort_unstable();
        assert_eq!(
            order, sorted,
            "every variant should occupy one run, light first"
        );
    }

    #[test]
    fn theme_options_puts_the_most_legible_theme_first_in_its_group() {
        let dirs = vec![(bundled_themes_dir().unwrap(), false)];
        let options = theme_options(&dirs);

        for pair in options.windows(2) {
            let (a, b) = (&pair[0], &pair[1]);
            if a.variant != b.variant {
                continue;
            }
            assert!(
                a.contrast >= b.contrast,
                "within {}, {} ({:?}) should not follow {} ({:?})",
                a.variant,
                b.id,
                b.contrast,
                a.id,
                a.contrast
            );
            if a.contrast == b.contrast {
                assert!(
                    a.name <= b.name,
                    "ties break by name: {} then {}",
                    a.name,
                    b.name
                );
            }
        }
    }

    #[test]
    fn theme_options_carries_every_theme_the_scan_found() {
        let dirs = vec![(bundled_themes_dir().unwrap(), false)];
        let mut scanned: Vec<String> = list_themes_from_dirs(&dirs)
            .into_iter()
            .map(|meta| meta.id)
            .collect();
        let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect();
        scanned.sort();
        offered.sort();
        assert_eq!(scanned, offered, "ordering must not drop a theme");
    }

    #[test]
    fn a_theme_that_cannot_be_measured_reads_as_standard() {
        // Not Low: a missing ground is the scan failing, and badging the theme
        // for that would tell the reader something untrue about the theme.
        let theme = ThemeColors {
            meta: ThemeMeta {
                id: "unmeasurable".to_string(),
                name: "Unmeasurable".to_string(),
                variant: "dark".to_string(),
                is_custom: false,
            },
            colors: HashMap::new(),
        };
        assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
    }

    #[test]
    fn the_house_themes_measure_high() {
        // The two we author. A change that drops either below AA is a
        // regression in a theme we control.
        //
        // `high-contrast` is deliberately not in this list. It measures
        // 4.89/3.53 and therefore reads as Standard: its muted text misses AA
        // on its own sunken panel. That is a finding about the theme file, not
        // about the measurement, and it is filed rather than asserted away.
        let dirs = vec![(bundled_themes_dir().unwrap(), false)];
        for id in ["goingson", "audiofiles"] {
            let theme = load_theme(&dirs, id).expect("shipped");
            assert_eq!(
                ContrastTier::of(&theme),
                ContrastTier::High,
                "{id} is one of ours and should meet AA on both grounds"
            );
        }
    }

    #[test]
    fn contrast_tiers_order_worst_first() {
        assert!(ContrastTier::Low < ContrastTier::Standard);
        assert!(ContrastTier::Standard < ContrastTier::High);
    }
}