Skip to main content

max / makeover-layout

0.36.0: Tone::Neutral is content, and a field can say what an answer costs Tone::Neutral answered `content-muted`, which read "no status" as "de-emphasised". Two sites took the muting from that one call and only one of them wanted it: makeover-webview's `.badge` (correctly quiet, because a badge answers no click) and `figure_rules` (a bug -- the headline number came out at the colour of its own caption). makeover-tui and makeover-immediate both already mapped the variant to full content, so the description was stating an intent two of its three renderers contradicted. Neutral answers `content`. What makes a badge quiet is `Token::Badge` answering no click, which a renderer holds and `Tone` does not know, so the muting moves to the renderer and each site states its own claim. A fourth renderer wanting a muted badge reaches for `Token::interactive()`, which the doc comment now says rather than leaving it to be discovered. Field::note is a third message channel, between `hint` and `error` and overlapping neither: the value is acceptable and choosing it costs something. It carries its own Tone rather than being fixed at Warning, so the same slot says "recommended" and "this is what that implies". It does not make the field invalid. Precedence for a renderer with room for one line is decided here rather than three times: error, then note, then hint. Every in-tree `Field { .. }` literal uses functional update, so the new member costs no call-site edits.
Author: Max Johnson <me@maxj.phd> · 2026-08-27 23:21 UTC
Signed with PGP, not checked
Commit: 114ff2a401f62e5e34d2b76f0ea1f9383e9c3abb
Parent: c30d0e4
2 files changed, +84 insertions, -6 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.35.0"
3 + version = "0.36.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 +83 -5
@@ -1055,7 +1055,13 @@
1055 1055 /// webview apps already carry it as a `data-color` attribute.
1056 1056 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1057 1057 pub enum Tone {
1058 - /// No status. Reads as ordinary de-emphasised content.
1058 + /// No status.
1059 + ///
1060 + /// Ordinary content, at full weight. It does not also mean muted: a
1061 + /// badge reads quiet because [`Token::Badge`] answers no click, which is
1062 + /// the renderer's knowledge and not this axis's. A renderer wanting a
1063 + /// muted badge reaches for [`Token::interactive`] itself rather than
1064 + /// expecting `Neutral` to have muted it.
1059 1065 Neutral,
1060 1066 /// Something worth knowing and nothing to do about it.
1061 1067 Info,
@@ -1070,10 +1076,12 @@
1070 1076 impl Intent for Tone {
1071 1077 fn token(self) -> &'static str {
1072 1078 match self {
1073 - // Neutral has no status token of its own. It takes the muted
1074 - // content intent, which is what both webview apps already spell as
1075 - // `data-color="muted"`.
1076 - Self::Neutral => "content-muted",
1079 + // Neutral has no status token of its own, so it takes the plain
1080 + // content intent. It used to answer `content-muted`, which read
1081 + // "no status" as "de-emphasised" and muted every figure value in
1082 + // the webview. Muting is a renderer's call about a particular
1083 + // token, not something the status axis knows.
1084 + Self::Neutral => "content",
1077 1085 Self::Info => "info",
1078 1086 Self::Success => "success",
1079 1087 Self::Warning => "warning",
@@ -4194,6 +4202,41 @@
4194 4202 pub hint: Option<&'a str>,
4195 4203 /// What is currently wrong with the value.
4196 4204 pub error: Option<&'a str>,
4205 + /// A consequence of the answer the user has given, carrying its own tone.
4206 + ///
4207 + /// The third message channel, between [`hint`](Self::hint) and
4208 + /// [`error`](Self::error) and overlapping neither. A hint is standing help
4209 + /// that does not depend on the value; an error says the value is not
4210 + /// acceptable. A note is the case in the middle: the value is perfectly
4211 + /// acceptable and choosing it costs something the user should know about.
4212 + ///
4213 + /// The first consumer is audiofiles' export Format field, where choosing
4214 + /// WAV or AIFF over Original re-encodes and silently drops embedded BWF,
4215 + /// iXML, loop points, cue markers and ID3. That is not a validation
4216 + /// failure and it is not standing help — it is true of one answer to one
4217 + /// question — and it was hand-drawn in the app's own draw callback for
4218 + /// want of anywhere to say it.
4219 + ///
4220 + /// The tone is carried rather than fixed at [`Tone::Warning`] because the
4221 + /// channel is not only for warnings: the same slot says "this is the
4222 + /// recommended one" ([`Tone::Success`]) and "this is what that setting
4223 + /// implies" ([`Tone::Info`]). A renderer gets the announcement behaviour
4224 + /// off the tone for free — makeover-webview emits `data-tone` and treats
4225 + /// Warning and Danger as assertive for `aria-live`.
4226 + ///
4227 + /// It does **not** make the field invalid. [`invalid`](Self::invalid) stays
4228 + /// `error.is_some()`, so a note never marks the group as a problem.
4229 + ///
4230 + /// # Precedence, for a renderer with room for one
4231 + ///
4232 + /// Error, then note, then hint. A renderer that shows every message shows
4233 + /// them in that order too. makeover-tui is the one with room for exactly
4234 + /// one line, and it is why the order is decided here rather than three
4235 + /// times: what is wrong outranks what it costs, which outranks how it
4236 + /// works.
4237 + ///
4238 + /// Added 0.36.0.
4239 + pub note: Option<(Tone, &'a str)>,
4197 4240 /// Ghost text shown while the field is empty.
4198 4241 ///
4199 4242 /// User-facing text, and it sits with `label` and `hint` rather than with
@@ -4348,6 +4391,7 @@
4348 4391 label,
4349 4392 hint: None,
4350 4393 error: None,
4394 + note: None,
4351 4395 placeholder: None,
4352 4396 options: &[],
4353 4397 accept: &[],
@@ -4468,6 +4512,10 @@
4468 4512 /// terminal) cannot find the group from the message. goingson already marks
4469 4513 /// the group and Balanced Breakfast does not, so goingson's shape is the
4470 4514 /// one taken here.
4515 + ///
4516 + /// [`note`](Self::note) is deliberately not consulted. A note says the
4517 + /// answer costs something, not that it is unacceptable, and a field the
4518 + /// user may submit as it stands is not invalid.
4471 4519 #[must_use]
4472 4520 pub const fn invalid(&self) -> bool {
4473 4521 self.error.is_some()
@@ -5795,6 +5843,36 @@
5795 5843 assert_ne!(Depth::Overlay.fill(), Depth::Flat.fill());
5796 5844 }
5797 5845
5846 + #[test]
5847 + fn a_note_is_neither_a_hint_nor_an_error() {
5848 + // audiofiles' export Format field: choosing WAV over Original
5849 + // re-encodes and drops the embedded metadata. Perfectly valid, and it
5850 + // costs something.
5851 + let format = Field {
5852 + note: Some((Tone::Warning, "Re-encoding drops embedded BWF and iXML")),
5853 + ..Field::select("format", "Format", &[])
5854 + };
5855 + assert!(!format.invalid(), "a note is not a validation failure");
5856 + assert!(format.hint.is_none());
5857 + assert!(format.error.is_none());
5858 + assert_eq!(format.note.unwrap().0, Tone::Warning);
5859 +
5860 + // The default is no note, so the 15 in-tree `..Field::new(..)`
5861 + // literals absorb the member with no call-site edit.
5862 + assert!(Field::new(FieldKind::Text, "title", "Title").note.is_none());
5863 + }
5864 +
5865 + #[test]
5866 + fn neutral_is_content_not_muted_content() {
5867 + // Neutral means "no status", and that is all it means. It answered
5868 + // `content-muted` until 2026-08-27, which muted a figure's headline
5869 + // number to the colour of its own caption. What makes a badge quiet
5870 + // is `Token::Badge` answering no click, which lives on the renderer.
5871 + assert_eq!(Tone::Neutral.token(), "content");
5872 + assert!(!Token::Badge.interactive());
5873 + assert_eq!(State::Disabled.token(), "content-muted");
5874 + }
5875 +
5798 5876 #[test]
5799 5877 fn intents_name_makeover_tokens_and_nothing_else() {
5800 5878 assert_eq!(Edge::Light.token(), "bevel-light");