Skip to main content

max / makeover-webview

0.24.0: emit a figure's change, and tone it rather than the value
Author: Max Johnson <me@maxj.phd> · 2026-08-11 01:35 UTC
Signed with PGP, not checked
Commit: 2a95c2a04da9b3aeccf72b12ae0b6d7f329c103f
Parent: 4b13146
3 files changed, +116 insertions, -18 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.23.0"
3 + version = "0.24.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"
@@ -12,7 +12,7 @@
12 12 # patch satisfy the requirement and still fail to compile. That happened once
13 13 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
14 14 # it surfaced, one release later.
15 - makeover-layout = "0.12.0"
15 + makeover-layout = "0.13.0"
16 16 # The capability axis. `makeover-touch` decides whether a hover rule should be
17 17 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
18 18 # answers are owned elsewhere and neither is re-derived here.
M src/figure.rs +61 -2
@@ -38,7 +38,13 @@
38 38 /// [`meter_text`]: crate::meter::meter_text
39 39 #[must_use]
40 40 pub fn figure_text(figure: &Figure<'_>) -> String {
41 - format!("{}: {}", figure.caption, figure.value)
41 + figure.change.map_or_else(
42 + || format!("{}: {}", figure.caption, figure.value),
43 + // The delta reaches a reader as part of the one name, because the spans
44 + // below are all `aria-hidden` and it would otherwise reach them not at
45 + // all. "Views: 1,204, +12.5%" rather than a bare number after a comma.
46 + |change| format!("{}: {}, {change}", figure.caption, figure.value),
47 + )
42 48 }
43 49
44 50 /// One figure, as its own element.
@@ -71,12 +77,26 @@
71 77 let _ = write!(
72 78 html,
73 79 "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
74 - <span class=\"{}\" aria-hidden=\"true\">{}</span></div>",
80 + <span class=\"{}\" aria-hidden=\"true\">{}</span>",
75 81 class("figure-value", opts),
76 82 escape(figure.value),
77 83 class("figure-caption", opts),
78 84 escape(figure.caption),
79 85 );
86 + // 0.13.0. Its own element rather than more of the caption, so a stylesheet
87 + // can set it smaller and a renderer with one line can drop it first. The
88 + // tone is already on the wrapper and the rule keys off it from there, which
89 + // is why the delta carries no `data-tone` of its own: two elements claiming
90 + // one tone is how they end up disagreeing.
91 + if let Some(change) = figure.change {
92 + let _ = write!(
93 + html,
94 + "<span class=\"{}\" aria-hidden=\"true\">{}</span>",
95 + class("figure-change", opts),
96 + escape(change),
97 + );
98 + }
99 + html.push_str("</div>");
80 100 html
81 101 }
82 102
@@ -114,6 +134,45 @@
114 134 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
115 135 }
116 136
137 + #[test]
138 + fn a_change_is_its_own_span_and_reaches_a_reader_through_the_name() {
139 + // 0.13.0. The spans are all `aria-hidden`, so a delta that is not in the
140 + // accessible name reaches a screen reader not at all.
141 + let figure = Figure::new("1,204", "Views").change("+12.5%");
142 + assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%");
143 +
144 + let html = figure_html(&figure, &Emit::default());
145 + assert!(html.contains("figure-change"), "{html}");
146 + assert!(html.contains(">+12.5%<"), "{html}");
147 + assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3);
148 +
149 + // A figure with nothing to compare against emits no empty span for it.
150 + let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
151 + assert!(!plain.contains("figure-change"), "{plain}");
152 + }
153 +
154 + #[test]
155 + fn a_change_carries_no_tone_of_its_own() {
156 + // One element claims the figure's meaning and the sheet reaches the
157 + // right span from there. Two would be two things able to disagree.
158 + let html = figure_html(
159 + &Figure::new("1,204", "Views")
160 + .change("-4%")
161 + .tone(Tone::Danger),
162 + &Emit::default(),
163 + );
164 + assert_eq!(html.matches("data-tone").count(), 1, "{html}");
165 + }
166 +
167 + #[test]
168 + fn a_change_is_text_and_cannot_become_markup() {
169 + let html = figure_html(
170 + &Figure::new("1", "Views").change("<img src=x onerror=alert(1)>"),
171 + &Emit::default(),
172 + );
173 + assert!(!html.contains("<img"), "{html}");
174 + }
175 +
117 176 #[test]
118 177 fn an_untoned_figure_emits_no_tone_attribute() {
119 178 // Same reason as the meter: the bare class is the untoned rule, so an
M src/lib.rs +53 -14
@@ -189,7 +189,9 @@
189 189 //! a renderer emits for one.
190 190 //!
191 191 //! `RowPart::revealed_on_hover` also stops being honoured, which its own doc
192 - //! sanctions: a renderer decides. The rule hid a row's actions until hover,
192 + //! sanctioned: a renderer decides. It was retired outright in makeover-layout
193 + //! 0.13.0, once this had been its only consumer for a release. The rule hid a
194 + //! row's actions until hover,
193 195 //! and every escape it grew was a report that hiding was wrong for somebody:
194 196 //! `focus-within` for the keyboard, the capability gate for a fingertip with no
195 197 //! way to unhide. What survived hid the controls from pointer users alone, who
@@ -768,12 +770,10 @@
768 770 let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent());
769 771 }
770 772
771 - // `RowPart::revealed_on_hover` is deliberately not honoured, and its
772 - // own doc is what sanctions that: "a renderer with no hover shows it
773 - // always. That is a renderer decision and this returning `true` does
774 - // not forbid it." This renderer now makes the same decision for every
775 - // device, and the reasoning is the one the gate had already been
776 - // forced into twice.
773 + // A row's actions are shown at rest. `RowPart::revealed_on_hover` said
774 + // otherwise and was not honoured here from 0.23.0; makeover-layout
775 + // 0.13.0 retired the method, so there is no longer a description saying
776 + // one thing and a renderer doing another.
777 777 //
778 778 // The rule was `opacity: 0` gated to pointer devices, revealed on
779 779 // `:hover` and on `:focus-within`. Each escape it needed was a report
@@ -855,6 +855,7 @@
855 855 let figure = class("figure", opts);
856 856 let value = class("figure-value", opts);
857 857 let caption = class("figure-caption", opts);
858 + let change = class("figure-change", opts);
858 859 let mut css = String::new();
859 860
860 861 let _ = writeln!(
@@ -866,13 +867,31 @@
866 867 css,
867 868 ".{figure} > .{caption} {{\n color: var(--content-muted);\n}}"
868 869 );
870 + let _ = writeln!(
871 + css,
872 + ".{figure} > .{change} {{\n color: var(--content-muted);\n}}"
873 + );
869 874
870 - // A toned figure tones the value and never the caption. The caption is the
871 - // noun and stays muted; the number is the thing that is saying something.
875 + // A toned figure tones one part and never the caption. The caption is the
876 + // noun and stays muted.
877 + //
878 + // Which part depends on whether there is a change, and that is the whole of
879 + // what 0.13.0 changed here. A figure with a delta is an ordinary number that
880 + // has moved in a direction worth reading, so the delta takes the colour and
881 + // the number stays plain; a figure without one has nowhere else to put it.
882 + // `:has` is what lets one attribute mean both, and the alternative was the
883 + // emitter deciding by writing the attribute onto a different element, which
884 + // leaves two elements able to disagree about a figure's one meaning.
872 885 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
873 886 let _ = writeln!(
874 887 css,
875 - ".{figure}[data-tone=\"{0}\"] > .{value} {{\n color: var(--{0});\n}}",
888 + ".{figure}[data-tone=\"{0}\"] > .{change} {{\n color: var(--{0});\n}}",
889 + tone.token()
890 + );
891 + let _ = writeln!(
892 + css,
893 + ".{figure}[data-tone=\"{0}\"]:not(:has(> .{change})) > .{value} \
894 + {{\n color: var(--{0});\n}}",
876 895 tone.token()
877 896 );
878 897 }
@@ -1484,11 +1503,31 @@
1484 1503 // order; the point is that neither is reached for.
1485 1504 assert!(!css.contains("display: none"), "{css}");
1486 1505 assert!(!css.contains("visibility:"), "{css}");
1506 + }
1487 1507
1488 - // The description still says the part is revealed on hover. That is a
1489 - // renderer's decision to make, in the words of the method's own doc,
1490 - // and this renderer now decides otherwise for every device.
1491 - assert!(RowPart::Actions.revealed_on_hover());
1508 + #[test]
1509 + fn a_figures_tone_lands_on_the_delta_when_there_is_one() {
1510 + // 0.13.0. The delta is the part that reads as good or bad; the number
1511 + // itself is an ordinary fact. A figure with no delta has nowhere else to
1512 + // put the colour, so the value takes it, and `:has` is what lets one
1513 + // attribute mean both without the emitter choosing an element.
1514 + let css = stylesheet(&Emit::default());
1515 +
1516 + assert!(
1517 + css.contains(".figure[data-tone=\"success\"] > .figure-change"),
1518 + "{css}"
1519 + );
1520 + assert!(
1521 + css.contains(
1522 + ".figure[data-tone=\"success\"]:not(:has(> .figure-change)) > .figure-value"
1523 + ),
1524 + "{css}"
1525 + );
1526 + // The caption is the noun and never takes the tone.
1527 + assert!(
1528 + !css.contains("[data-tone=\"success\"] > .figure-caption"),
1529 + "{css}"
1530 + );
1492 1531 }
1493 1532
1494 1533 #[test]