Skip to main content

max / makeover-webview

0.56.0: an interval, as the group MNW already wrote by hand `FieldKind::Interval` emits a role="group" named by the field's label, holding one <input type="number"> per end, and `Value::Between` carries the two values. The shape is not invented here. MNW's discover sidebar has a role="group" with aria-labelledby over min_price and max_price today, written by hand because nothing in the description could say the two boxes were one question. This emits what that page already proved right. The group carries the error and the descriptions, on the split `Radio` already uses: what is wrong is the answer, and a crossed interval is not the fault of either end. Both boxes take the whole extent, because min/max/step describe the axis rather than either end -- `push_bounds` is that factored out. The crossing rule is not emitted: HTML has no attribute for it and the description does not carry it, so it arrives as an error on the group like every other refusal. Which end is which is `aria-label` and nothing more. The description states direction structurally, by which member holds which name, and never in words; visible Min and Max captions are a page's own and reach the group through `Filling::trailing`. `Value::Between` rather than a separator inside one string, which would have made this crate the owner of a delimiter either end could contain. An interval whose upper name is absent draws one box. Drawn as described rather than repaired: inventing a name would submit a parameter no handler reads.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 01:45 UTC
Signed with PGP, not checked
Commit: 1386e820ca4581175095e4927f8689384dcebb06
Parent: ac0d7b3
3 files changed, +255 insertions, -25 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.55.0"
3 + version = "0.56.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
@@ -17,7 +17,7 @@
17 17 # patch satisfy the requirement and still fail to compile. That happened once
18 18 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
19 19 # it surfaced, one release later.
20 - makeover-layout = "0.33.0"
20 + makeover-layout = "0.34.0"
21 21 # The capability axis. `makeover-touch` decides whether a hover rule should be
22 22 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
23 23 # answers are owned elsewhere and neither is re-derived here.
@@ -27,7 +27,7 @@
27 27 # satisfies "0.8" and keeps a second makeover-geometry in the graph next to the
28 28 # 0.7 this crate asks for. `Density` is nominally distinct across the two and
29 29 # the build fails on a type that reads as identical.
30 - makeover-touch = "0.23.0"
30 + makeover-touch = "0.24.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/form.rs +227 -22
@@ -75,18 +75,46 @@
75 75 Text(&'a str),
76 76 /// A checkbox, on or off.
77 77 On(bool),
78 + /// Both ends of a [`FieldKind::Interval`], lower first.
79 + ///
80 + /// Two values rather than one string with a separator, for
81 + /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
82 + /// interval submits under two names, so it comes back as two values, and a
83 + /// delimiter this crate owned could appear inside either of them.
84 + ///
85 + /// Either end may be empty while the other stands. "Over 120 BPM" is a
86 + /// lower end and no upper one, and it is an answer rather than a
87 + /// half-filled form.
88 + ///
89 + /// Added 0.56.0 with makeover-layout 0.34.0.
90 + Between {
91 + /// What the lower box holds now.
92 + lower: &'a str,
93 + /// What the upper box holds now.
94 + upper: &'a str,
95 + },
78 96 }
79 97
80 98 impl<'a> Value<'a> {
81 99 /// The value as text, for the kinds that submit one.
82 100 const fn as_text(&self) -> &'a str {
83 101 match self {
84 - Self::Text(text) => text,
102 + Self::Text(text) | Self::Between { lower: text, .. } => text,
85 103 Self::Absent | Self::On(_) => "",
86 104 }
87 105 }
88 106 }
89 107
108 + impl<'a> Value<'a> {
109 + /// The upper end, for the one variant that has one.
110 + const fn upper_text(&self) -> &'a str {
111 + match self {
112 + Self::Between { upper, .. } => upper,
113 + Self::Absent | Self::Text(_) | Self::On(_) => "",
114 + }
115 + }
116 + }
117 +
90 118 /// Everything about the field that the description does not carry.
91 119 #[derive(Debug, Clone, Copy, Default)]
92 120 pub struct Filling<'a> {
@@ -284,26 +312,12 @@
284 312 out.push('"');
285 313 }
286 314
287 - fn push_control_attributes(
288 - out: &mut String,
289 - field: &Field<'_>,
290 - filling: &Filling<'_>,
291 - id: &str,
292 - name: &str,
293 - ) {
294 - let _ = write!(out, " id=\"{id}\" name=\"");
295 - escape_into(name, out);
296 - out.push('"');
297 - if field.required {
298 - out.push_str(" required");
299 - }
300 - // makeover-layout 0.11.0's constraints. The description carries the rule and
301 - // this emits the browser's idiom for it, which is the model `required` has
302 - // been using since before the crate wrote down that it carried none.
303 - // Enforcement is still whoever validated's, and arrives back as `error`.
304 - if let Some(limit) = field.max_length {
305 - let _ = write!(out, " maxlength=\"{limit}\"");
306 - }
315 + /// The extent and the granularity, as the browser spells them.
316 + ///
317 + /// Its own function because an interval writes them onto both of its ends: they
318 + /// describe the axis rather than either end of it, which is what
319 + /// [`FieldKind::Interval`] says and what the six audiofiles filter axes are.
320 + fn push_bounds(out: &mut String, field: &Field<'_>) {
307 321 if let Some(min) = field.min {
308 322 out.push_str(" min=\"");
309 323 escape_into(min, out);
@@ -332,6 +346,29 @@
332 346 escape_into(step, out);
333 347 out.push('"');
334 348 }
349 + }
350 +
351 + fn push_control_attributes(
352 + out: &mut String,
353 + field: &Field<'_>,
354 + filling: &Filling<'_>,
355 + id: &str,
356 + name: &str,
357 + ) {
358 + let _ = write!(out, " id=\"{id}\" name=\"");
359 + escape_into(name, out);
360 + out.push('"');
361 + if field.required {
362 + out.push_str(" required");
363 + }
364 + // makeover-layout 0.11.0's constraints. The description carries the rule and
365 + // this emits the browser's idiom for it, which is the model `required` has
366 + // been using since before the crate wrote down that it carried none.
367 + // Enforcement is still whoever validated's, and arrives back as `error`.
368 + if let Some(limit) = field.max_length {
369 + let _ = write!(out, " maxlength=\"{limit}\"");
370 + }
371 + push_bounds(out, field);
335 372 if field.invalid() {
336 373 out.push_str(" aria-invalid=\"true\"");
337 374 }
@@ -406,7 +443,85 @@
406 443 /// association has to invert — the label takes an id and the group names itself
407 444 /// with `aria-labelledby`.
408 445 const fn is_group_control(kind: FieldKind) -> bool {
409 - matches!(kind, FieldKind::Radio)
446 + matches!(kind, FieldKind::Radio | FieldKind::Interval)
447 + }
448 +
449 + /// An interval: two number boxes inside one labelled group.
450 + ///
451 + /// The markup MNW's discover sidebar writes by hand -- a `role="group"` with
452 + /// `aria-labelledby` pointing at the question, holding `min_price` and
453 + /// `max_price` -- which is HTML saying by hand exactly what
454 + /// [`FieldKind::Interval`] now says in the description. So this emits what that
455 + /// page already proved is right, rather than inventing a shape.
456 + ///
457 + /// The group carries the error state and the descriptions, for
458 + /// [`push_radio`]'s reason: what is wrong is the answer, and marking one box
459 + /// invalid would name the wrong half of a fault that belongs to both ends.
460 + ///
461 + /// # Both boxes take the same extent
462 + ///
463 + /// [`Field::min`], [`Field::max`] and [`Field::step`] describe the axis rather
464 + /// than either end, so [`push_bounds`] writes them onto both. The crossing rule
465 + /// is not emitted, because the description does not carry it and the browser
466 + /// has no attribute for it: an upper end below the lower one is a refusal
467 + /// whoever validated hands back as [`Field::error`], which lands on the group.
468 + ///
469 + /// # Which end is which, in words
470 + ///
471 + /// `aria-label`, because the description states direction structurally -- the
472 + /// lower end's name is [`Field::name`] and the upper one's is
473 + /// [`Field::upper_name`] -- and never in words. Words for the ends are the
474 + /// host's, the same way a slider's readout is, and a page with visible Min and
475 + /// Max captions supplies them through [`Filling::trailing`] rather than having
476 + /// this crate own two strings of English.
477 + fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
478 + let id = filling.id_for(field.name);
479 +
480 + out.push_str("<div class=\"");
481 + push_class(out, "form-interval", opts);
482 + let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
483 + if field.invalid() {
484 + out.push_str(" aria-invalid=\"true\"");
485 + }
486 + push_described_by(out, field, &id);
487 + out.push('>');
488 +
489 + // An interval with no upper name has one end that can be submitted, which
490 + // is what the description said and is drawn honestly rather than repaired:
491 + // `Field::interval` is what makes it unsayable, and inventing a name here
492 + // would submit a parameter no handler is reading.
493 + let ends: [(&str, &str, &str); 2] = [
494 + ("lower", field.name, filling.value.as_text()),
495 + (
496 + "upper",
497 + field.upper_name.unwrap_or(""),
498 + filling.value.upper_text(),
499 + ),
500 + ];
501 + for (end, name, value) in ends {
502 + if name.is_empty() {
503 + continue;
504 + }
505 + out.push_str("<input type=\"number\" class=\"");
506 + push_class(out, "field", opts);
507 + let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
508 + escape_into(name, out);
509 + let _ = write!(out, "\" aria-label=\"{end}\"");
510 + if field.required {
511 + out.push_str(" required");
512 + }
513 + push_bounds(out, field);
514 + if let Some(text) = field.placeholder {
515 + out.push_str(" placeholder=\"");
516 + escape_into(text, out);
517 + out.push('"');
518 + }
519 + out.push_str(" value=\"");
520 + escape_into(value, out);
521 + out.push_str("\">");
522 + }
523 +
524 + out.push_str("</div>");
410 525 }
411 526
412 527 /// A radio group: the options as sibling inputs sharing one `name`.
@@ -556,6 +671,13 @@
556 671 push_radio(out, field, filling, opts);
557 672 return;
558 673 }
674 + // The same split one kind along: an interval is two inputs and one
675 + // question, so the group carries the error and the descriptions and the
676 + // boxes carry what submits.
677 + if matches!(field.kind, FieldKind::Interval) {
678 + push_interval(out, field, filling, opts);
679 + return;
680 + }
559 681
560 682 let id = filling.id_for(field.name);
561 683 let placeholder = |out: &mut String| {
@@ -1494,6 +1616,89 @@
1494 1616 assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
1495 1617 }
1496 1618
1619 + #[test]
1620 + fn an_interval_is_one_labelled_group_holding_both_ends() {
1621 + // The markup MNW's discover sidebar writes by hand, which is the
1622 + // measurement that decided the member: `role="group"` naming the
1623 + // question, two number boxes under it.
1624 + let f = Field::interval("min_price", "max_price", "Price");
1625 + let html = field_html(
1626 + &f,
1627 + &Filling::of(Value::Between {
1628 + lower: "5",
1629 + upper: "40",
1630 + }),
1631 + &Emit::default(),
1632 + );
1633 +
1634 + assert!(html.contains("role=\"group\""), "{html}");
1635 + assert!(
1636 + html.contains("aria-labelledby=\"min_price-label\""),
1637 + "{html}"
1638 + );
1639 + assert!(html.contains("id=\"min_price-label\""), "{html}");
1640 + assert!(!html.contains("for=\"min_price\""), "{html}");
1641 + assert!(html.contains("name=\"min_price\""), "{html}");
1642 + assert!(html.contains("name=\"max_price\""), "{html}");
1643 + assert!(html.contains("value=\"5\""), "{html}");
1644 + assert!(html.contains("value=\"40\""), "{html}");
1645 + assert_eq!(html.matches("type=\"number\"").count(), 2, "{html}");
1646 + }
1647 +
1648 + #[test]
1649 + fn both_ends_of_an_interval_take_the_whole_extent() {
1650 + // The extent describes the axis rather than either end of it, so a
1651 + // browser refuses the same values in both boxes.
1652 + let f = Field {
1653 + min: Some("0"),
1654 + max: Some("300"),
1655 + step: Some("1"),
1656 + ..Field::interval("bpm_min", "bpm_max", "BPM")
1657 + };
1658 + let html = field_html(&f, &Filling::default(), &Emit::default());
1659 +
1660 + assert_eq!(html.matches("min=\"0\"").count(), 2, "{html}");
1661 + assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
1662 + assert_eq!(html.matches("step=\"1\"").count(), 2, "{html}");
1663 + // Neither box holds anything, which is the open interval rather than an
1664 + // empty form: no filter on this axis at all.
1665 + assert_eq!(html.matches("value=\"\"").count(), 2, "{html}");
1666 + }
1667 +
1668 + #[test]
1669 + fn an_interval_carries_the_fault_on_the_group_and_not_on_one_end() {
1670 + // A crossed interval is wrong about the answer, and the answer is the
1671 + // pair. This is the half two `Number` fields could not say.
1672 + let f = Field {
1673 + error: Some("The high end is below the low one."),
1674 + hint: Some("Leave an end empty for no bound."),
1675 + ..Field::interval("bpm_min", "bpm_max", "BPM")
1676 + };
1677 + let html = field_html(&f, &Filling::default(), &Emit::default());
1678 +
1679 + assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
1680 + let group = html.find("role=\"group\"").expect("group");
1681 + let invalid = html.find("aria-invalid").expect("invalid");
1682 + let first_input = html.find("<input").expect("input");
1683 + assert!(invalid > group && invalid < first_input, "{html}");
1684 + assert!(
1685 + html.contains("aria-describedby=\"bpm_min-hint bpm_min-error\""),
1686 + "{html}"
1687 + );
1688 + }
1689 +
1690 + #[test]
1691 + fn an_interval_with_one_end_named_draws_one_box() {
1692 + // Drawn as described rather than repaired. Inventing a name for the
1693 + // upper end would submit a parameter no handler reads, and
1694 + // `Field::interval` is what makes the omission unsayable at the source.
1695 + let f = Field::new(FieldKind::Interval, "bpm_min", "BPM");
1696 + let html = field_html(&f, &Filling::default(), &Emit::default());
1697 +
1698 + assert_eq!(html.matches("<input").count(), 1, "{html}");
1699 + assert!(html.contains("name=\"bpm_min\""), "{html}");
1700 + }
1701 +
1497 1702 #[test]
1498 1703 fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
1499 1704 // One `name` is what makes them one answer rather than three; distinct
M src/lib.rs +25
@@ -166,6 +166,31 @@
166 166 //! the same magnitude with its sign off the depth. Both values are the measured
167 167 //! consensus rather than a new opinion.
168 168 //!
169 + //! # 0.56.0: an interval, as the group MNW already wrote by hand
170 + //!
171 + //! [`makeover_layout::FieldKind::Interval`] emits a `role="group"` named by the
172 + //! field's label, holding one `<input type="number">` per end. That is not a
173 + //! shape invented here: MNW's discover sidebar has a `role="group"` with
174 + //! `aria-labelledby` over `min_price` and `max_price` today, written by hand
175 + //! because nothing in the description could say the two boxes were one
176 + //! question. The markup is what the measurement found, and this emits it.
177 + //!
178 + //! - **The group carries the error and the descriptions**, on the split
179 + //! [`makeover_layout::FieldKind::Radio`] already uses here: what is wrong is
180 + //! the answer, and a crossed interval is not the fault of either end.
181 + //! - **Both boxes take the whole extent.** `min`, `max` and `step` describe the
182 + //! axis, so they are written twice. The crossing rule is not emitted, because
183 + //! HTML has no attribute for it and the description does not carry it: it
184 + //! comes back as an error on the group, like every other refusal.
185 + //! - **Which end is which is `aria-label` and nothing more.** The description
186 + //! states direction structurally, by which member holds which name, and never
187 + //! in words. Visible Min and Max captions are a page's own and reach the
188 + //! group through [`form::Filling::trailing`].
189 + //!
190 + //! [`form::Value::Between`] is the second value. A separator inside one string
191 + //! would have made this crate the owner of a delimiter that either end could
192 + //! contain.
193 + //!
169 194 //! # 0.55.0: a number's unit, as adjacent text
170 195 //!
171 196 //! `makeover-layout` 0.33.0's `Field::unit`. HTML has no unit attribute and