Skip to main content

max / makeover-webview

Emit a meter, filling a trough that predated the word for it The unusual order: progress_rules has emitted .progress and .progress-fill[data-tone] for every tone since phase A, while the only way to describe a bar was to concatenate its numbers into a heading. makeover-layout 0.10.0 named it, so this fills its own trough. The over-run leaves as data-over, because a bar that is full because it landed exactly and one that is full because it ran over are the same width and not the same fact. No rule is emitted for it: what an over-run looks like is app taste, and goingson already says it with a tone. aria-valuenow is clamped to aria-valuemax, since a value outside the range is invalid ARIA and may be dropped whole. The unclamped truth stays in the accessible name, which is read either way.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 13:03 UTC
Signed with PGP, not checked
Commit: 935eabf6a85374c7ca1ca2c34f8d4de1d7b8213c
Parent: 32e9dd8
3 files changed, +178 insertions, -9 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.18.0"
3 + version = "0.19.0"
4 4 edition = "2024"
5 5 description = "The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser."
6 6 license = "MIT"
@@ -11,11 +11,11 @@
11 11 # in 0.8.2. Declared as "0.8" from 0.16.1, where the radio landed, so a consumer
12 12 # whose lock already held 0.8.0 got a resolve that satisfied the pin and failed
13 13 # to compile. makeover-build is where that surfaced, one release later.
14 - makeover-layout = "0.9.0"
14 + makeover-layout = "0.10.0"
15 15 # The capability axis. `makeover-touch` decides whether a hover rule should be
16 16 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
17 17 # answers are owned elsewhere and neither is re-derived here.
18 - makeover-touch = "0.5"
18 + makeover-touch = "0.6"
19 19 makeover-geometry = "0.6"
20 20
21 21 [lints.rust]
M src/lib.rs +11 -6
@@ -195,6 +195,7 @@
195 195
196 196 pub mod form;
197 197 pub mod list;
198 + pub mod meter;
198 199
199 200 use crate::list::part_class;
200 201 use makeover_geometry::{Density, SizeClass};
@@ -728,13 +729,17 @@
728 729 css
729 730 }
730 731
731 - /// The progress trough, which has nothing behind it in the description.
732 + /// The progress trough these rules fill.
732 733 ///
733 - /// Renderer-local chrome, on the same licence the skeletons hold as the
734 - /// webview's expression of `Readiness::Pending`: a determinate bar is a shape
735 - /// CSS draws readily and a terminal would rather not be told about. It earns
736 - /// the place empirically, goingson having grown four independent progress bars
737 - /// before anything named one.
734 + /// This was renderer-local chrome with nothing behind it until makeover-layout
735 + /// 0.10.0, which is the unusual order: the tones below were emitted for every
736 + /// bar in the tree while the only way to describe one was to concatenate the
737 + /// numbers into a heading. `Meter` is the word that arrived late, and
738 + /// [`meter::meter_html`](crate::meter::meter_html) is what now fills these.
739 + ///
740 + /// The rules stay a superset of what a description can ask for. An app drawing
741 + /// its own bar keeps these classes, which is what the four goingson grew
742 + /// independently were adopted onto.
738 743 ///
739 744 /// The trough is a [`Depth::Well`], the same reading a text field gets:
740 745 /// something with its content down inside it.
A src/meter.rs +164
@@ -1,0 +1,164 @@
1 + //! A proportion, rendered as a bar.
2 + //!
3 + //! The third phase-B emitter, beside [`form`](crate::form) and
4 + //! [`list`](crate::list). It is much the smallest, and it is here rather than in
5 + //! the app because the trough it fills has been in phase A since before anything
6 + //! could describe one: `progress_rules` emitted `.progress` and
7 + //! `.progress-fill[data-tone]` for every tone while the only way to say "3 of 7"
8 + //! was to concatenate it into a heading.
9 + //!
10 + //! # What the pair buys, at the last layer
11 + //!
12 + //! `makeover_layout::Meter` carries `done` and `total` rather than a percentage,
13 + //! and the reason shows up here. A bar that is full because it landed exactly
14 + //! and a bar that is full because it ran over are the same width and are not the
15 + //! same fact, so the width is not allowed to be the only thing emitted. The
16 + //! over-run leaves as `data-over="true"`, and the accessible name keeps both
17 + //! true numbers.
18 + //!
19 + //! No CSS rule is emitted for `data-over`. What an over-run should look like is
20 + //! app taste — goingson already says it with `Tone::Danger` — and a renderer
21 + //! that picked a stripe for everyone would be decorating rather than describing.
22 +
23 + use crate::form::escape;
24 + use crate::{Emit, class};
25 + use makeover_layout::{Intent, Meter, Tone};
26 + use std::fmt::Write as _;
27 +
28 + /// The accessible name for a meter: the two numbers, and the noun if it has one.
29 + ///
30 + /// The description carries the noun alone, so the sentence is built here. That
31 + /// is the whole reason `Meter::label` is not the assembled string: a tooltip
32 + /// wants "3 of 7 subtasks" and a terminal at one line wants "3/7", and a
33 + /// description that shipped either one would have chosen for both.
34 + ///
35 + /// The true `done` is used, not the clamped one. This is the text that says an
36 + /// over-run happened.
37 + #[must_use]
38 + pub fn meter_text(meter: &Meter<'_>) -> String {
39 + match meter.label {
40 + Some(label) => format!("{} of {} {label}", meter.done, meter.total),
41 + None => format!("{} of {}", meter.done, meter.total),
42 + }
43 + }
44 +
45 + /// A meter as a filled trough.
46 + ///
47 + /// ```
48 + /// use makeover_layout::{Meter, Tone};
49 + /// use makeover_webview::{Emit, meter::meter_html};
50 + ///
51 + /// let meter = Meter::new(3, 7).tone(Tone::Success).label("subtasks");
52 + /// let html = meter_html(&meter, &Emit::default());
53 + ///
54 + /// assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
55 + /// assert!(html.contains(r#"data-tone="success""#));
56 + /// assert!(html.contains("width: 42%"));
57 + /// ```
58 + ///
59 + /// `aria-valuenow` is clamped to `aria-valuemax`, because a value outside the
60 + /// range is invalid ARIA and a screen reader is entitled to ignore the whole
61 + /// element. The unclamped truth is in the accessible name, which is read either
62 + /// way.
63 + #[must_use]
64 + pub fn meter_html(meter: &Meter<'_>, opts: &Emit) -> String {
65 + let progress = class("progress", opts);
66 + let fill = class("progress-fill", opts);
67 + let reported = meter.done.min(meter.total);
68 +
69 + let mut html = format!(
70 + "<div class=\"{progress}\" role=\"progressbar\" aria-valuenow=\"{reported}\" \
71 + aria-valuemin=\"0\" aria-valuemax=\"{}\" aria-label=\"{}\">",
72 + meter.total,
73 + escape(&meter_text(meter))
74 + );
75 +
76 + let _ = write!(html, "<div class=\"{fill}\"");
77 + // Neutral is the untoned bar, and `progress_rules` gives it `--action`
78 + // rather than a tone attribute. Emitting `data-tone="content-muted"` would
79 + // match a rule that does not exist and read as disabled if it did.
80 + if meter.tone != Tone::Neutral {
81 + let _ = write!(html, " data-tone=\"{}\"", meter.tone.token());
82 + }
83 + if meter.overflowing() {
84 + html.push_str(" data-over=\"true\"");
85 + }
86 + let _ = write!(html, " style=\"width: {}%\"></div></div>", meter.percent());
87 + html
88 + }
89 +
90 + #[cfg(test)]
91 + mod tests {
92 + use super::*;
93 +
94 + #[test]
95 + fn a_full_bar_says_whether_it_ran_over() {
96 + // The two facts a percentage could not tell apart, and the reason the
97 + // description carries a pair. Both are 100% wide.
98 + let exact = meter_html(&Meter::new(30, 30), &Emit::default());
99 + let over = meter_html(&Meter::new(45, 30), &Emit::default());
100 +
101 + assert!(exact.contains("width: 100%"));
102 + assert!(over.contains("width: 100%"));
103 + assert!(!exact.contains("data-over"));
104 + assert!(over.contains(r#"data-over="true""#));
105 + }
106 +
107 + #[test]
108 + fn the_accessible_name_keeps_the_number_the_bar_cannot_show() {
109 + // The bar is clamped and the name is not. Losing this is how an
110 + // over-run becomes invisible to anyone not looking at the colour.
111 + let over = Meter::new(45, 30).label("minutes");
112 + assert_eq!(meter_text(&over), "45 of 30 minutes");
113 + assert!(meter_html(&over, &Emit::default()).contains(r#"aria-label="45 of 30 minutes""#));
114 + }
115 +
116 + #[test]
117 + fn aria_valuenow_stays_inside_its_range() {
118 + // Outside it, the element is invalid and a reader may drop it whole,
119 + // which would lose the label above along with it.
120 + let html = meter_html(&Meter::new(45, 30), &Emit::default());
121 + assert!(html.contains(r#"aria-valuenow="30""#));
122 + assert!(html.contains(r#"aria-valuemax="30""#));
123 + }
124 +
125 + #[test]
126 + fn an_untoned_bar_emits_no_tone_attribute() {
127 + // `progress_rules` styles the untoned bar with `--action` on the bare
128 + // class. A `data-tone="content-muted"` here would match no rule.
129 + let plain = meter_html(&Meter::new(1, 2), &Emit::default());
130 + assert!(!plain.contains("data-tone"));
131 +
132 + let toned = meter_html(&Meter::new(1, 2).tone(Tone::Danger), &Emit::default());
133 + assert!(toned.contains(r#"data-tone="danger""#));
134 + }
135 +
136 + #[test]
137 + fn an_empty_set_renders_an_empty_trough() {
138 + // Sayable, so it has to be emittable. Nothing here divides by zero.
139 + let html = meter_html(&Meter::new(0, 0), &Emit::default());
140 + assert!(html.contains("width: 0%"));
141 + assert!(html.contains(r#"aria-valuemax="0""#));
142 + }
143 +
144 + #[test]
145 + fn the_label_is_escaped_like_every_other_string() {
146 + // It arrives from the app the same as a field label does.
147 + let html = meter_html(&Meter::new(1, 2).label("a & b"), &Emit::default());
148 + assert!(html.contains("a &amp; b"));
149 + assert!(!html.contains("a & b"));
150 + }
151 +
152 + #[test]
153 + fn the_prefix_reaches_both_classes() {
154 + // A prefixed build claims its own names, and the fill is a descendant
155 + // selector in the emitted CSS: miss one and the rule stops matching.
156 + let opts = Emit {
157 + class_prefix: "mo-",
158 + ..Emit::default()
159 + };
160 + let html = meter_html(&Meter::new(1, 2), &opts);
161 + assert!(html.contains(r#"class="mo-progress""#));
162 + assert!(html.contains(r#"class="mo-progress-fill""#));
163 + }
164 + }