Skip to main content

max / makeover-webview

0.66.0: an option carries what picking it means `Choice::detail` from makeover-layout 0.39.0, and it takes `unavailable`'s split because the split is forced: a radio group gets a `.form-option-detail` span under the label, a `<select>`'s option has room for no element at all so the line runs into its text. What the option is reads before why it cannot be picked. Ruled muted, where `.form-option-reason` beside it is unruled. That class shipped at 0.28.0, before the test `unit_rules` and `note_rules` state; this one has no app counterpart to keep either, and an unruled second line renders identically to the label above it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 15:44 UTC
Signed with PGP, not checked
Commit: 5ae78bfb9d94e4dbbf27f434aa7e44ec1cc04d80
Parent: 27492e6
3 files changed, +121 insertions, -19 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.65.1"
3 + version = "0.66.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.38.0"
20 + makeover-layout = "0.39.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.28.0"
30 + makeover-touch = "0.29.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/form.rs +113 -16
@@ -71,6 +71,7 @@
71 71 "form-interval",
72 72 "form-label",
73 73 "form-note",
74 + "form-option-detail",
74 75 "form-option-reason",
75 76 "form-radio-group",
76 77 "form-radio-label",
@@ -653,18 +654,33 @@
653 654 // explanation behind a hover.
654 655 if let Some(reason) = opt.unavailable {
655 656 out.push_str(" disabled");
656 - out.push_str("><span>");
657 - escape_into(opt.label, out);
658 - out.push_str("</span><span class=\"");
659 - push_class(out, "form-option-reason", opts);
660 - out.push_str("\">");
661 - escape_into(reason, out);
662 - out.push_str("</span></label>");
663 - continue;
664 657 }
665 658 out.push_str("><span>");
666 659 escape_into(opt.label, out);
667 - out.push_str("</span></label>");
660 + out.push_str("</span>");
661 + // What picking it means, on the line under the label. `5e21dcfc`, and
662 + // the same treatment the reason gets one line down: a radio group has
663 + // room, so the sentence sits in its own element rather than being run
664 + // into the label the way a `<select>`'s has to be.
665 + //
666 + // Before the reason, which is the order the two read in: what this
667 + // option *is* comes ahead of why it cannot be picked, and an option
668 + // carrying both has said two things rather than one long one.
669 + if let Some(detail) = opt.detail {
670 + out.push_str("<span class=\"");
671 + push_class(out, "form-option-detail", opts);
672 + out.push_str("\">");
673 + escape_into(detail, out);
674 + out.push_str("</span>");
675 + }
676 + if let Some(reason) = opt.unavailable {
677 + out.push_str("<span class=\"");
678 + push_class(out, "form-option-reason", opts);
679 + out.push_str("\">");
680 + escape_into(reason, out);
681 + out.push_str("</span>");
682 + }
683 + out.push_str("</label>");
668 684 }
669 685
670 686 out.push_str("</div>");
@@ -724,17 +740,22 @@
724 740 // "Multi-sample: Drop a second sample onto the keyboard." and is the
725 741 // one place the precondition can be both attached to its option and
726 742 // read without a pointer.
727 - if let Some(reason) = opt.unavailable {
743 + if opt.unavailable.is_some() {
728 744 out.push_str(" disabled");
729 - out.push('>');
730 - escape_into(opt.label, out);
731 - out.push_str(": ");
732 - escape_into(reason, out);
733 - out.push_str("</option>");
734 - continue;
735 745 }
736 746 out.push('>');
737 747 escape_into(opt.label, out);
748 + // Both extra strings run into the row's text, for the reason above:
749 + // this is the one control with nowhere else to put either of them.
750 + // `5e21dcfc` did not invent that rule, it met it.
751 + if let Some(detail) = opt.detail {
752 + out.push_str(": ");
753 + escape_into(detail, out);
754 + }
755 + if let Some(reason) = opt.unavailable {
756 + out.push_str(": ");
757 + escape_into(reason, out);
758 + }
738 759 out.push_str("</option>");
739 760 }
740 761 }
@@ -1162,6 +1183,27 @@
1162 1183 css
1163 1184 }
1164 1185
1186 + /// The rules an option's second line needs.
1187 + ///
1188 + /// [`unit_rules`]' argument, and it is worth saying why this one is ruled where
1189 + /// `.form-option-reason` beside it is not: that class shipped at 0.28.0, before
1190 + /// the test the two functions above state — rule what has no app counterpart to
1191 + /// keep — and nothing has been written against it since. This one has none
1192 + /// either, and an unruled second line renders identically to the label it sits
1193 + /// under, which is a worse default than the hand-written markup it replaces.
1194 + /// MNW spells that markup `.card--selectable-desc` and mutes it.
1195 + ///
1196 + /// Colour only, and muted, which is the same reading `.form-unit` and
1197 + /// `.form-suggestion-detail` take: the line orients the label rather than
1198 + /// competing with it. Nothing about placement or spacing, for `unit_rules`'
1199 + /// reason — a magnitude asserted here belongs to `makeover-geometry`.
1200 + pub(crate) fn option_detail_rules(opts: &Emit) -> String {
1201 + let detail = class("form-option-detail", opts);
1202 + let mut css = String::new();
1203 + let _ = writeln!(css, ".{detail} {{\n color: var(--content-muted);\n}}");
1204 + css
1205 + }
1206 +
1165 1207 /// The rules a field's suggestion list needs.
1166 1208 ///
1167 1209 /// [`editor_rules`]' precedent and its argument: the class names this module's
@@ -1879,6 +1921,61 @@
1879 1921 assert!(html.contains("disabled"), "{html}");
1880 1922 }
1881 1923
1924 + #[test]
1925 + fn an_option_can_say_what_picking_it_means() {
1926 + // makeover-layout 0.39.0. A radio group has room, so the line gets its
1927 + // own element under the label, and it is muted rather than unruled: an
1928 + // unruled second line renders identically to the label above it, which
1929 + // is a worse default than the markup this replaces.
1930 + let options = [
1931 + Choice::new("16", "Basic").detailing("$16/mo. Fits text, blogs, newsletters."),
1932 + Choice::new("24", "Small Files"),
1933 + ];
1934 + let f = Field::radio("tier", "Content tier", &options);
1935 + let html = field_html(&f, &Filling::default(), &Emit::default());
1936 +
1937 + assert!(html.contains("form-option-detail"), "{html}");
1938 + assert!(
1939 + html.contains(">$16/mo. Fits text, blogs, newsletters.</span>"),
1940 + "{html}"
1941 + );
1942 + // One option carries it and the other does not, so the class appears
1943 + // once rather than on every label.
1944 + assert_eq!(html.matches("form-option-detail").count(), 1, "{html}");
1945 + assert!(
1946 + option_detail_rules(&Emit::default()).contains("var(--content-muted)"),
1947 + "the line orients the label rather than competing with it"
1948 + );
1949 + }
1950 +
1951 + #[test]
1952 + fn an_option_reads_what_it_is_before_why_it_cannot_be_picked() {
1953 + // Two different sentences, drawn in the order they read in. A tier that
1954 + // is sold out is still a tier the reader is owed a description of.
1955 + let options = [Choice::new("24", "Small Files")
1956 + .detailing("$24/mo. Fits audio, plugins, binaries.")
1957 + .unless("Sold out while the founder window is open.")];
1958 + let f = Field::radio("tier", "Content tier", &options);
1959 + let html = field_html(&f, &Filling::default(), &Emit::default());
1960 +
1961 + let detail = html.find("form-option-detail").expect("the detail");
1962 + let reason = html.find("form-option-reason").expect("the reason");
1963 + assert!(detail < reason, "{html}");
1964 + assert!(html.contains(" disabled"), "{html}");
1965 +
1966 + // A `<select>` has room for neither element, so both run into the
1967 + // row's own text in the same order.
1968 + let f = Field::select("tier", "Content tier", &options);
1969 + let html = field_html(&f, &Filling::default(), &Emit::default());
1970 + assert!(
1971 + html.contains(concat!(
1972 + ">Small Files: $24/mo. Fits audio, plugins, binaries.",
1973 + ": Sold out while the founder window is open.</option>"
1974 + )),
1975 + "{html}"
1976 + );
1977 + }
1978 +
1882 1979 #[test]
1883 1980 fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
1884 1981 // The association inverts, and getting it wrong is silent: a
M src/lib.rs +5
@@ -293,6 +293,10 @@
293 293 //! differs by control and the difference is forced: a radio group gets a
294 294 //! `.form-option-reason` span beside the label, and a `<select>` option has
295 295 //! room for no element at all, so the reason runs into its text.
296 + //! - `Choice::detail` takes the same split for the same reason, one release
297 + //! later (makeover-layout 0.39.0): a `.form-option-detail` span in a radio
298 + //! group, run into the text of a `<select>`'s option. An option carrying both
299 + //! reads what it is before why it cannot be picked.
296 300 //!
297 301 //! # 0.25.0: a cell says what it holds
298 302 //!
@@ -1988,6 +1992,7 @@
1988 1992 css.push_str(&form::editor_rules(opts));
1989 1993 css.push_str(&form::suggestion_rules(opts));
1990 1994 css.push_str(&form::unit_rules(opts));
1995 + css.push_str(&form::option_detail_rules(opts));
1991 1996 css.push_str(&form::note_rules(opts));
1992 1997 css
1993 1998 }