Skip to main content

max / makeover-webview

0.55.0: a number's unit, as adjacent text makeover-layout 0.33.0's `Field::unit`. HTML has no unit attribute and inventing one would be markup nothing reads, so it is a span after the control -- and it is named in `aria-describedby` rather than left as decoration, because a number and what it is measured in are one fact and reading the first without the second is reading it wrong. `.form-unit` gets a rule here on `suggestion_rules`' precedent: `.form-group`, `.form-label`, `.form-hint` and `.form-error` are the apps' own names and stay unruled, and this one has no app counterpart because nothing emitted it before the member existed. One declaration, the muted content intent, and nothing about placement or spacing -- those are the app's layout and makeover-geometry's magnitudes. This is the renderer the member costs something: here the two placements are equally adjacent, and what is bought is that the unit is a value a consumer can read back rather than a suffix it would have to parse off a label.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:27 UTC
Signed with PGP, not checked
Commit: ac0d7b33e0ddc9fe03a075d3514a7cd80b74223e
Parent: 261bc62
3 files changed, +137 insertions, -5 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.54.0"
3 + version = "0.55.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.32.0"
20 + makeover-layout = "0.33.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.22.1"
30 + makeover-touch = "0.23.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/form.rs +120 -2
@@ -358,15 +358,28 @@
358 358 /// Its own function because a radio group carries it on the group rather than
359 359 /// on a control, and one reading of "what describes this field" is the point.
360 360 fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
361 - if field.hint.is_none() && field.error.is_none() {
361 + let unit = unit_of(field).is_some();
362 + if field.hint.is_none() && field.error.is_none() && !unit {
362 363 return;
363 364 }
365 + let mut written = false;
364 366 out.push_str(" aria-describedby=\"");
365 367 if field.hint.is_some() {
366 368 let _ = write!(out, "{id}-hint");
369 + written = true;
370 + }
371 + // The unit before the error and after the hint, which is the order they are
372 + // useful in: what the number is measured in is standing context like the
373 + // hint, and what is wrong with it now comes last.
374 + if unit {
375 + if written {
376 + out.push(' ');
377 + }
378 + let _ = write!(out, "{id}-unit");
379 + written = true;
367 380 }
368 381 if field.error.is_some() {
369 - if field.hint.is_some() {
382 + if written {
370 383 out.push(' ');
371 384 }
372 385 let _ = write!(out, "{id}-error");
@@ -374,6 +387,16 @@
374 387 out.push('"');
375 388 }
376 389
390 + /// The unit to draw beside this field's value, if there is one to draw.
391 + ///
392 + /// Two conditions rather than one: the field has to carry a unit and its kind
393 + /// has to be one that means anything by it. `FieldKind::measurable` is the
394 + /// description answering the second, so this renderer keeps no list of its own
395 + /// of which kinds are quantities.
396 + fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
397 + field.unit.filter(|_| field.kind.measurable())
398 + }
399 +
377 400 /// Whether the field's control is a set of elements rather than one.
378 401 ///
379 402 /// A DOM concern rather than a description one, which is why it is decided here
@@ -788,6 +811,29 @@
788 811 css
789 812 }
790 813
814 + /// The rule a field's unit needs.
815 + ///
816 + /// [`suggestion_rules`]' precedent and its argument: `.form-group`,
817 + /// `.form-label`, `.form-hint` and `.form-error` are the apps' own names and
818 + /// stay unruled here, and this one has no app counterpart to keep because
819 + /// nothing emitted it before `Field::unit` existed.
820 + ///
821 + /// One declaration, and it is the whole look. A unit is a fact about the number
822 + /// beside it rather than a second thing to read, so it takes the muted content
823 + /// intent -- the same reading `.figure-caption` and `.track-tick` take, and for
824 + /// the same reason.
825 + ///
826 + /// Nothing about placement or spacing. Where the span sits relative to the
827 + /// control is the app's layout, exactly as `.form-hint`'s is, and a margin
828 + /// asserted here would be this crate deciding a magnitude that belongs to
829 + /// `makeover-geometry`.
830 + pub(crate) fn unit_rules(opts: &Emit) -> String {
831 + let unit = class("form-unit", opts);
832 + let mut css = String::new();
833 + let _ = writeln!(css, ".{unit} {{\n color: var(--content-muted);\n}}");
834 + css
835 + }
836 +
791 837 /// The rules a field's suggestion list needs.
792 838 ///
793 839 /// [`editor_rules`]' precedent and its argument: the class names this module's
@@ -938,6 +984,19 @@
938 984
939 985 push_control(out, field, filling, opts);
940 986
987 + // Adjacent text, because HTML has no unit attribute and inventing one would
988 + // be markup nothing reads. Pointed at by `aria-describedby` so it is not
989 + // decoration a screen reader skips: the number and what it is measured in
990 + // are one fact, and reading the first without the second is reading it
991 + // wrong.
992 + if let Some(unit) = unit_of(field) {
993 + out.push_str("<span class=\"");
994 + push_class(out, "form-unit", opts);
995 + let _ = write!(out, "\" id=\"{id}-unit\">");
996 + escape_into(unit, out);
997 + out.push_str("</span>");
998 + }
999 +
941 1000 if let Some(hint) = field.hint {
942 1001 out.push_str("<div class=\"");
943 1002 push_class(out, "form-hint", opts);
@@ -1294,6 +1353,65 @@
1294 1353 assert!(!html.contains("step="), "{html}");
1295 1354 }
1296 1355
1356 + #[test]
1357 + fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
1358 + // Not decoration: the number and what it is measured in are one fact,
1359 + // so the association is what makes this worth emitting at all.
1360 + let f = Field {
1361 + unit: Some("dBFS"),
1362 + ..Field::range("threshold", "Threshold", "-96", "-20")
1363 + };
1364 + let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
1365 + assert!(html.contains(r#"id="threshold-unit""#), "{html}");
1366 + assert!(html.contains(">dBFS</span>"), "{html}");
1367 + assert!(
1368 + html.contains(r#"aria-describedby="threshold-unit""#),
1369 + "{html}"
1370 + );
1371 + // The label is the question's name and keeps no unit in it.
1372 + assert!(html.contains(">Threshold</label>"), "{html}");
1373 + }
1374 +
1375 + #[test]
1376 + fn a_unit_takes_its_place_between_the_hint_and_the_error() {
1377 + let f = Field {
1378 + unit: Some("ms"),
1379 + hint: Some("How long the fade runs."),
1380 + error: Some("Too long."),
1381 + ..Field::new(FieldKind::Number, "fade", "Fade")
1382 + };
1383 + let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
1384 + assert!(
1385 + html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
1386 + "{html}"
1387 + );
1388 + }
1389 +
1390 + #[test]
1391 + fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1392 + // Sayable and ignored, the way `options` is on a kind that offers none.
1393 + // The renderer asks the description which kinds are measurable rather
1394 + // than keeping its own list.
1395 + let f = Field {
1396 + unit: Some("s"),
1397 + ..Field::new(FieldKind::Text, "name", "Name")
1398 + };
1399 + let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
1400 + assert!(!html.contains("name-unit"), "{html}");
1401 + assert!(!html.contains("aria-describedby"), "{html}");
1402 + }
1403 +
1404 + #[test]
1405 + fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
1406 + let f = Field {
1407 + unit: Some("</span><script>"),
1408 + ..Field::new(FieldKind::Number, "n", "N")
1409 + };
1410 + let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
1411 + assert!(!html.contains("<script>"), "{html}");
1412 + assert!(html.contains("&lt;script&gt;"), "{html}");
1413 + }
1414 +
1297 1415 #[test]
1298 1416 fn a_constant_ratio_curve_still_emits_a_linear_track() {
1299 1417 // Honest shortfall rather than a silent one: HTML has no logarithmic
M src/lib.rs +14
@@ -166,6 +166,19 @@
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.55.0: a number's unit, as adjacent text
170 + //!
171 + //! `makeover-layout` 0.33.0's `Field::unit`. HTML has no unit attribute and
172 + //! inventing one would be markup nothing reads, so it is a `<span>` after the
173 + //! control -- and it is named in `aria-describedby` rather than left as
174 + //! decoration, because a number and what it is measured in are one fact and
175 + //! reading the first without the second is reading it wrong.
176 + //!
177 + //! This is the renderer the member costs something. egui already had somewhere
178 + //! better than the label to put a unit; here the two placements are equally
179 + //! adjacent, and what is bought is that the unit is a value a consumer can read
180 + //! back rather than a suffix on a label it would have to parse.
181 + //!
169 182 //! # 0.54.0: a curve this renderer can carry, and one it cannot
170 183 //!
171 184 //! `makeover-layout` 0.32.0's `Curve`. A range takes its granularity from the
@@ -1688,6 +1701,7 @@
1688 1701 css.push_str(&facet::facet_rules(opts));
1689 1702 css.push_str(&form::editor_rules(opts));
1690 1703 css.push_str(&form::suggestion_rules(opts));
1704 + css.push_str(&form::unit_rules(opts));
1691 1705 css
1692 1706 }
1693 1707