Skip to main content

max / makeover-webview

0.46.0: the triangles, and an idle sort mark that is drawn rather than hidden Two changes to the sort caret, folded because writing the second against the arrow that was about to change would have meant writing it twice. The glyph comes from Sort::glyph (makeover-layout 0.27.5) instead of this renderer's own \2191/\2193, so all three renderers spell it once. Settled 2026-08-16 (Max): the solid triangles, because the bolder simpler glyph wins. Escaped rather than written literally, through a css_escape helper, so the sheet stays ASCII whatever encoding a consumer serves it as. The idle arm drew nothing and reserved a hidden box. It draws now, at content-secondary, and the two sorted arms state content explicitly instead of inheriting -- three states of one control reading off each other in the emitted CSS, per wiki three-tone-convention. The reservation the hidden box existed for stops being a thing to get right: the glyph is always there. Neither self-hosted web face carries U+25B2/U+25BC, so a browser falls back per glyph until the in-house face ships them (makeover 6d6d9146).
Author: Max Johnson <me@maxj.phd> · 2026-08-16 21:49 UTC
Signed with PGP, not checked
Commit: caf1f63333a1b82773db181eccc82b74608f0e26
Parent: ade50c5
3 files changed, +60 insertions, -23 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.45.0"
3 + version = "0.46.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.27.0"
20 + makeover-layout = "0.27.5"
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.
M src/lib.rs +57 -20
@@ -270,7 +270,7 @@
270 270 // rules are as generated as the ones here.
271 271 pub use makeover_geometry::{CSS_LAYER, in_css_layer};
272 272 use makeover_layout::{
273 - Bevel, CellPart, Depth, Fill, Intent, RowPart, Selector, State, Token, Tone,
273 + Bevel, CellPart, Depth, Fill, Intent, RowPart, Selector, Sort, State, Token, Tone,
274 274 };
275 275 use makeover_touch::Affordance;
276 276 use std::fmt::Write as _;
@@ -311,6 +311,23 @@
311 311 }
312 312 }
313 313
314 + /// A string as CSS escapes, for a `content` value.
315 + ///
316 + /// `\u{25B2}` becomes `\25B2`. Emitted escaped rather than literally so the
317 + /// stylesheet is ASCII whatever the description spells: a `content` string is
318 + /// read by whatever encoding the consumer serves the file as, and a caret that
319 + /// depends on that is a caret that works on one machine.
320 + ///
321 + /// Terminated by the closing quote at every site here. A CSS hex escape takes
322 + /// up to six digits and ends at the first character that cannot be one, so an
323 + /// escape followed by more text would need a space that these do not.
324 + fn css_escape(text: &str) -> String {
325 + text.chars().fold(String::new(), |mut out, c| {
326 + let _ = write!(out, "\\{:X}", c as u32);
327 + out
328 + })
329 + }
330 +
314 331 /// The CSS custom property holding a bevel's composition.
315 332 #[must_use]
316 333 pub fn bevel_var(bevel: Bevel) -> &'static str {
@@ -1287,20 +1304,31 @@
1287 1304 // heading with no caret at all. A consumer should not have to know that, and
1288 1305 // with the space emitted here there is nothing left for one to add.
1289 1306 //
1290 - // The unsorted arm reserves the same box with the glyph hidden, so pressing
1291 - // a heading does not reflow the row it sits in. Hidden rather than sized:
1292 - // the reservation is the caret's own width, so it names no magnitude and
1293 - // stays out of `makeover-geometry`'s territory.
1307 + // Three states, three tones, on the convention in wiki
1308 + // `three-tone-convention`. A column in force is `content`; a column offering
1309 + // to reorder and not doing it now is `content-secondary`, because it still
1310 + // answers a press; a column that is not sortable emits no caret at all and
1311 + // takes nothing. The idle arm used to hide its glyph and reserve the box,
1312 + // which cost a reflow-free press and said nothing. It draws now, and the
1313 + // reservation stops being a thing to get right.
1314 + //
1315 + // The glyph is `Sort::glyph`, escaped rather than written: a CSS `content`
1316 + // string cannot carry the character literally through this file's own
1317 + // escaping, and spelling it here as well would put the third copy back that
1318 + // `makeover-layout` 0.27.5 exists to remove.
1294 1319 let _ = writeln!(
1295 1320 css,
1296 1321 ".{heading}[data-sortable]::after \
1297 - {{\n content: \" \\2191\";\n visibility: hidden;\n}}"
1322 + {{\n content: \" {}\";\n color: var(--content-secondary);\n}}",
1323 + css_escape(Sort::Ascending.glyph())
1298 1324 );
1299 - for (direction, caret) in [("ascending", "\\2191"), ("descending", "\\2193")] {
1325 + for direction in [Sort::Ascending, Sort::Descending] {
1300 1326 let _ = writeln!(
1301 1327 css,
1302 - ".{heading}[aria-sort=\"{direction}\"]::after \
1303 - {{\n content: \" {caret}\";\n visibility: visible;\n}}"
1328 + ".{heading}[aria-sort=\"{}\"]::after \
1329 + {{\n content: \" {}\";\n color: var(--content);\n}}",
1330 + direction.as_str(),
1331 + css_escape(direction.glyph())
1304 1332 );
1305 1333 }
1306 1334 css
@@ -2109,32 +2137,41 @@
2109 2137 }
2110 2138
2111 2139 #[test]
2112 - fn the_caret_brings_its_own_gap_and_its_own_reserved_box() {
2140 + fn the_caret_brings_its_own_gap_and_is_the_glyph_the_description_names() {
2113 2141 let css = stylesheet(&Emit::default());
2114 2142
2115 2143 // The space is inside the glyph, which is what the other two renderers
2116 2144 // write. Emitted bare, every consumer has to add it back, and the
2117 2145 // obvious way to add it -- `content` in an app stylesheet, which is
2118 2146 // unlayered and so outranks this sheet -- deletes the caret instead.
2119 - assert!(css.contains("content: \" \\2191\";"), "{css}");
2120 - assert!(css.contains("content: \" \\2193\";"), "{css}");
2147 + assert!(css.contains("content: \" \\25B2\";"), "{css}");
2148 + assert!(css.contains("content: \" \\25BC\";"), "{css}");
2121 2149 assert!(!css.contains("content: \"\\2"), "{css}");
2122 2150
2123 - // The unsorted arm holds the box open so pressing a heading does not
2124 - // move the row, and the sorted arms have to turn the glyph back on
2125 - // after it: same specificity, so order is what decides.
2126 - let reserve = css
2151 + // The arrows this renderer used to draw alone are gone. Composition
2152 + // rather than agreement: the glyph comes from `Sort::glyph`, so a
2153 + // fourth spelling cannot appear here without appearing everywhere.
2154 + assert!(!css.contains("2191") && !css.contains("2193"), "{css}");
2155 + assert!(css.contains(&css_escape(Sort::Ascending.glyph())), "{css}");
2156 +
2157 + // Three states, three tones. An idle sortable heading draws its caret
2158 + // now rather than reserving a hidden box for it, so there is no
2159 + // visibility to order and no reflow left to guard against; what
2160 + // separates the states is the colour, and the sorted arms come after
2161 + // the idle one because the specificity is the same.
2162 + let idle = css
2127 2163 .find(".table-heading[data-sortable]::after")
2128 - .expect("the reserved box is emitted");
2164 + .expect("the idle caret is emitted");
2129 2165 let sorted = css
2130 2166 .find(".table-heading[aria-sort=\"ascending\"]::after")
2131 2167 .expect("the ascending caret is emitted");
2132 - assert!(reserve < sorted, "{css}");
2168 + assert!(idle < sorted, "{css}");
2133 2169 assert!(
2134 - css[reserve..sorted].contains("visibility: hidden;"),
2170 + css[idle..sorted].contains("color: var(--content-secondary);"),
2135 2171 "{css}"
2136 2172 );
2137 - assert!(css[sorted..].contains("visibility: visible;"), "{css}");
2173 + assert!(css[sorted..].contains("color: var(--content);"), "{css}");
2174 + assert!(!css.contains("visibility: hidden;"), "{css}");
2138 2175 }
2139 2176
2140 2177 #[test]
@@ -144,7 +144,7 @@
144 144 ///
145 145 /// A comment is skipped whole: the banner at the top of the generated sheet is
146 146 /// prose about the cascade layer and would otherwise contribute words that look
147 - /// like selectors. A string is opaque, because `content: "\2191"` is the sort
147 + /// like selectors. A string is opaque, because `content: "\25B2"` is the sort
148 148 /// caret rather than a selector and a brace inside one would desync the stack.
149 149 /// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
150 150 /// declarations, so a depth counter alone is not enough and the stack records