| 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 |
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 |
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 |
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 |
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 |
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(--") {
|