Skip to main content

max / makeover-webview

0.11.0: emit the stylesheet inside the makeover cascade layer The blocker under the whole layer thread. Unlayered normal declarations outrank every named layer, so an app that declared `@layer base, components, responsive` put every rule it owns into a named layer and lost all of them to this unlayered file, regardless of specificity and regardless of loading last. Nothing errors: the CSS is valid, the minifier is happy, and buttons and badges look subtly wrong. The app cannot fix that from its own stylesheet, because the fix is to layer the file it does not own. So the layer is an output of the design system, not something each app has to know and restate. What it flips, and why each app wants a look when it bumps the pin: a generated rule that currently beats an app rule by being more specific stops beating it. The direction is always "the app wins", which is what the apps already assume, but a hand-written rule an app thought was dead can come back to life. The banner carries the `@layer makeover, base, components, responsive;` line an app should declare, since without a declared order the layer's position depends on which generated file the browser sees first, and that is not a contract. in_css_layer is re-exported for an app assembling its own sheet from this crate's pieces: goingson builds tables.css in its own build.rs out of list::narrowing_css, and those rules belong in the same layer. Two tests were asserting on indentation and had to change. The colour test identified declarations by counting four leading spaces, which only ever worked as a proxy for nesting depth; it keys on the trailing semicolon now, which is what a declaration actually is. The row-reveal test hunted for a closing brace and now compares indentation, since both halves sit inside the layer and every brace is nested. Neither change weakens what is asserted.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 16:46 UTC
Signed with PGP, not checked
Commit: defd3c4f2d7bc0e41137f77cc0533f51f4f0a65d
Parent: 32bdcda
2 files changed, +132 insertions, -19 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.10.0"
3 + version = "0.11.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,8 +11,8 @@
11 11 # The capability axis. `makeover-touch` decides whether a hover rule should be
12 12 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
13 13 # answers are owned elsewhere and neither is re-derived here.
14 - makeover-touch = "0.1"
15 - makeover-geometry = "0.5"
14 + makeover-touch = "0.2"
15 + makeover-geometry = "0.6"
16 16
17 17 [lints.rust]
18 18 unused = "warn"
M src/lib.rs +129 -16
@@ -99,6 +99,44 @@
99 99 //! solely to take the hover state back on touch, which is a fight it should
100 100 //! never have been handed.
101 101 //!
102 + //! # 0.11.0: the layer contract
103 + //!
104 + //! [`stylesheet`] emits into the `makeover` cascade layer ([`CSS_LAYER`], which
105 + //! lives in `makeover-geometry` because that is the one crate every CSS emitter
106 + //! in the family already depends on). `makeover-geometry` 0.6.0 does the same
107 + //! for `geometry.css`.
108 + //!
109 + //! The cascade resolves origin and importance, then layer, then specificity,
110 + //! then source order, and **unlayered normal declarations outrank every named
111 + //! layer**. So before this, an app that declared `@layer base, components,
112 + //! responsive` put every rule it owns into a named layer and lost all of them to
113 + //! this unlayered file, regardless of specificity and regardless of loading
114 + //! last. Nothing errors when that happens: the CSS is valid, the minifier is
115 + //! happy, and buttons and badges look subtly wrong.
116 + //!
117 + //! That is why the layer belongs here rather than in each app. An app cannot fix
118 + //! it from its own stylesheet, because the fix is to layer the file it does not
119 + //! own.
120 + //!
121 + //! **What it flips**, and the reason each app wants a look when it bumps the
122 + //! pin: a generated rule that currently beats an app rule by being more specific
123 + //! stops beating it. The direction is always "the app wins", which is what the
124 + //! apps already assume, but a hand-written rule an app thought was dead can come
125 + //! back to life.
126 + //!
127 + //! An app should declare the order once, or the layer's position is decided by
128 + //! whichever generated file the browser happens to see first:
129 + //!
130 + //! ```css
131 + //! @layer makeover, base, components, responsive;
132 + //! ```
133 + //!
134 + //! [`in_css_layer`] is re-exported for an app that assembles its own stylesheet
135 + //! from this crate's pieces. goingson builds `tables.css` in its own `build.rs`
136 + //! out of [`list::narrowing_css`] and [`list::grid_template_columns`], and those
137 + //! rules are as generated as the ones here, so they belong in the same layer and
138 + //! this crate cannot put them there on the app's behalf.
139 + //!
102 140 //! # Substitution, three ways
103 141 //!
104 142 //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
@@ -118,6 +156,12 @@
118 156 pub mod list;
119 157
120 158 use makeover_geometry::{Density, SizeClass};
159 + // Re-exported rather than redefined. An app assembling its own stylesheet out
160 + // of this crate's pieces needs the same layer name, and most such apps depend
161 + // on this crate and not on `makeover-geometry` directly: goingson builds
162 + // `tables.css` in its own build.rs from [`list::narrowing_css`], and those
163 + // rules are as generated as the ones here.
164 + pub use makeover_geometry::{CSS_LAYER, in_css_layer};
121 165 use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, State, Token, Tone};
122 166 use makeover_touch::Affordance;
123 167 use std::fmt::Write as _;
@@ -664,18 +708,28 @@
664 708 css
665 709 }
666 710
667 - /// The whole phase-A stylesheet: properties, depth rules and components, with
668 - /// a generated-file banner.
711 + /// The whole phase-A stylesheet: properties, depth rules and components, in
712 + /// [`CSS_LAYER`], under a generated-file banner.
713 + ///
714 + /// The banner sits outside the layer, because a comment participates in no
715 + /// cascade and a reader opening the file should see what it is before seeing
716 + /// an at-rule.
669 717 #[must_use]
670 718 pub fn stylesheet(opts: &Emit) -> String {
671 719 format!(
672 720 "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \
673 721 Depth is a fill and an edge together; naming them apart is what let\n \
674 - them disagree. See the crate's README and wiki note makeover-layout. */\n\
675 - :root {{\n{}}}\n\n{}\n{}",
676 - bevel_properties(opts),
677 - depth_rules(opts),
678 - component_rules(opts)
722 + them disagree. See the crate's README and wiki note makeover-layout.\n\n \
723 + Everything below is in the `{CSS_LAYER}` cascade layer. Declare the\n \
724 + order once in your own stylesheet, or this layer's position is decided\n \
725 + by whichever generated file the browser happens to see first:\n\n \
726 + @layer {CSS_LAYER}, base, components, responsive; */\n{}",
727 + in_css_layer(&format!(
728 + ":root {{\n{}}}\n\n{}\n{}",
729 + bevel_properties(opts),
730 + depth_rules(opts),
731 + component_rules(opts)
732 + ))
679 733 )
680 734 }
681 735
@@ -786,6 +840,49 @@
786 840 );
787 841 }
788 842
843 + #[test]
844 + fn the_whole_stylesheet_is_emitted_in_the_family_layer() {
845 + // The point of 0.11.0. Unlayered normal declarations outrank every
846 + // named layer, so an app declaring `@layer base, components` loses
847 + // every rule it owns to this file until this file is layered too.
848 + let css = stylesheet(&Emit::default());
849 + assert!(css.contains(&format!("@layer {CSS_LAYER} {{")));
850 +
851 + // Exactly one layer block, and nothing outside it but the banner.
852 + assert_eq!(css.matches("@layer").count(), 2, "banner names it once");
853 + let opened = css.find("@layer makeover {").expect("layer opens");
854 + for (i, line) in css.lines().enumerate() {
855 + let before_layer = css.lines().take(i).map(str::len).sum::<usize>() < opened;
856 + if before_layer || line.is_empty() {
857 + continue;
858 + }
859 + assert!(
860 + line.starts_with(" ") || line == "}" || line.starts_with(" "),
861 + "line outside the layer: {line:?}"
862 + );
863 + }
864 + }
865 +
866 + #[test]
867 + fn the_generated_sheet_carries_no_trailing_whitespace() {
868 + // A checked-in generated file that a formatter wants to rewrite is a
869 + // diff every time somebody saves it.
870 + let css = stylesheet(&Emit::default());
871 + for (i, line) in css.lines().enumerate() {
872 + assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}");
873 + }
874 + }
875 +
876 + #[test]
877 + fn the_banner_tells_an_app_how_to_order_the_layer() {
878 + // Without a declared order the layer's position depends on which
879 + // generated file the browser sees first, which is not a contract.
880 + let css = stylesheet(&Emit::default());
881 + assert!(css.contains("@layer makeover, base, components, responsive;"));
882 + // And the banner is outside the layer, not a rule inside it.
883 + assert!(css.starts_with("/* Generated by makeover-webview"));
884 + }
885 +
789 886 #[test]
790 887 fn a_primitive_owns_every_state_it_implies() {
791 888 // The whole point of 0.10.0. Anything emitting a hover rule owes the
@@ -880,12 +977,22 @@
880 977
881 978 // The row reveal splits: hover inside the query, focus-within outside,
882 979 // or a touchscreen with a keyboard loses its only way to the actions.
883 - let reveal = css.find(".row:hover .row-actions").expect("hover reveal");
884 - let keyboard = css
885 - .find(".row:focus-within .row-actions")
886 - .expect("keyboard reveal");
887 - let query_end = css[reveal..].find("\n}\n").expect("query closes") + reveal;
888 - assert!(reveal < query_end && query_end < keyboard);
980 + // Compared by indentation rather than by brace-hunting, because both
981 + // sit inside the cascade layer now and every brace is nested.
982 + let indent = |needle: &str| {
983 + let line = css
984 + .lines()
985 + .find(|l| l.contains(needle))
986 + .unwrap_or_else(|| panic!("no line for {needle}"));
987 + line.len() - line.trim_start().len()
988 + };
989 + let hover = indent(".row:hover .row-actions");
990 + let keyboard = indent(".row:focus-within .row-actions");
991 + assert!(
992 + hover > keyboard,
993 + "hover reveal must be nested inside the capability query and the \
994 + keyboard reveal must not be: hover indent {hover}, keyboard {keyboard}"
995 + );
889 996 }
890 997
891 998 #[test]
@@ -1161,9 +1268,15 @@
1161 1268 assert!(!css.contains("rgb"));
1162 1269 for line in css.lines() {
1163 1270 // Declarations only: a selector or an at-rule can carry a colon of
1164 - // its own (`:root`, `:hover`) and declares nothing.
1165 - let declaration = line.strip_prefix(" ").map(str::trim);
1166 - let Some(Some((_, value))) = declaration.map(|d| d.split_once(": ")) else {
1271 + // its own (`:root`, `:hover`, `@media (hover: hover)`) and declares
1272 + // nothing. Keyed on the trailing semicolon rather than on leading
1273 + // indentation, which only ever worked as a proxy for nesting depth
1274 + // and stopped when the sheet gained a cascade layer around it.
1275 + let trimmed = line.trim();
1276 + if !trimmed.ends_with(';') {
1277 + continue;
1278 + }
1279 + let Some((_, value)) = trimmed.split_once(": ") else {
1167 1280 continue;
1168 1281 };
1169 1282 if value.contains("var(--") {