Skip to main content

max / makeover-layout

0.38.0: describe the theme picker FieldKind::Theme, ThemeChoice, ThemeVariant, Contrast, Field::themes and Field::follows. One screen's control named as furniture, ruled by Max 2026-08-28 (70028e00) against the grouped option list that was the obvious answer. The measurement is what rejected Choice::group: optgroup appears at exactly one live site in the tree, in the one app not yet ported, and the non-theme grouping count is zero. What recurs is not option lists that group; it is this picker, hand-written three times. Two of the four facts a theme picker needs cannot come from an app. A theme's group and its measured contrast tier come off the resolved theme, so the layer that loaded it is the only party holding them.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:52 UTC
Signed with PGP, not checked
Commit: cd4cb531c07f9b8f2f30d5ebec98b40ac2829634
Parent: 585b391
2 files changed, +438 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.37.0"
3 + version = "0.38.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +437
@@ -382,6 +382,35 @@
382 382 //! that one page: audiofiles' library browser and goingson's filters are the
383 383 //! same shape.
384 384 //!
385 + //! 0.38.0 describes the theme picker. [`FieldKind::Theme`], [`ThemeChoice`],
386 + //! [`ThemeVariant`], [`Contrast`], [`Field::themes`] and [`Field::follows`] are
387 + //! one screen's control named as furniture. Ruled by Max 2026-08-28
388 + //! (`70028e00`), against the grouped option list that was the obvious answer.
389 + //!
390 + //! - **The measurement is what rejected `Choice::group`.** `optgroup` appears
391 + //! at exactly one live site in the tree, in the one app not yet ported, and
392 + //! the non-theme grouping count is zero. Two apps' grouped pickers were
393 + //! deleted by their ports and both described replacements dropped the
394 + //! grouping on purpose. So the thing that recurs is not option lists that
395 + //! group; it is this picker, hand-written three times.
396 + //! - **Two of the four facts cannot come from an app.** A theme's group and its
397 + //! measured contrast tier come off the resolved theme, so the layer that
398 + //! loaded it is the only party holding them. `Choice::new(id, "{name}
399 + //! ({variant})")` is what all three apps wrote, and it turns structure into
400 + //! prose and drops the tier entirely.
401 + //! - **The order is the grouping**, rather than a returned list of groups. A
402 + //! renderer that draws headings walks the run of one variant; one that cannot
403 + //! still gets the useful order, and neither shape is made to flatten the
404 + //! other's.
405 + //! - **[`ThemeVariant`] is spelled twice on purpose.** This crate takes no
406 + //! dependencies, so it cannot name `makeover::Variant`, and a renderer that
407 + //! groups needs the groups as values. The adopter converts, in a three-arm
408 + //! match.
409 + //! - **The cost is stated rather than found later.** This is the first member
410 + //! here that names a subject instead of a shape of answer. It stays narrow: a
411 + //! theme picker, not a general host-resolved list. A second such list is when
412 + //! the generalisation gets measured.
413 + //!
385 414 //! 0.35.0 gives a suggestion its second line. [`Candidate`] is the entry in a
386 415 //! field's suggestion list: a value, a label, and the [`detail`] that orients
387 416 //! it. Ruled by Max 2026-08-21 (`1fcf2e9b`) after the combobox member shipped
@@ -3544,6 +3573,51 @@
3544 3573 /// the MNW server is a third consumer with 14 of them. A member designed
3545 3574 /// against nothing is still the rule; the measurement is what changed.
3546 3575 File,
3576 + /// Which theme the app wears.
3577 + ///
3578 + /// The one member here that names a *subject* rather than a shape of
3579 + /// answer, and it is worth saying why that is not the door it looks like.
3580 + /// Every other kind is a question a screen might ask about anything; this
3581 + /// one is a specific question every app in the family asks, once, on its
3582 + /// settings screen, and three of them wrote the same control by hand.
3583 + ///
3584 + /// # It is furniture, and the measurement is what says so
3585 + ///
3586 + /// The reading to resist is that this is [`Select`](Self::Select) with a
3587 + /// grouped option list, and the grouped select is what was rejected to get
3588 + /// here (`70028e00`, ruled by Max 2026-08-28). Grouping was measured across
3589 + /// the tree first: `optgroup` appears at exactly **one** live site, in the
3590 + /// one app not yet ported, and the non-theme grouping count is **zero**.
3591 + /// So the recurring thing was never "option lists that group". It was this
3592 + /// picker.
3593 + ///
3594 + /// # What it carries that a select cannot
3595 + ///
3596 + /// [`Field::themes`] rather than [`Field::options`], because a theme is
3597 + /// four facts and an option is two. The two extra facts are the ones no
3598 + /// app can supply without redoing work the theme layer has already done:
3599 + /// which [`ThemeVariant`] group a theme is in, and how legible its muted
3600 + /// text measured. `Choice::new(id, format!("{name} ({variant})"))` is what
3601 + /// the three apps had, and it flattens the group into prose and loses the
3602 + /// tier entirely.
3603 + ///
3604 + /// [`Field::follows`] carries the entry that is not a theme.
3605 + ///
3606 + /// # The cost, stated rather than discovered later
3607 + ///
3608 + /// This puts one screen's shape into a vocabulary that otherwise holds
3609 + /// none, which was the objection raised against it and accepted going in.
3610 + /// The mitigation is narrowness: this describes a theme picker, not a
3611 + /// general "list the host resolved" mechanism. A second host-resolved list
3612 + /// is when that generalisation gets measured, and not before.
3613 + ///
3614 + /// A renderer that has not heard of it draws a select over
3615 + /// [`Field::themes`]' names and loses the grouping, which is the state
3616 + /// every app was in before this member. Degrading to the status quo ante
3617 + /// is the floor the member is designed against.
3618 + ///
3619 + /// Added 0.38.0.
3620 + Theme,
3547 3621 /// Carried through the form and never shown.
3548 3622 Hidden,
3549 3623 }
@@ -3615,6 +3689,24 @@
3615 3689 matches!(self, Self::Select | Self::Radio)
3616 3690 }
3617 3691
3692 + /// Whether the kind reads [`Field::themes`] and [`Field::follows`].
3693 + ///
3694 + /// One member answers yes, and it gets a name for
3695 + /// [`takes_files`](Self::takes_files)'s reason rather than in spite of
3696 + /// being alone: four renderers ask it before they read either member, and
3697 + /// a `matches!` per renderer is where the next one goes missing.
3698 + ///
3699 + /// Deliberately not folded into
3700 + /// [`offers_options`](Self::offers_options). A theme picker offers no
3701 + /// [`Choice`]es at all, so a renderer walking `options` for it walks an
3702 + /// empty slice and draws an empty control.
3703 + ///
3704 + /// Added 0.38.0.
3705 + #[must_use]
3706 + pub const fn offers_themes(self) -> bool {
3707 + matches!(self, Self::Theme)
3708 + }
3709 +
3618 3710 /// Whether the value runs to more than one line.
3619 3711 ///
3620 3712 /// Named once here for [`temporal`](Self::temporal)'s reason: two kinds
@@ -3973,6 +4065,184 @@
3973 4065 }
3974 4066 }
3975 4067
4068 + /// Which ambient mode a theme is written for.
4069 + ///
4070 + /// The vocabulary's own spelling of what `makeover` calls a theme's variant,
4071 + /// and the duplication is deliberate rather than an oversight. This crate has
4072 + /// no dependencies by charter — it emits nothing, reads nothing and resolves
4073 + /// nothing — so it cannot take the crate that owns the file format, and a
4074 + /// renderer that must group a picker needs the three groups as values.
4075 + ///
4076 + /// The two are kept in step by the app that converts between them, which is a
4077 + /// three-arm `match` at each adopter and the price of the layering. If a fourth
4078 + /// mode is ever authored, this enum and `makeover::Variant` move together.
4079 + ///
4080 + /// Three, not two: one shipped theme is high contrast, and an app matching on
4081 + /// light-or-dark alone files it under the wrong one.
4082 + ///
4083 + /// Added 0.38.0.
4084 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
4085 + #[non_exhaustive]
4086 + pub enum ThemeVariant {
4087 + /// Written for a light ambient mode.
4088 + Light,
4089 + /// Written for a dark ambient mode.
4090 + Dark,
4091 + /// Written to be legible before it is pretty.
4092 + HighContrast,
4093 + }
4094 +
4095 + impl ThemeVariant {
4096 + /// The machine spelling, matching the theme file's own `meta.variant`.
4097 + ///
4098 + /// A data attribute, a stored value, a test assertion. Not a heading: what
4099 + /// a group is *called* on screen is [`heading`](Self::heading).
4100 + #[must_use]
4101 + pub const fn as_str(self) -> &'static str {
4102 + match self {
4103 + ThemeVariant::Light => "light",
4104 + ThemeVariant::Dark => "dark",
4105 + ThemeVariant::HighContrast => "high-contrast",
4106 + }
4107 + }
4108 +
4109 + /// What the group of themes in this variant is called on screen.
4110 + ///
4111 + /// Here rather than at each renderer, which is the whole argument for the
4112 + /// member existing: three renderers picking their own headings is one
4113 + /// picker reading three ways, and the spellings below are the ones
4114 + /// goingson's shipped picker used before it was described.
4115 + #[must_use]
4116 + pub const fn heading(self) -> &'static str {
4117 + match self {
4118 + ThemeVariant::Light => "Light",
4119 + ThemeVariant::Dark => "Dark",
4120 + ThemeVariant::HighContrast => "High Contrast",
4121 + }
4122 + }
4123 + }
4124 +
4125 + impl std::fmt::Display for ThemeVariant {
4126 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4127 + f.write_str(self.as_str())
4128 + }
4129 + }
4130 +
4131 + /// How legible a theme measured, as a picker reports it.
4132 + ///
4133 + /// A measurement carried into the description, which is unusual here and is the
4134 + /// one case that earns it: the number comes off the theme's resolved colours,
4135 + /// so the layer that loaded the theme is the only party that has it, and an app
4136 + /// re-deriving it would be parsing every theme file a second time to learn what
4137 + /// was already known. What a renderer does with it is a badge beside the name.
4138 + ///
4139 + /// Ordered worst-first, matching `makeover::ContrastTier`, so the two sort the
4140 + /// same way and an adopter's `match` cannot invert an ordering by accident.
4141 + ///
4142 + /// Added 0.38.0.
4143 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
4144 + #[non_exhaustive]
4145 + pub enum Contrast {
4146 + /// Muted text below the 3:1 floor for large text and UI parts.
4147 + Low,
4148 + /// Muted text clears 3:1 but not the 4.5:1 bar for normal text.
4149 + Standard,
4150 + /// Muted text meets WCAG AA on every panel ground.
4151 + High,
4152 + }
4153 +
4154 + impl Contrast {
4155 + /// The machine spelling, for a data attribute or a test.
4156 + #[must_use]
4157 + pub const fn as_str(self) -> &'static str {
4158 + match self {
4159 + Contrast::Low => "low",
4160 + Contrast::Standard => "standard",
4161 + Contrast::High => "high",
4162 + }
4163 + }
4164 +
4165 + /// The short mark shown beside a theme's name.
4166 + ///
4167 + /// One spelling for the tree, for [`ThemeVariant::heading`]'s reason. These
4168 + /// are the marks audiofiles shipped before its picker was described, which
4169 + /// is the only implementation that ever drew them.
4170 + ///
4171 + /// [`Standard`](Self::Standard) is not the absence of a mark: a reader
4172 + /// scanning a column of badges learns more from three marks than from two
4173 + /// and a gap, and "OK" is the honest reading of a theme that clears the UI
4174 + /// floor and misses the text one.
4175 + #[must_use]
4176 + pub const fn badge(self) -> &'static str {
4177 + match self {
4178 + Contrast::Low => "low",
4179 + Contrast::Standard => "OK",
4180 + Contrast::High => "AA",
4181 + }
4182 + }
4183 + }
4184 +
4185 + impl std::fmt::Display for Contrast {
4186 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4187 + f.write_str(self.as_str())
4188 + }
4189 + }
4190 +
4191 + /// One theme, as a picker offers it.
4192 + ///
4193 + /// Four facts where a [`Choice`] has two, and the two extra ones are why this
4194 + /// is its own type rather than options with the variant folded into the label.
4195 + /// Both are facts the theme layer resolved and neither survives being written
4196 + /// into a string: a group is structure and a badge is a second column.
4197 + ///
4198 + /// # No `unavailable`
4199 + ///
4200 + /// [`Choice::unavailable`]'s counterpart is absent for its own sibling's
4201 + /// reason. A theme that is installed can be picked, and a theme that is not
4202 + /// installed is not in the list. There is no third state for a reason to
4203 + /// explain.
4204 + ///
4205 + /// `#[non_exhaustive]` from birth, which is the whole of what 0.28.0 cost the
4206 + /// tree and is not being paid a third time.
4207 + ///
4208 + /// Added 0.38.0.
4209 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4210 + #[non_exhaustive]
4211 + pub struct ThemeChoice<'a> {
4212 + /// What is submitted, and what the app stores.
4213 + pub id: &'a str,
4214 + /// What is read.
4215 + pub name: &'a str,
4216 + /// Which group it belongs to.
4217 + pub variant: ThemeVariant,
4218 + /// How legible its muted text measured.
4219 + pub contrast: Contrast,
4220 + }
4221 +
4222 + impl<'a> ThemeChoice<'a> {
4223 + /// A theme, with everything a picker needs to place and mark it.
4224 + ///
4225 + /// Every fact is an argument and none is a builder, which is the opposite
4226 + /// of [`Choice`]'s arrangement and is deliberate: a theme missing its
4227 + /// variant has no group to sit in and a theme missing its tier has no badge
4228 + /// to draw, so both are the control rather than embellishments on it. The
4229 + /// same reasoning [`Field::range`] applies to its bounds.
4230 + #[must_use]
4231 + pub const fn new(
4232 + id: &'a str,
4233 + name: &'a str,
4234 + variant: ThemeVariant,
4235 + contrast: Contrast,
4236 + ) -> Self {
4237 + Self {
4238 + id,
4239 + name,
4240 + variant,
4241 + contrast,
4242 + }
4243 + }
4244 + }
4245 +
3976 4246 /// One field of a form.
3977 4247 ///
3978 4248 /// Borrowed rather than owned: a description is built, read once by a renderer,
@@ -4259,6 +4529,48 @@
4259 4529 /// Which option is *current* is not here. That is the value, and the value
4260 4530 /// is renderer state.
4261 4531 pub options: &'a [Choice<'a>],
4532 + /// The themes offered, in the order they are offered.
4533 + ///
4534 + /// Empty for every kind [`FieldKind::offers_themes`] rejects, and sayable
4535 + /// as empty for the one that accepts it: an app whose theme directories
4536 + /// hold nothing has a picker offering only [`follows`](Self::follows),
4537 + /// which is a true description of that machine.
4538 + ///
4539 + /// **The order is the grouping.** Entries arrive sorted by
4540 + /// [`ThemeVariant`] and then by [`Contrast`] within each variant, so a
4541 + /// renderer that draws headings walks the run of one variant and a renderer
4542 + /// that cannot still gets the useful order. Handing back groups would force
4543 + /// the second renderer to flatten what the first wanted.
4544 + ///
4545 + /// Nothing here sorts. The description carries the order it was given, and
4546 + /// the sort belongs with whoever measured the tiers — `makeover::theme_options`
4547 + /// is what produces it, and re-sorting here would be this crate deciding a
4548 + /// question it cannot see the inputs to.
4549 + ///
4550 + /// Which theme is *current* is not here. That is the value, and the value
4551 + /// is renderer state, exactly as it is for [`options`](Self::options).
4552 + ///
4553 + /// Added 0.38.0.
4554 + pub themes: &'a [ThemeChoice<'a>],
4555 + /// The entry that follows the ambient mode instead of naming a theme.
4556 + ///
4557 + /// [`None`] for a picker that does not offer one, which is a real answer:
4558 + /// an app whose host has no ambient mode to follow should not offer a row
4559 + /// that does nothing.
4560 + ///
4561 + /// A [`Choice`] rather than a bare label, because the *value* is the app's.
4562 + /// Every store in the family spells it `system` today and none of them is
4563 + /// obliged to; a description that hardcoded the spelling would be this
4564 + /// crate holding a fact about somebody else's config table.
4565 + ///
4566 + /// It is not a [`ThemeChoice`] with an absent variant. Following is a
4567 + /// standing instruction that resolves differently as the desktop flips, and
4568 + /// a theme id is an answer that does not — which is the distinction
4569 + /// `makeover::ThemeSelection` exists to hold, carried here rather than
4570 + /// blurred.
4571 + ///
4572 + /// Added 0.38.0.
4573 + pub follows: Option<Choice<'a>>,
4262 4574 /// What a file field takes, in the order a host offering the list shows it.
4263 4575 ///
4264 4576 /// Empty for every kind [`FieldKind::takes_files`] rejects, and empty is
@@ -4435,6 +4747,8 @@
4435 4747 note: None,
4436 4748 placeholder: None,
4437 4749 options: &[],
4750 + themes: &[],
4751 + follows: None,
4438 4752 accept: &[],
4439 4753 multiple: false,
4440 4754 required: false,
@@ -4529,6 +4843,35 @@
4529 4843 Self::offering(FieldKind::Radio, name, label, options)
4530 4844 }
4531 4845
4846 + /// A theme picker over the themes the host resolved.
4847 + ///
4848 + /// A constructor for [`select`](Self::select)'s reason and one of its own.
4849 + /// The shared reason: a theme picker built through [`new`](Self::new) has
4850 + /// an empty [`themes`](Self::themes) list and draws an empty control. Its
4851 + /// own: the list is the *only* thing this kind takes that a call site
4852 + /// cannot get wrong by omission and can get wrong by substitution, since
4853 + /// [`options`](Self::options) is right there and reads as if it would work.
4854 + ///
4855 + /// [`following`](Self::following) is the builder rather than a fourth
4856 + /// argument, because a picker with no follow-the-system row is a real
4857 + /// picker and every renderer draws it honestly.
4858 + #[must_use]
4859 + pub const fn theme(name: &'a str, label: &'a str, themes: &'a [ThemeChoice<'a>]) -> Self {
4860 + Self {
4861 + themes,
4862 + ..Self::new(FieldKind::Theme, name, label)
4863 + }
4864 + }
4865 +
4866 + /// The same picker, offering a row that tracks the ambient mode.
4867 + ///
4868 + /// The [`Choice`] carries the value the app's own store spells it with.
4869 + #[must_use]
4870 + pub const fn following(mut self, follow: Choice<'a>) -> Self {
4871 + self.follows = Some(follow);
4872 + self
4873 + }
4874 +
4532 4875 /// The shared body of the two constructors that take options.
4533 4876 ///
4534 4877 /// Private, and keyed on the kind rather than exposed, because the two
@@ -7127,4 +7470,98 @@
7127 7470 assert_eq!(slid.step, None);
7128 7471 assert_eq!(slid.curve.step(), Some("0.001"));
7129 7472 }
7473 +
7474 + #[test]
7475 + fn a_theme_picker_offers_themes_and_no_options() {
7476 + // The substitution hazard the constructor exists against: `options` is
7477 + // right there and reads as if it would work, and a renderer walking it
7478 + // for a theme picker draws an empty control.
7479 + let themes = [
7480 + ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High),
7481 + ThemeChoice::new("dracula", "Dracula", ThemeVariant::Dark, Contrast::Standard),
7482 + ];
7483 + let field = Field::theme("theme", "Theme", &themes);
7484 +
7485 + assert_eq!(field.kind, FieldKind::Theme);
7486 + assert!(field.kind.offers_themes());
7487 + assert!(!field.kind.offers_options());
7488 + assert_eq!(field.themes.len(), 2);
7489 + assert!(field.options.is_empty());
7490 + assert_eq!(field.follows, None);
7491 + }
7492 +
7493 + #[test]
7494 + fn following_carries_the_store_s_own_spelling() {
7495 + // Not hardcoded here: the value belongs to the app's config table, and
7496 + // this crate holds no facts about somebody else's store.
7497 + let field =
7498 + Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
7499 + let follow = field.follows.expect("the row was offered");
7500 + assert_eq!(follow.value, "system");
7501 + assert_eq!(follow.label, "Follow System");
7502 + }
7503 +
7504 + #[test]
7505 + fn a_picker_with_nothing_resolved_is_sayable() {
7506 + // A machine whose theme directories hold nothing. The description is
7507 + // true and a renderer says so on screen rather than in a log, which is
7508 + // `Field::options`' own arrangement.
7509 + let field = Field::theme("theme", "Theme", &[]);
7510 + assert!(field.themes.is_empty());
7511 + }
7512 +
7513 + #[test]
7514 + fn every_kind_but_theme_offers_no_themes() {
7515 + for kind in [
7516 + FieldKind::Text,
7517 + FieldKind::Select,
7518 + FieldKind::Radio,
7519 + FieldKind::Checkbox,
7520 + FieldKind::File,
7521 + FieldKind::Hidden,
7522 + ] {
7523 + assert!(
7524 + !kind.offers_themes(),
7525 + "{kind:?} does not read Field::themes"
7526 + );
7527 + }
7528 + }
7529 +
7530 + #[test]
7531 + fn a_contrast_tier_reads_worst_first() {
7532 + // Matches `makeover::ContrastTier`, so an adopter's conversion cannot
7533 + // invert an ordering by accident and a sort agrees across the seam.
7534 + assert!(Contrast::Low < Contrast::Standard);
7535 + assert!(Contrast::Standard < Contrast::High);
7536 + }
7537 +
7538 + #[test]
7539 + fn the_groups_and_badges_have_one_spelling_each() {
7540 + // The whole argument for these living here: three renderers picking
7541 + // their own is one picker reading three ways.
7542 + assert_eq!(ThemeVariant::Light.heading(), "Light");
7543 + assert_eq!(ThemeVariant::Dark.heading(), "Dark");
7544 + assert_eq!(ThemeVariant::HighContrast.heading(), "High Contrast");
7545 +
7546 + assert_eq!(ThemeVariant::HighContrast.as_str(), "high-contrast");
7547 +
7548 + assert_eq!(Contrast::High.badge(), "AA");
7549 + assert_eq!(Contrast::Standard.badge(), "OK");
7550 + assert_eq!(Contrast::Low.badge(), "low");
7551 + }
7552 +
7553 + #[test]
7554 + fn the_variant_spelling_matches_the_theme_file_s_own() {
7555 + // The seam this enum is duplicated across. `makeover::parse_meta` reads
7556 + // `meta.variant` as one of these three strings; a rename on either side
7557 + // that does not move together silently regroups every picker.
7558 + for (variant, spelling) in [
7559 + (ThemeVariant::Light, "light"),
7560 + (ThemeVariant::Dark, "dark"),
7561 + (ThemeVariant::HighContrast, "high-contrast"),
7562 + ] {
7563 + assert_eq!(variant.as_str(), spelling);
7564 + assert_eq!(variant.to_string(), spelling);
7565 + }
7566 + }
7130 7567 }