Skip to main content

max / makeover-webview

0.31.0: the sort caret brings its own gap and its own reserved box makeover-tui and makeover-immediate both write their caret as " \u{25B2}", with the space. This renderer appended the bare glyph, so a sorted heading read as DESCRIPTION-arrow with nothing between them, and both apps that adopted the vocabulary on 2026-08-11 put the gap back in their own stylesheets within minutes of each other. Each had to work out first that an app stylesheet is unlayered and so outranks @layer makeover, which makes the obvious fix -- declaring `content` with a leading space -- win over the generated caret and leave the heading with no arrow at all. Both landed on a box-only rule with a comment explaining the trap. A design system that leaks a cascade fact to two consumers in one afternoon is the thing this crate exists to stop, so the space moves into the glyph here and there is nothing left for a consumer to add. The unsorted arm now reserves the same box with the glyph hidden, so pressing a heading no longer reflows the row it sits in; both apps had written that too. Hidden rather than sized, because the reservation is the caret's own width and names no magnitude, which keeps it out of makeover-geometry's territory.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 14:17 UTC
Signed with PGP, not checked
Commit: 7ed150566db88d3488451ed3e590ee16b08563c4
Parent: 38104c4
2 files changed, +59 insertions, -4 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.30.1"
3 + version = "0.31.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 +58 -3
@@ -987,10 +987,29 @@
987 987 css,
988 988 ".{heading}[data-sortable] {{\n cursor: pointer;\n}}"
989 989 );
990 + // The caret carries its own leading space, the way `makeover-tui` and
991 + // `makeover-immediate` both write `" \u{25B2}"`. It used to be emitted bare,
992 + // and both apps that adopted the vocabulary had to put the gap back in their
993 + // own stylesheets on the same afternoon -- each having to work out first that
994 + // app CSS outranks this crate's cascade layer, so adding the space the
995 + // obvious way, as `content`, silently wins over the glyph and leaves the
996 + // heading with no caret at all. A consumer should not have to know that, and
997 + // with the space emitted here there is nothing left for one to add.
998 + //
999 + // The unsorted arm reserves the same box with the glyph hidden, so pressing
1000 + // a heading does not reflow the row it sits in. Hidden rather than sized:
1001 + // the reservation is the caret's own width, so it names no magnitude and
1002 + // stays out of `makeover-geometry`'s territory.
1003 + let _ = writeln!(
1004 + css,
1005 + ".{heading}[data-sortable]::after \
1006 + {{\n content: \" \\2191\";\n visibility: hidden;\n}}"
1007 + );
990 1008 for (direction, caret) in [("ascending", "\\2191"), ("descending", "\\2193")] {
991 1009 let _ = writeln!(
992 1010 css,
993 - ".{heading}[aria-sort=\"{direction}\"]::after {{\n content: \"{caret}\";\n}}"
1011 + ".{heading}[aria-sort=\"{direction}\"]::after \
1012 + {{\n content: \" {caret}\";\n visibility: visible;\n}}"
994 1013 );
995 1014 }
996 1015 css
@@ -1780,6 +1799,32 @@
1780 1799 assert_eq!(option_class(Selector::Tabs), "tab");
1781 1800 }
1782 1801
1802 + #[test]
1803 + fn the_caret_brings_its_own_gap_and_its_own_reserved_box() {
1804 + let css = stylesheet(&Emit::default());
1805 +
1806 + // The space is inside the glyph, which is what the other two renderers
1807 + // write. Emitted bare, every consumer has to add it back, and the
1808 + // obvious way to add it -- `content` in an app stylesheet, which is
1809 + // unlayered and so outranks this sheet -- deletes the caret instead.
1810 + assert!(css.contains("content: \" \\2191\";"), "{css}");
1811 + assert!(css.contains("content: \" \\2193\";"), "{css}");
1812 + assert!(!css.contains("content: \"\\2"), "{css}");
1813 +
1814 + // The unsorted arm holds the box open so pressing a heading does not
1815 + // move the row, and the sorted arms have to turn the glyph back on
1816 + // after it: same specificity, so order is what decides.
1817 + let reserve = css
1818 + .find(".table-heading[data-sortable]::after")
1819 + .expect("the reserved box is emitted");
1820 + let sorted = css
1821 + .find(".table-heading[aria-sort=\"ascending\"]::after")
1822 + .expect("the ascending caret is emitted");
1823 + assert!(reserve < sorted, "{css}");
1824 + assert!(css[reserve..sorted].contains("visibility: hidden;"), "{css}");
1825 + assert!(css[sorted..].contains("visibility: visible;"), "{css}");
1826 + }
1827 +
1783 1828 #[test]
1784 1829 fn a_destructive_button_has_somewhere_for_its_tone_to_land() {
1785 1830 let css = component_rules(&Emit::default());
@@ -1907,8 +1952,14 @@
1907 1952 // with the pointer, and the caret is this renderer's own
1908 1953 // expression of `aria-sort`. Neither is a colour, which is
1909 1954 // what this test is actually about, and neither is a size,
1910 - // which is the other thing this crate must not name.
1911 - || value.starts_with("\"\\2")
1955 + // which is the other thing this crate must not name. The
1956 + // leading space inside the glyph is the same thing the other
1957 + // two renderers write into theirs, so it is part of the
1958 + // caret rather than spacing this crate decided on.
1959 + || value
1960 + .trim_start_matches('"')
1961 + .trim_start()
1962 + .starts_with("\\2")
1912 1963 || matches!(
1913 1964 value.trim_end_matches(';'),
1914 1965 "0" | "1"
@@ -1916,6 +1967,10 @@
1916 1967 | "auto"
1917 1968 | "not-allowed"
1918 1969 | "pointer"
1970 + // The caret's reserved box. Visibility is presence,
1971 + // not magnitude and not colour.
1972 + | "hidden"
1973 + | "visible"
1919 1974 // The table frame. `display` is structure and not a
1920 1975 // size; `nowrap` is what makes a content column
1921 1976 // content. The two widths are the awkward pair and