Skip to main content

max / makeover-webview

0.60.0: draw the wait the emitter has been describing to nobody `data-awaiting` has been written since the vocabulary gained `Awaiting`, with `determinate` plus a `data-awaiting-amount` or `indeterminate` alone, and nothing in the tree styled either value. Measured 2026-08-26: no rule for `[data-awaiting]` here, in makeover, or in the MNW server's stylesheets. The sole reader was `upload.js`, which reads it to run the upload chain rather than to draw anything. So the two kinds of wait rendered identically, which is to say not at all. `awaiting_rules` is phase 2 of wiki `loading-and-progress-standard` for this surface. One pseudo-element on both branches, so no renderer emits an extra node: a blinking square for the unmeasured half, and for the measured one a trough whose fill is a gradient stop at `--awaiting-share`, a plain number the binder sets from bytes it has watched land. Keyed on `aria-busy` and not on `data-awaiting` alone. The attribute is a fact about the control -- pressing this waits -- and is true when the page is painted. Whether a wait is running is true only between two events, so it is the binder's to set, and `aria-busy` is the standard spelling of it: announced as well as drawn, and no client library's vocabulary. The share defaults to zero, so a determinate control with nobody counting draws a trough rather than a full bar. The cadence is `--cadence-activity`, used bare: a fallback here would be a second heartbeat for a mark three renderers draw. A sheet assembled without the time axis leaves the duration invalid, which resolves to 0s, which runs no animation and leaves a lit still mark -- the same place the reduced-motion block lands, because the base style is lit and the keyframes do the dimming.
Author: Max Johnson <me@maxj.phd> · 2026-08-26 19:41 UTC
Signed with PGP, not checked
Commit: b6766542efa54ae8b5ef2cf3e5f9c9707d141d83
Parent: 2242ba2
2 files changed, +202 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.59.2"
3 + version = "0.60.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
M src/lib.rs +201
@@ -1260,6 +1260,135 @@
1260 1260 css
1261 1261 }
1262 1262
1263 + /// What a wait looks like, for the attribute that has been describing one to
1264 + /// nobody.
1265 + ///
1266 + /// Wiki `loading-and-progress-standard`, phase 2. `data-awaiting` has been
1267 + /// emitted since the vocabulary gained `Awaiting`, with
1268 + /// `data-awaiting="determinate"` and a `data-awaiting-amount` beside it or
1269 + /// `data-awaiting="indeterminate"` alone, and until 0.60.0 **nothing in the tree
1270 + /// styled either**. Determinate and indeterminate rendered identically, which is
1271 + /// to say not at all. This is the half that was missing.
1272 + ///
1273 + /// # Why it is keyed on `aria-busy` and not on the attribute alone
1274 + ///
1275 + /// `data-awaiting` is a fact about the control: pressing this waits. It is true
1276 + /// when the page is painted and it stays true. Whether a wait is *running* is
1277 + /// true only between two events, so it is the binder's to set, and `aria-busy`
1278 + /// is the standard spelling of it — announced as well as drawn, which a class
1279 + /// of our own would not be.
1280 + ///
1281 + /// That also keeps this crate out of any one client library's vocabulary.
1282 + /// `quasi-webview` sets `aria-busy` from htmx's request events; a host driving
1283 + /// the same markup another way sets it the same way and gets the same drawing.
1284 + ///
1285 + /// # The two drawings
1286 + ///
1287 + /// One pseudo-element either way, so no renderer has to emit an extra node.
1288 + ///
1289 + /// Indeterminate is the activity mark of rule 2: a small square that blinks.
1290 + /// Determinate is a trough with a fill, drawn as a single gradient whose stop is
1291 + /// `--awaiting-share`, a plain number from 0 to 1 that the binder sets from
1292 + /// bytes it has actually watched land. A determinate control with nothing
1293 + /// setting the share draws an empty trough rather than a full one, which is the
1294 + /// honest reading: the size is known and the delivery is not.
1295 + ///
1296 + /// **What the bar may not do**, from rule 1 and from `Awaiting`'s own docs:
1297 + /// what is done over what there is, and never a remaining time, an arrival time
1298 + /// or a rate extrapolated forward. Nothing here can express one, which is
1299 + /// deliberate — the only input is a share of a measured payload.
1300 + ///
1301 + /// # Sizes, and the deferral rule
1302 + ///
1303 + /// `progress_rules` emits the tones and never the width, because the width is
1304 + /// not this crate's to know. A pseudo-element has no intrinsic size at all, so
1305 + /// the same treatment would render nothing anywhere. Both sizes are therefore
1306 + /// custom properties with defaults: an app that wants a different mark sets
1307 + /// `--awaiting-mark` and `--awaiting-bar` once, and one that says nothing gets a
1308 + /// mark that is visible.
1309 + ///
1310 + /// # The cadence, and what happens without it
1311 + ///
1312 + /// `--cadence-activity` comes from `makeover-timing` through `makeover-build`,
1313 + /// and is a half-period, so a full cycle is twice it. It is used bare rather
1314 + /// than with a fallback: a number written here would be a second heartbeat for
1315 + /// a mark three renderers draw.
1316 + ///
1317 + /// A sheet assembled without the time axis leaves `animation-duration` invalid,
1318 + /// which resolves to `0s`, which runs no animation and leaves the base style —
1319 + /// a lit, still mark. That is also exactly what the reduced-motion block does,
1320 + /// since it sets the cadence to `0ms`. Both fall out of one rule because the
1321 + /// base style is lit and the keyframes do the dimming, which is the ordering
1322 + /// `makeover_timing::reduced_motion_css` asks its consumers for by name.
1323 + fn awaiting_rules(opts: &Emit) -> String {
1324 + let mut css = String::new();
1325 +
1326 + // Dimming rather than lighting, so a zero-length animation leaves a lit
1327 + // mark rather than a blank one. See `makeover_timing::reduced_motion_css`.
1328 + css.push_str(
1329 + "@keyframes makeover-activity {\n \
1330 + 0%, 49.99% {\n background: var(--action);\n }\n \
1331 + 50%, 100% {\n background: var(--surface-sunken);\n }\n\
1332 + }\n",
1333 + );
1334 +
1335 + // Nothing is drawn until something is waiting. `content` on the base rule
1336 + // rather than on the busy one keeps the box the same box across the
1337 + // transition, so a mark appearing does not reflow the line it is in.
1338 + let _ = writeln!(
1339 + css,
1340 + "[data-awaiting]::after {{\n \
1341 + content: \"\";\n \
1342 + display: none;\n \
1343 + margin-inline-start: var(--awaiting-gap, 0.5ch);\n \
1344 + vertical-align: baseline;\n\
1345 + }}"
1346 + );
1347 +
1348 + let _ = writeln!(
1349 + css,
1350 + "[data-awaiting][aria-busy=\"true\"]::after {{\n \
1351 + display: inline-block;\n \
1352 + inline-size: var(--awaiting-mark, 0.5em);\n \
1353 + block-size: var(--awaiting-mark, 0.5em);\n \
1354 + background: var(--action);\n \
1355 + opacity: 1;\n \
1356 + animation: makeover-activity calc(var(--cadence-activity) * 2) \
1357 + step-end infinite;\n\
1358 + }}"
1359 + );
1360 +
1361 + // The measured half. A wider box, no blink, and a gradient whose stop is
1362 + // the share: the fill and the trough in one paint, so the markup stays one
1363 + // pseudo-element on both branches.
1364 + //
1365 + // `--awaiting-share` unset is an empty trough, not a full one. A bar that
1366 + // read full because nobody was counting would be the confidently-wrong
1367 + // drawing rule 1 exists to forbid.
1368 + //
1369 + // The trough takes an edge for the reason a well does: a bar at zero share
1370 + // is otherwise a rectangle of the surface it sits on, which is nothing at
1371 + // all. `border_width` rather than a literal, the way `depth_rule` and
1372 + // `track_rules` write theirs.
1373 + let _ = writeln!(
1374 + css,
1375 + "[data-awaiting=\"determinate\"][aria-busy=\"true\"]::after {{\n \
1376 + inline-size: var(--awaiting-bar, 6em);\n \
1377 + animation: none;\n \
1378 + outline: {} solid var(--border);\n \
1379 + outline-offset: -{};\n \
1380 + background: linear-gradient(\n \
1381 + to inline-end,\n \
1382 + var(--action) 0 calc(var(--awaiting-share, 0) * 100%),\n \
1383 + var(--surface-sunken) 0\n \
1384 + );\n\
1385 + }}",
1386 + opts.border_width, opts.border_width
1387 + );
1388 +
1389 + css
1390 + }
1391 +
1263 1392 /// A strip of figures, and the two spans inside each one.
1264 1393 ///
1265 1394 /// Colour only, which is the deferral rule applied to a component that badly
@@ -1749,6 +1878,7 @@
1749 1878 css.push_str(&row_rules(opts));
1750 1879 css.push_str(&run_rules(opts));
1751 1880 css.push_str(&progress_rules(opts));
1881 + css.push_str(&awaiting_rules(opts));
1752 1882 css.push_str(&figure_rules(opts));
1753 1883 css.push_str(&picture_rules(opts));
1754 1884 css.push_str(&showing_rules(opts));
@@ -2688,6 +2818,63 @@
2688 2818 assert!(crate::vocabulary::names(&Emit::default()).contains("row-relaxed"));
2689 2819 }
2690 2820
2821 + #[test]
2822 + fn the_two_kinds_of_wait_stop_rendering_identically() {
2823 + // The state `d43ea1c5` fixes: the emitter had been writing
2824 + // `data-awaiting` for months and nothing styled either value, so a
2825 + // measured wait and an unmeasured one drew the same nothing.
2826 + let css = stylesheet(&Emit::default());
2827 + assert!(css.contains("[data-awaiting]::after"), "{css}");
2828 + assert!(
2829 + css.contains("[data-awaiting][aria-busy=\"true\"]::after"),
2830 + "the mark is drawn only while something is actually waiting"
2831 + );
2832 + assert!(
2833 + css.contains("[data-awaiting=\"determinate\"][aria-busy=\"true\"]::after"),
2834 + "the measured half is its own drawing"
2835 + );
2836 + }
2837 +
2838 + #[test]
2839 + fn the_blink_takes_its_cadence_and_never_names_one() {
2840 + // Three renderers draw this mark. A number written here would be a
2841 + // second heartbeat for one wait.
2842 + let css = stylesheet(&Emit::default());
2843 + assert!(
2844 + css.contains("calc(var(--cadence-activity) * 2)"),
2845 + "a half-period doubled, not a literal"
2846 + );
2847 + assert!(!css.contains("500ms"), "{css}");
2848 + }
2849 +
2850 + #[test]
2851 + fn a_bar_nobody_is_counting_is_empty_rather_than_full() {
2852 + // Rule 1. The share defaults to zero, so a determinate control with no
2853 + // binder watching bytes draws a trough. A default of 1 would be the
2854 + // confidently-wrong drawing the rule exists to forbid.
2855 + let css = stylesheet(&Emit::default());
2856 + assert!(css.contains("var(--awaiting-share, 0)"), "{css}");
2857 + }
2858 +
2859 + #[test]
2860 + fn motion_off_leaves_the_mark_lit_because_the_keyframes_do_the_dimming() {
2861 + // `makeover_timing::reduced_motion_css` sets `--cadence-activity: 0ms`,
2862 + // and a zero-length animation leaves the element in its base style
2863 + // rather than at its last keyframe. So the base has to be the lit one.
2864 + // The inverted spelling would blank the mark for a reader who asked for
2865 + // less motion, which answers a request nobody made.
2866 + let css = stylesheet(&Emit::default());
2867 + let busy = css
2868 + .split("[data-awaiting][aria-busy=\"true\"]::after {")
2869 + .nth(1)
2870 + .expect("the busy rule");
2871 + let busy = busy.split('}').next().expect("its body");
2872 + assert!(
2873 + busy.contains("background: var(--action);"),
2874 + "the base state is lit: {busy}"
2875 + );
2876 + }
2877 +
2691 2878 #[test]
2692 2879 fn the_whole_sheet_still_names_every_colour() {
2693 2880 // The crate's founding property, asserted over the component layer and
@@ -2738,6 +2925,20 @@
2738 2925 || matches!(
2739 2926 value.trim_end_matches(';'),
2740 2927 "0" | "1"
2928 + // The wait's mark and bar, 0.60.0. An empty
2929 + // `content` is what brings a pseudo-element into
2930 + // existence with nothing in it, and `step-end`
2931 + // is an easing: the blink is two states, not a
2932 + // slide between them. Neither is a colour, and
2933 + // neither is a magnitude.
2934 + | "\"\""
2935 + // Where the mark sits on the line it joins.
2936 + // Alignment is structure, the same way `display`
2937 + // is, and `baseline` is the initial value said out
2938 + // loud so a host stylesheet cannot leave it
2939 + // wherever an earlier rule put it.
2940 + | "baseline"
2941 + | "inline-block"
2741 2942 | "none"
2742 2943 | "auto"
2743 2944 | "not-allowed"