Skip to main content

max / makeover-layout

0.34.0: one question with two ends `FieldKind::Interval` and `Field::upper_name`. Ruled by Max 2026-08-21 against audiofiles' six filter axes and the MNW server's price pair, the latter being HTML that says the grouping in an ARIA role="group" and nowhere else. Two `Number` fields are two questions. Nothing said they were one, so a renderer drew two controls with two labels and no relationship, the crossing rule was app-side per pair, and `Field::error` could only be attached to one side of a fault that belongs to both. Not `Range`, which is the reading to resist and the same resistance `Range` needed against `Number`: a range is one value inside an extent, and this is two with the extent a bound on each rather than the question's meaning. So the bounds are optional here, as they are for a number, and `bounded()` is a check rather than a debt. Both names are stated rather than derived. Measured the same day, the two sites disagree about affix order -- bpm_min/bpm_max against min_price/max_price -- so any rule this crate owned would rename one of them. Which member a name sits in is also what says which end it is, so nothing separate carries the direction. The crossing rule is not enforced, exactly as `min` is not. What the description buys is one place to report the fault instead of two. `Field` is not #[non_exhaustive], so the member is a formal break for crates.io and zero call-site edits in the tree: every literal builds on a constructor.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:41 UTC
Signed with PGP, not checked
Commit: c52d33c5e8209b596f94b43c6917f80fb19fd703
Parent: a177ee6
2 files changed, +172 insertions, -5 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.33.0"
3 + version = "0.34.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 +171 -4
@@ -382,6 +382,32 @@
382 382 //! that one page: audiofiles' library browser and goingson's filters are the
383 383 //! same shape.
384 384 //!
385 + //! 0.34.0 gives an interval a description. [`FieldKind::Interval`] and
386 + //! [`Field::upper_name`] say that two values are one question with two ends.
387 + //! Ruled by Max 2026-08-21 against audiofiles' six filter axes and the MNW
388 + //! server's price pair, which is HTML saying the grouping in an ARIA
389 + //! `role="group"` and nowhere else.
390 + //!
391 + //! - **The ends constrain each other, and nothing else in the vocabulary could
392 + //! say so.** Two [`FieldKind::Number`] fields are two questions: a renderer
393 + //! draws two labels with no relationship, and [`Field::error`] attaches to one
394 + //! side of a fault that belongs to both.
395 + //! - **It is not [`FieldKind::Range`]**, which is the reading to resist and the
396 + //! same resistance `Range` needed against `Number`. A range is one value
397 + //! inside an extent; this is two, and the extent is a bound on each rather
398 + //! than the question's meaning.
399 + //! - **Both names are stated rather than derived.** Measured the same day, the
400 + //! two sites disagree about affix order -- `bpm_min`/`bpm_max` against
401 + //! `min_price`/`max_price` -- so any rule renames one of them. One member
402 + //! instead of a naming convention this crate would own forever, and which
403 + //! member a name sits in is what says which end it is.
404 + //! - The crossing rule is not enforced, exactly as [`Field::min`] is not. What
405 + //! the description buys is one place to report the fault rather than two.
406 + //!
407 + //! [`Field::upper_name`] is a member on a struct that is not
408 + //! `#[non_exhaustive]`, so this is a formal break for crates.io and zero
409 + //! call-site edits in the tree: every literal builds on a constructor.
410 + //!
385 411 //! 0.33.0 gives a number its unit. [`Field::unit`] carries what the value is
386 412 //! measured in, and [`FieldKind::measurable`] says which kinds read it. Decided
387 413 //! by Max 2026-08-21 (`32215e21`) against eight sites across four audiofiles
@@ -3258,6 +3284,58 @@
3258 3284 /// picker (`fb93426b`), where four sliders were hand-rolled against a
3259 3285 /// vocabulary that could not say what they were.
3260 3286 Range,
3287 + /// One question with two ends: a lower value and an upper one, submitted
3288 + /// under two names.
3289 + ///
3290 + /// "Show me samples between 90 and 130 BPM" has a single answer with two
3291 + /// ends, and the ends constrain each other: a minimum above the maximum is
3292 + /// not a wrong value, it is an empty result nobody asked for. Described as
3293 + /// two [`Number`](Self::Number) fields that is unsayable — nothing says they
3294 + /// are one question, so a renderer draws two controls with two labels and no
3295 + /// relationship, and [`Field::error`] can only be attached to one side of a
3296 + /// fault that belongs to both.
3297 + ///
3298 + /// Not [`Range`](Self::Range), which was the reading to resist and the
3299 + /// resistance is the same one `Range` itself needed against `Number`. A
3300 + /// range describes *one* value inside an extent; this describes two, and the
3301 + /// extent is a bound on each rather than the question's meaning. The two
3302 + /// come apart in the answer: a range has a value, an interval has a pair,
3303 + /// and either end may be absent while the other stands.
3304 + ///
3305 + /// # It states both names
3306 + ///
3307 + /// [`Field::name`] is the lower end and [`Field::upper_name`] is the upper
3308 + /// one, stated rather than derived. Measured 2026-08-21, the two sites in
3309 + /// this tree disagree about affix order — audiofiles submits `bpm_min` /
3310 + /// `bpm_max` and the MNW server submits `min_price` / `max_price` — so any
3311 + /// derived rule picks one and renames the other's parameters. One member
3312 + /// instead of a naming convention this crate would then own forever.
3313 + ///
3314 + /// Direction is carried by which member the name sits in, so nothing
3315 + /// separate says which end is which.
3316 + ///
3317 + /// # What it does not enforce
3318 + ///
3319 + /// The crossing rule. A lower end above the upper one is describable here
3320 + /// and always was, exactly as an out-of-[`min`](Field::min) number is: this
3321 + /// crate carries constraints and never checks them, and deciding a value is
3322 + /// wrong stays with whoever validated. What the description buys is that the
3323 + /// fault now has one place to be reported rather than two.
3324 + ///
3325 + /// # Both ends take the same facts
3326 + ///
3327 + /// [`min`](Field::min), [`max`](Field::max), [`step`](Field::step) and
3328 + /// [`unit`](Field::unit) describe the axis rather than one end of it, so
3329 + /// they are read once and applied to both. Six of audiofiles' filter axes
3330 + /// are exactly this: one extent, one unit, one granularity, two ends.
3331 + ///
3332 + /// The bounds are optional here, unlike `Range`. They are a rule the answer
3333 + /// is checked against rather than the control's extent, which is
3334 + /// [`Number`](Self::Number)'s arrangement and not a slider's.
3335 + ///
3336 + /// Added 0.34.0, ruled by Max 2026-08-21, from audiofiles' six filter axes
3337 + /// and the MNW server's price pair.
3338 + Interval,
3261 3339 /// An email address.
3262 3340 ///
3263 3341 /// Distinct from [`Text`](Self::Text) because the distinction is not
@@ -3533,7 +3611,7 @@
3533 3611
3534 3612 /// Whether the value is a quantity, so [`Field::unit`] means something.
3535 3613 ///
3536 - /// The two numeric kinds and nothing else. A date is a quantity in the sense
3614 + /// The numeric kinds and nothing else. A date is a quantity in the sense
3537 3615 /// that it is ordered, and it is not one in the sense that matters here:
3538 3616 /// its unit is fixed by the kind, so `Date` carrying `days` would be the
3539 3617 /// description restating what [`kind`](Field::kind) already said.
@@ -3542,10 +3620,11 @@
3542 3620 /// renderers ask this before they decide where a unit goes, and a
3543 3621 /// `matches!` per renderer is where the next measurable kind goes missing.
3544 3622 ///
3545 - /// Added 0.33.0 with [`Field::unit`].
3623 + /// Added 0.33.0 with [`Field::unit`]. [`Interval`](Self::Interval) joined at
3624 + /// 0.34.0: an axis is measured in something and both its ends are in it.
3546 3625 #[must_use]
3547 3626 pub const fn measurable(self) -> bool {
3548 - matches!(self, Self::Number | Self::Range)
3627 + matches!(self, Self::Number | Self::Range | Self::Interval)
3549 3628 }
3550 3629 }
3551 3630
@@ -3965,7 +4044,29 @@
3965 4044 /// What kind of value it takes.
3966 4045 pub kind: FieldKind,
3967 4046 /// The name the value is submitted under.
4047 + ///
4048 + /// The *lower* end's name for a [`FieldKind::Interval`], whose upper end is
4049 + /// [`upper_name`](Self::upper_name). Every other kind submits one value and
4050 + /// this is the whole of it.
3968 4051 pub name: &'a str,
4052 + /// The name a [`FieldKind::Interval`]'s upper end is submitted under.
4053 + ///
4054 + /// [`None`] for every other kind, and sayable-and-ignored there the way
4055 + /// [`options`](Self::options) is on a kind that offers none.
4056 + ///
4057 + /// Stated rather than derived from [`name`](Self::name), and
4058 + /// [`FieldKind::Interval`] carries the measurement that decided it: the two
4059 + /// sites in this tree disagree about affix order, so a derived rule would
4060 + /// rename one of them. Which member a name sits in is also what says which
4061 + /// end it is, so nothing separate carries the direction.
4062 + ///
4063 + /// An interval missing it is an interval with one end that can be submitted,
4064 + /// which is a description a renderer may draw honestly and no better than
4065 + /// that. [`Field::interval`] is what makes forgetting it unsayable, on the
4066 + /// same footing as [`Field::range`] and its bounds.
4067 + ///
4068 + /// Added 0.34.0.
4069 + pub upper_name: Option<&'a str>,
3969 4070 /// What the user is asked for.
3970 4071 pub label: &'a str,
3971 4072 /// Standing help, shown whether or not anything is wrong.
@@ -4122,6 +4223,7 @@
4122 4223 Self {
4123 4224 kind,
4124 4225 name,
4226 + upper_name: None,
4125 4227 label,
4126 4228 hint: None,
4127 4229 error: None,
@@ -4160,6 +4262,25 @@
4160 4262 }
4161 4263 }
4162 4264
4265 + /// One question with two ends, taking the name each end submits under.
4266 + ///
4267 + /// A constructor for [`range`](Self::range)'s reason inverted: a range's
4268 + /// bounds are what a call site cannot forget, and an interval's second name
4269 + /// is. An interval built through [`new`](Self::new) has an upper end with
4270 + /// nowhere to be submitted, and nothing downstream can invent one, so taking
4271 + /// it as an argument is what makes that unsayable.
4272 + ///
4273 + /// The extent, the granularity and the unit stay members. They describe the
4274 + /// axis rather than either end and they are genuinely optional, which is
4275 + /// [`FieldKind::Number`]'s arrangement and the one an interval takes.
4276 + #[must_use]
4277 + pub const fn interval(name: &'a str, upper_name: &'a str, label: &'a str) -> Self {
4278 + Self {
4279 + upper_name: Some(upper_name),
4280 + ..Self::new(FieldKind::Interval, name, label)
4281 + }
4282 + }
4283 +
4163 4284 /// A file field, taking the given accept list.
4164 4285 ///
4165 4286 /// The fourth under-described kind and it gets a constructor for
@@ -4929,9 +5050,12 @@
4929 5050 }
4930 5051
4931 5052 #[test]
4932 - fn only_the_two_numeric_kinds_are_measurable() {
5053 + fn only_the_numeric_kinds_are_measurable() {
4933 5054 assert!(FieldKind::Number.measurable());
4934 5055 assert!(FieldKind::Range.measurable());
5056 + // An axis is measured in something and both its ends are in it, so the
5057 + // unit is read once for the pair rather than per end.
5058 + assert!(FieldKind::Interval.measurable());
4935 5059 // A date is ordered and is not a quantity with a unit to choose: its
4936 5060 // unit is fixed by the kind, so saying one would restate `kind`.
4937 5061 for kind in [
@@ -4959,6 +5083,49 @@
4959 5083 assert!(measured.kind.measurable());
4960 5084 }
4961 5085
5086 + #[test]
5087 + fn an_interval_states_both_ends_names() {
5088 + // Stated rather than derived: the two measured sites disagree about
5089 + // affix order, so a rule here would rename one of them.
5090 + let suffixed = Field::interval("bpm_min", "bpm_max", "BPM");
5091 + assert_eq!(suffixed.name, "bpm_min");
5092 + assert_eq!(suffixed.upper_name, Some("bpm_max"));
5093 + let prefixed = Field::interval("min_price", "max_price", "Price");
5094 + assert_eq!(prefixed.name, "min_price");
5095 + assert_eq!(prefixed.upper_name, Some("max_price"));
5096 + assert_eq!(suffixed.kind, FieldKind::Interval);
5097 + }
5098 +
5099 + #[test]
5100 + fn every_other_kind_has_no_upper_end() {
5101 + // Additive: absent is what every field described before 0.34.0 meant.
5102 + for kind in [FieldKind::Text, FieldKind::Number, FieldKind::Range] {
5103 + assert_eq!(Field::new(kind, "n", "N").upper_name, None, "{kind:?}");
5104 + }
5105 + assert_eq!(Field::range("t", "T", "0", "1").upper_name, None);
5106 + }
5107 +
5108 + #[test]
5109 + fn an_interval_owes_no_bounds_and_takes_the_axis_facts_once() {
5110 + // A range's bounds are the control's extent and are owed; an interval's
5111 + // are a rule on each end, which is Number's arrangement.
5112 + let plain = Field::interval("bpm_min", "bpm_max", "BPM");
5113 + assert!(!plain.bounded());
5114 + let axis = Field {
5115 + min: Some("0"),
5116 + max: Some("300"),
5117 + step: Some("1"),
5118 + unit: Some("BPM"),
5119 + ..Field::interval("bpm_min", "bpm_max", "BPM")
5120 + };
5121 + assert!(axis.bounded());
5122 + assert_eq!(axis.unit, Some("BPM"));
5123 + // Nothing here checks the crossing rule, exactly as nothing checks
5124 + // `min` for a number: the description carries constraints and whoever
5125 + // validated decides a value is wrong.
5126 + assert!(axis.error.is_none());
5127 + }
5128 +
4962 5129 #[test]
4963 5130 fn only_a_subtree_prunes_and_only_the_listing_modes_offer_values() {
4964 5131 // Excluding a value from a flat facet is the same fact as not picking