//! The webview renderer for [`makeover_layout`]. //! //! //! //! # The renderer that needs no palette //! //! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui //! and a terminal need an actual colour before they can put anything on //! screen. A webview does not: `var(--surface-raised)` *is* the late binding, //! and the browser resolves it against whatever `themes.js` last wrote onto //! `:root`. //! //! So this crate emits text naming intents, and never learns a colour. It is //! the deferral rule with no adapter in the way, and it is why the webview was //! always the wrong renderer to derive a vocabulary from: it can express //! anything, so it never pushes back. //! //! # Phase A: the stylesheet //! //! This module emits component CSS and no markup, deliberately. GoingsOn has //! 145 `innerHTML` sites and Balanced Breakfast 175 `createElement` sites, so //! moving markup is a migration where adopting a generated stylesheet is not. //! The apps keep every line of their markup and gain the classes. //! //! It is not a deletion either, which this header claimed until the measurement //! came in. Adoption across goingson removed 49 declarations net and *added* 25 //! lines: a rule loses its depth declarations and gains a variant selector next //! to it, so the file stays the same size. What phase A moves is where depth is //! defined, not how much CSS exists. Numbers and method in the wiki note under //! "The deletion test, run". //! //! The bevel properties are byte-identical to what both apps already //! hand-write, which is asserted below. //! //! # Phase B: the markup, one description at a time //! //! [`form`] renders [`makeover_layout::Field`], which is the half of phase B //! whose description is settled. It emits strings, because both apps //! interpolate their fields into larger string-built forms and returning nodes //! would rewrite those too. It owns its own escaping, on the reasoning in that //! module: a Rust encoder can cover element text and attribute values with one //! function, where the apps need four and have to choose correctly at every //! call site. //! //! [`list`] is the other half: column tracks, the narrowing rules, and the cell //! containers a row is made of. It stops at the cell boundary and does not //! render what goes inside one, on the reasoning in that module. So phase B is //! now the frame around content in both directions, and what an app still owns //! is the content itself. //! //! # What phase A settled, and what it costs //! //! Decided 2026-07-29 against goingson's `styles.css` rather than against a //! component list. The useful finding there was that `.btn` (line 644), //! `.card` (768) and `.tag, .badge` (882) each hand-write the same //! composition, so three quarters of phase A is one rule with several names. //! //! Two of the four decisions change how goingson looks, and adoption should //! not be described as a pure deletion: //! //! - **Pressed carries its fill.** [`interactive_rules`] emits //! [`Depth::pressed`] whole. goingson presses to `--surface-sunken` today and //! will press to `--surface-well`, and hovers to `--surface-overlay` today //! and will hover to `--hover-surface`. Since `surface-well` inverts by theme //! where `surface-sunken` does not, a dark theme presses *lighter* than it //! hovers. That falls out of `makeover`'s own derivation, which says outright //! that `surface-sunken` cannot serve as a well, so if it reads wrong the //! answer is there and not here. //! - **Badges go flat.** See [`token_rules`]. //! //! The other two: the progress trough is renderer-local and the scrollbar //! track was dropped ([`component_rules`]), and no class prefix ships by //! default, so adoption means deleting the app's hand-written rule in the same //! commit that adds the generated one. `.card`, `.badge` and the tab classes //! all already exist in goingson, and while both rules exist the cascade order //! decides which wins. That is the one real risk in adopting this, and it is //! why the migration lands per component rather than in one commit. //! //! # 0.10.0: the states this crate used to leave to its consumers //! //! [`interactive_rules`] emitted hover and pressed and stopped, because //! `makeover-layout` modelled no interaction state. Focus and disabled were //! therefore unsayable, and every app completed the primitive from outside the //! only way that works: by out-specifying a rule it does not own. goingson //! carries 19 such rules and the MNW server 21, and the three focus rings do //! not match each other. //! //! That also blocked the cascade-layer work outright. An app that declares //! `@layer` puts its own rules in a named layer, and unlayered declarations //! outrank every named layer regardless of specificity, so all of those //! overrides lose in the commit that adopts layers. They cannot simply be //! deleted, because they are the only thing supplying the missing states. //! Emitting the states here is what turns that adoption into a deletion. //! //! Four states now, in emission order, and the order is load-bearing: they are //! all specificity (0,2,0), so disabled beats hover by coming last and by //! nothing else. Nothing here reaches for `:not(:disabled)`, which would raise //! a selector this crate will shortly be wrapping in its own layer. //! //! Hover additionally sits inside a capability query now. `makeover-touch` //! answers whether a fingertip has hover and `makeover-geometry` spells the //! condition; this crate asks and does not decide. goingson's section 60 exists //! solely to take the hover state back on touch, which is a fight it should //! never have been handed. //! //! # 0.11.0: the layer contract //! //! [`stylesheet`] emits into the `makeover` cascade layer ([`CSS_LAYER`], which //! lives in `makeover-geometry` because that is the one crate every CSS emitter //! in the family already depends on). `makeover-geometry` 0.6.0 does the same //! for `geometry.css`. //! //! The cascade resolves origin and importance, then layer, then specificity, //! then source order, and **unlayered normal declarations outrank every named //! layer**. So before this, 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 when that happens: the CSS is valid, the minifier is //! happy, and buttons and badges look subtly wrong. //! //! That is why the layer belongs here rather than in each app. An app cannot fix //! it from its own stylesheet, because the fix is to layer the file it does not //! own. //! //! **What it flips**, and the reason 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. //! //! An app should declare the order once, or the layer's position is decided by //! whichever generated file the browser happens to see first: //! //! ```css //! @layer makeover, base, components, responsive; //! ``` //! //! [`in_css_layer`] is re-exported for an app that assembles its own stylesheet //! from this crate's pieces. goingson builds `tables.css` in its own `build.rs` //! out of [`list::narrowing_css`] and [`list::grid_template_columns`], and those //! rules are as generated as the ones here, so they belong in the same layer and //! this crate cannot put them there on the app's behalf. //! //! # 0.12.0: the ring gets its own width //! //! [`focus_rule`] reused [`Emit::border_width`] and emitted a 1px ring. That was //! an implementation convenience dressed as consistency with the invalid-field //! ring: a bevel and a focus indicator answer different questions, and only one //! of them has to be noticed from across a desk. //! //! Caught while adopting 0.11.0 into goingson, by the check the adoption tasks //! ask for. Every consumer had already written its own ring and all three chose //! at least 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px, //! goingson 2px on three rules and 3px on the one covering twelve selectors. The //! design system was the only thing in the tree saying 1px, so deleting the app //! rules in favour of it would have thinned the focus indicator everywhere. //! //! [`Emit::focus_width`] now carries it, defaulting to `2px`, and the offset is //! the same magnitude with its sign off the depth. Both values are the measured //! consensus rather than a new opinion. //! //! # 0.17.0: the depth classes stop being controls //! //! [`depth_rules`] gave `.raised` the whole interactive set. A depth is a //! statement about shape, so that left the vocabulary with no raised surface //! that is merely an object, and an app wanting one had two moves: write its //! own class from tokens, or take a control class and cancel the control half. //! goingson took the second, in three variants over sixteen elements //! (`.card--static` at 14 call sites, `.card--muted` at 2, `.card--shell` at 1), //! each re-asserting the resting fill and bevel on `:hover` and `:active`. //! //! Measured before changing it: `.raised` is emitted into goingson, Balanced //! Breakfast and the MNW server, and none of the three has a single call site. //! The states were unasked-for everywhere at once, and dropping them costs no //! migration anywhere. //! //! `.card` and `.button` are unchanged. They are the same depth *and* controls, //! and they take their states from [`surface_rules`], which is where a state //! belongs: on the thing that claims to answer a pointer. //! //! # Substitution, three ways //! //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer //! answers that differently, which is the evidence that dropping //! `Fill::fallback` from the description was right: //! //! - `makeover-immediate` substitutes the page in Rust. //! - `makeover-tui` refuses to substitute and draws an edge instead, because a //! terminal would quantise the two together. //! - here, CSS already has the mechanism: `var(--surface-well, //! var(--surface-page))` falls back in the browser, and nothing in Rust //! decides anything. #![forbid(unsafe_code)] pub mod figure; pub mod form; pub mod list; pub mod meter; pub mod placeholder; use crate::list::part_class; use makeover_geometry::{Density, SizeClass}; // Re-exported rather than redefined. An app assembling its own stylesheet out // of this crate's pieces needs the same layer name, and most such apps depend // on this crate and not on `makeover-geometry` directly: goingson builds // `tables.css` in its own build.rs from [`list::narrowing_css`], and those // rules are as generated as the ones here. pub use makeover_geometry::{CSS_LAYER, in_css_layer}; use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, State, Token, Tone}; use makeover_touch::Affordance; use std::fmt::Write as _; /// How the emitted CSS is shaped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Emit { /// Bevel thickness, as a CSS length. /// /// A value, so it arrives from the caller: border widths belong to /// `makeover-geometry` and will come from there once it carries them. pub border_width: &'static str, /// Focus ring thickness, as a CSS length. /// /// Separate from [`border_width`](Self::border_width), which it reused /// until 0.12.0. That reuse was an implementation convenience dressed as /// consistency, and it emitted a 1px ring: a bevel and a focus indicator /// are answering different questions, and only one of them has to be /// noticed from across a desk. /// /// The default is the measured consensus rather than a new opinion. Every /// consumer had already written its own ring and all three chose at least /// 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px, /// goingson 2px on three rules and 3px on the one covering twelve /// selectors. The design system was the only thing in the tree saying 1px. pub focus_width: &'static str, /// Prefix for emitted class names, without the leading dot. pub class_prefix: &'static str, } impl Default for Emit { fn default() -> Self { Self { border_width: "1px", focus_width: "2px", class_prefix: "", } } } /// The CSS custom property holding a bevel's composition. #[must_use] pub fn bevel_var(bevel: Bevel) -> &'static str { match bevel { Bevel::Raised => "--bevel-raised", Bevel::Inset => "--bevel-inset", } } /// A `var()` reference to a fill intent, with the browser's own fallback where /// the intent may be absent. /// /// The fallback is CSS syntax, not a decision made here. That is the whole /// difference between this renderer and the other two. #[must_use] pub fn fill_var(fill: Fill) -> String { match fill { Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()), other => format!("var(--{})", other.token()), } } /// The two-tone edge as a `box-shadow` value. /// /// Two inset shadows, one per corner pair: the light one offset down and /// right so it lands on the top and left edges, the dark one the other way. /// The same assignment `makeover-immediate` draws with polylines and /// `makeover-tui` draws with box-drawing characters. #[must_use] pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String { let (top_left, bottom_right) = bevel.edges(); let w = opts.border_width; format!( "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})", top_left.token(), bottom_right.token() ) } /// The custom properties both bevels resolve through. /// /// Emitted as properties rather than inlined into every rule because that is /// what the apps already do, and because a consumer that wants the edge /// without the fill reads the property directly. #[must_use] pub fn bevel_properties(opts: &Emit) -> String { let mut css = String::new(); for bevel in [Bevel::Raised, Bevel::Inset] { let _ = writeln!( css, " {}: {};", bevel_var(bevel), bevel_shadow(bevel, opts) ); } css.push_str(ELEVATION_PROPERTY); css } /// The cast shadow of a surface that floats over the page. /// /// Composed here for the reason the bevel pair is: `makeover` derives the tone, /// this crate owns the geometry, and neither has to know the other's numbers. /// /// **Only for a surface that overlays the page.** A menu, a toast, a popover, a /// dropdown. A surface *in* the page takes `.raised` and its bevel, and a rule /// that reaches for this on a card or a plate has renamed a literal rather than /// replaced it. /// /// Two lengths rather than one, because a single blur reads as a smudge at /// plate size and as a halo at menu size. The offset is small and downward: a /// Platinum-era menu sits just off the page rather than hovering above it. const ELEVATION_PROPERTY: &str = " --elevation-overlay: 0 2px 4px var(--elevation), 0 8px 24px var(--elevation);\n"; /// The class name for a depth. #[must_use] pub fn depth_class(depth: Depth, opts: &Emit) -> Option { let name = match depth { Depth::Flat => return None, Depth::Raised => "raised", Depth::Well => "well", Depth::Sunken => "sunken", // A depth added to the description since this renderer was last // built. No class, on the same footing as Flat: emitting a name // whose rule body we cannot write would put a class in the markup // that the stylesheet never defines. _ => return None, }; Some(format!("{}{name}", opts.class_prefix)) } /// A prefixed class name. fn class(name: &str, opts: &Emit) -> String { format!("{}{name}", opts.class_prefix) } /// The fill and edge declarations for a depth, as a rule body. /// /// Empty for [`Depth::Flat`], which has neither and inherits what it sits on. /// Callers lean on the emptiness to skip the rule rather than emit a class that /// sets nothing: a class that sets no properties is a class that means "I /// thought about this", which is what comments are for. /// /// The two halves are emitted independently because [`Depth::Sunken`] has a /// fill and no bevel. Requiring both, which this did before makeover-layout /// 0.3.0, silently dropped the fill for exactly that case. Independent does not /// mean unpaired: both halves still come off one `Depth`, so they cannot /// disagree about what the region is. #[must_use] pub fn depth_declarations(depth: Depth) -> String { let mut css = String::new(); if let Some(fill) = depth.fill() { let _ = writeln!(css, " background: {};", fill_var(fill)); } if let Some(bevel) = depth.bevel() { let _ = writeln!(css, " box-shadow: var({});", bevel_var(bevel)); } css } /// One rule giving a selector a depth, or nothing when the depth declares /// nothing. #[must_use] pub fn depth_rule(selector: &str, depth: Depth) -> String { let body = depth_declarations(depth); if body.is_empty() { return String::new(); } format!(".{selector} {{\n{body}}}\n") } /// The media condition a hover rule has to sit inside, or `None` if hover is /// unconditional. /// /// Two crates answer this and neither answer is made here. `makeover-touch` /// owns *whether* hover exists at a density, and `makeover-geometry` owns how /// that capability is spelled as a media condition. Asking both is what stops /// this renderer minting a third opinion, which is what all three apps did: /// goingson sniffed the user agent, Balanced Breakfast used `(hover: none)` /// alone, and the MNW server had no gate at all. /// /// [`SizeClass`] is required by [`Affordance::available`] and ignored by this /// member, which reports as much through `reads_size`. Passing Compact is not /// a claim about width; the test below pins that every class agrees. fn hover_condition() -> Option<&'static str> { if Affordance::Hover.available(Density::Touch, SizeClass::Compact) { // A fingertip grew a hover state. Nothing to gate, and this renderer // should not invent a reason to gate anyway. None } else { Some(Density::Pointer.media_condition()) } } /// Put a rule inside a media query, or leave it alone. fn gated(condition: Option<&str>, rule: &str) -> String { let Some(condition) = condition else { return rule.to_string(); }; let mut css = format!("@media {condition} {{\n"); for line in rule.lines() { // Blank lines stay blank. Indenting one leaves trailing whitespace, // which is the sort of thing a formatter later reverts and calls a diff. if line.is_empty() { css.push('\n'); } else { let _ = writeln!(css, " {line}"); } } css.push_str("}\n"); css } /// The keyboard focus ring, placed by the depth it lands on. /// /// One ring for the whole system, because a focus ring's job is to be /// recognised and three apps having three of them is the failure. What varies /// is where it sits, and that comes off [`Depth`] rather than off a per- /// component choice: a well takes the ring inside its own edge, and anything /// standing proud of the page takes it outside. /// /// `outline` rather than the composed `box-shadow` the invalid-field ring at /// [`field_rules`] uses, and deliberately the one place the two rings are built /// differently. A `box-shadow` ring has to restate the bevel beside it, because /// `box-shadow` is not additive and a lone ring silently drops the well out /// from under the element. That restatement is a second copy of the depth, /// living in a different function from the first, and it is exactly the /// duplication `Depth` exists to prevent. `outline` occupies its own property, /// so the bevel survives untouched and there is nothing to keep in agreement. /// They render the same: both are a flush ring one border-width wide. #[must_use] pub fn focus_rule(selector: &str, depth: Depth, opts: &Emit) -> String { let w = opts.focus_width; // Same magnitude either way, and only the sign comes off the depth. Both // values are what the consumers had already converged on independently: // 2px out is what all three wrote, and 2px in is the MNW server's own // answer for the one inset ring it had. let offset = match depth.bevel() { // Inside the well, clear of its edge rather than painted over it. Some(Bevel::Inset) => format!("calc(-1 * {w})"), // Raised, or no edge at all. Outside, standing off by its own width. _ => w.to_string(), }; format!( ".{selector}:focus-visible {{\n outline: {w} solid var(--{});\n outline-offset: {offset};\n}}\n", State::Focus.token() ) } /// Present, visible, and not answering. /// /// Matches the ARIA attribute as well as the pseudo-class, because `:disabled` /// only matches form elements and half the things this crate emits are not /// one: a `div` carrying `.chip` or `.tab` can never be `:disabled`. Keying on /// the accessible state is the pattern [`field_rules`] already establishes for /// `aria-invalid`, on the reasoning that one fact read by both the styling and /// the accessibility tree cannot drift from itself. /// /// The rest depth is re-asserted rather than assumed, because this rule has to /// beat the hover and pressed rules above it. It does that on source order at /// equal specificity, not by out-specifying them: every rule this function's /// caller emits is (0,2,0), and adding a `:not(:disabled)` anywhere would raise /// one of them and have to be unpicked when this output moves inside its own /// cascade layer. #[must_use] pub fn disabled_rule(selector: &str, depth: Depth) -> String { format!( ".{selector}:disabled,\n.{selector}[aria-disabled=\"true\"] {{\n{} color: var(--{});\n cursor: not-allowed;\n}}\n", depth_declarations(depth), State::Disabled.token() ) } /// Every state a selector that answers a click implies: hover, pressed, focus /// and disabled, in that order. /// /// Order is the whole cascade mechanism here. All four selectors are /// specificity (0,2,0), so disabled wins over hover and pressed by coming last /// and by nothing else. /// /// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting /// only the edge is what left goingson hand-writing `background: /// var(--surface-sunken)` on three separate rules, and a fill that does not /// travel with its edge is precisely the disagreement `Depth` exists to make /// unrepresentable. So the pressed fill comes from the description /// (`--surface-well`) rather than from whatever each app reached for. /// /// Hover has no member in the description and is renderer policy: a terminal /// and an immediate-mode painter have no hover to express. It resolves against /// `--hover-surface`, which `makeover` already derives and which nothing /// consumed until now. What it *is* gated on is capability, via /// [`hover_condition`]. Before that gate existed the apps each wrote their own: /// goingson's section 60 exists solely to take back the hover state this /// function had just handed it, by out-specifying a rule it does not own. /// /// `depth` is the selector's **rest** depth, used to place the focus ring and /// to restore the surface under a disabled control. The pressed rule keeps /// inverting from [`Depth::Raised`] regardless, which is what every caller got /// before this parameter existed: a tab's unchosen depth is /// [`Depth::Sunken`], and `Sunken.pressed()` is `Sunken`, so deriving the press /// from the rest depth would leave a tab with no press at all. #[must_use] pub fn interactive_rules(selector: &str, depth: Depth, opts: &Emit) -> String { let mut css = gated( hover_condition(), &format!(".{selector}:hover {{\n background: var(--hover-surface);\n}}\n"), ); css.push_str(&depth_rule( &format!("{selector}:active"), Depth::Raised.pressed(), )); css.push_str(&focus_rule(selector, depth, opts)); css.push_str(&disabled_rule(selector, depth)); css } /// One rule per depth: its fill and its edge, together. /// /// A depth and nothing else. `.raised` says a surface sits on what is behind /// it, which is a statement about the shape and not about what happens when a /// pointer arrives, so it emits no hover, press, focus or disabled rule. The /// named surfaces are where interaction lives: `.card` and `.button` are the /// same depth *and* controls, and they get their states from /// [`surface_rules`]. /// /// This class carried the interactive set until 0.17.0, which left the /// vocabulary with no raised surface that is merely an object. Consumers that /// needed one took a control class and cancelled half of it instead: sixteen /// elements in goingson across three `.card--*` variants, each re-asserting the /// resting fill and bevel on `:hover` and `:active`. Nothing anywhere used /// `.raised` itself, so the states were unasked-for in every consumer at once. #[must_use] pub fn depth_rules(opts: &Emit) -> String { let mut css = String::new(); for depth in [Depth::Raised, Depth::Well] { let Some(class) = depth_class(depth, opts) else { continue; }; css.push_str(&depth_rule(&class, depth)); } css } /// The three surfaces that are a depth with a name. /// /// `button` and `card` are both [`Depth::Raised`], and `field` is a /// [`Depth::Well`] because that is the reading `Depth`'s own documentation /// gives a text field. Their bodies come out identical by construction rather /// than by hand: three hand-written copies in goingson's stylesheet is what /// phase A deletes, and generating them from one call is what stops them /// drifting apart again. fn surface_rules(opts: &Emit) -> String { let mut css = String::new(); for name in ["button", "card"] { let c = class(name, opts); css.push_str(&depth_rule(&c, Depth::Raised)); css.push_str(&interactive_rules(&c, Depth::Raised, opts)); } let field = class("field", opts); css.push_str(&depth_rule(&field, Depth::Well)); // A field takes focus and refuses input like everything else here, and got // neither until now, which is why all three apps hand-write a focus ring // for it and no two of them match. No hover or pressed: a text field does // not light up under the pointer and does not invert when clicked, so the // two states `interactive_rules` would add are the two it does not have. css.push_str(&focus_rule(&field, Depth::Well, opts)); css.push_str(&disabled_rule(&field, Depth::Well)); // Keyed on the ARIA attribute rather than on a class, so the visual state // and the accessible state cannot drift apart: there is one fact and both // read it. goingson already drove its invalid styling this way and was // right to; the `.invalid` class this emitted before 0.5.0 was a second // place to forget. // // The ring composes *after* the bevel rather than replacing it. box-shadow // is not additive, so a lone ring silently dropped the well out from under // an invalid field. Flat and unlit: this edge is saying "wrong", and // lighting one side would have it say "raised" at the same time. let _ = writeln!( css, ".{field}[aria-invalid=\"true\"] {{\n box-shadow: var({}), 0 0 0 {} var(--danger);\n}}", bevel_var(Bevel::Inset), opts.border_width ); css } /// Badges and chips. /// /// The one place phase A changes how goingson looks rather than only where its /// rules live. [`Token::Badge`] is [`Depth::Flat`], so a badge emits no fill /// and no edge at all, where goingson ships `.tag, .badge` as a single rule /// carrying the raised bevel. Splitting that means reading every call site to /// decide which of the two it always was. /// /// What a badge does carry is a [`Tone`], the intent family it shares with /// notices and nothing else. Neutral is the bare class rather than a variant, /// because it is the absence of a status and not a status called "none". fn token_rules(opts: &Emit) -> String { let mut css = String::new(); // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat, // and a label with an edge says it can be pressed. let badge = class("badge", opts); let _ = writeln!( css, ".{badge} {{\n color: var(--{});\n}}", Tone::Neutral.token() ); for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { let _ = writeln!( css, ".{badge}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}", tone.token() ); } // A chip holds itself down, which is `Depth::pressed` arrived at // independently by two apps. `removable` is a remove affordance, so it is // markup and waits for phase B. let chip = class("chip", opts); let unlatched = Token::Chip { removable: false }; css.push_str(&depth_rule(&chip, unlatched.depth(false))); css.push_str(&interactive_rules(&chip, unlatched.depth(false), opts)); css.push_str(&depth_rule( &format!("{chip}.latched"), unlatched.depth(true), )); css } /// The three selectors, each named by what it picks. /// /// A tab comes *forward* to join the pane it opens, which is why /// [`Selector::Tabs`] chooses [`Depth::Raised`] where a segment and a toggle /// are held in. That is the folder semantic, and it is the whole reason the /// three are not one member with a flag. /// /// [`Selector::abutting`] is not emitted: whether the options touch is /// spacing, and spacing is `makeover-geometry`'s question to answer. /// /// Both states emit as of makeover-layout 0.3.0. Before it the description /// named only the chosen option, so an unchosen one fell through to /// [`Depth::Flat`] and nothing was drawn for it, which left goingson's tab /// strip hand-writing the recess that makes its chosen tab read as forward. fn selector_rules(opts: &Emit) -> String { let mut css = String::new(); for (selector, name) in [ (Selector::Tabs, "tab"), (Selector::Segmented, "segment"), (Selector::Toggle, "toggle"), ] { let c = class(name, opts); css.push_str(&depth_rule(&c, selector.unchosen())); css.push_str(&interactive_rules(&c, selector.unchosen(), opts)); css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen())); } css } /// The parts of a list row. /// /// The list is written out rather than derived because `RowPart` is /// `#[non_exhaustive]` as of makeover-layout 0.9.0, so there is nothing to /// iterate. A member added upstream emits no rule until it is named here, which /// is the trade `non_exhaustive` makes: a silent gap instead of a build break. /// [`part_class`] carries the same list and the same obligation. fn row_rules(opts: &Emit) -> String { let mut css = String::new(); let row = class("row", opts); for part in [ RowPart::Primary, RowPart::Secondary, RowPart::Meta, RowPart::Actions, RowPart::Tokens, RowPart::Proportion, ] { let c = class(part_class(part), opts); // Actions carry controls rather than text, and `RowPart::intent` says // so by returning the same intent inheriting already gives. Pinning it // would be louder than saying nothing. Tokens answer alike, for their // own reason: each token carries its own tone, and a colour on the // strip would fight the things sitting in it. A proportion is the same // case again: the meter inside carries the tone. if !matches!( part, RowPart::Actions | RowPart::Tokens | RowPart::Proportion ) { let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent()); } if part.revealed_on_hover() { // Hidden rather than absent: the row must not change height when // the pointer arrives. `focus-within` carries the keyboard, which // hover on its own would lock out. // // Transparent rather than `visibility: hidden`, which was the first // form and defeated the very escape above: a `visibility: hidden` // element is out of the focus order and out of the accessibility // tree, so tabbing could never reach an action and could never // trigger the row's `focus-within`. goingson had reached the same // opacity form independently, on its own comment "always in the DOM // for keyboard and screen readers". // // `pointer-events` rides along because opacity leaves the hit area // behind: without it a renderer with no hover carries an invisible // tappable control. Keyboard focus is unaffected by it. // The hide is gated too, which it was not in 0.10.0, and that was // an incomplete capability answer rather than a deliberate one. // Ungated, a fingertip got actions hidden with no hover to bring // them back, so both webview apps hand-wrote the same // `opacity: 1` to undo it: goingson in its touch block and // Balanced Breakfast in its own. A primitive that owns the hiding // owes the answer for the device that cannot unhide, and the // answer both consumers already reached is not to hide at all. css.push_str(&gated( hover_condition(), &format!(".{c} {{\n opacity: 0;\n pointer-events: none;\n}}\n"), )); // The two halves split here, where they used to be one selector // list. Hover-to-reveal is the literal case `Affordance::Hover` // was written from, and on a touchscreen it does not fail // gracefully: the actions are simply unreachable, because there // is no pointer to bring them back. So the hover half is gated // and the app owes those rows another way in. // // `focus-within` stays outside the query. A touchscreen device // with a keyboard attached is a real thing, and it is the one // path to these actions that survives the gate. let revealed = " opacity: 1;\n pointer-events: auto;\n"; css.push_str(&gated( hover_condition(), &format!(".{row}:hover .{c} {{\n{revealed}}}\n"), )); let _ = write!(css, ".{row}:focus-within .{c} {{\n{revealed}}}\n"); } } css } /// The progress trough these rules fill. /// /// This was renderer-local chrome with nothing behind it until makeover-layout /// 0.10.0, which is the unusual order: the tones below were emitted for every /// bar in the tree while the only way to describe one was to concatenate the /// numbers into a heading. `Meter` is the word that arrived late, and /// [`meter::meter_html`](crate::meter::meter_html) is what now fills these. /// /// The rules stay a superset of what a description can ask for. An app drawing /// its own bar keeps these classes, which is what the four goingson grew /// independently were adopted onto. /// /// The trough is a [`Depth::Well`], the same reading a text field gets: /// something with its content down inside it. fn progress_rules(opts: &Emit) -> String { let progress = class("progress", opts); // `progress-fill` rather than a bare `fill`: an unprefixed build claims // these names in the app's own stylesheet, and `.fill` is grabby enough to // catch things that have nothing to do with progress. goingson already // calls it `.progress-fill`, so this is also the name that deletes. let fill = class("progress-fill", opts); let mut css = depth_rule(&progress, Depth::Well); // The untoned bar is `--action`, not [`Tone::Neutral`]. That is the one // place this differs from the badge rules, and deliberately: a badge with // no status is a muted label, while a bar with no status is still // reporting progress, and `content-muted` would read as disabled. let _ = writeln!( css, ".{progress} > .{fill} {{\n background: var(--action);\n}}" ); // A bar can be saying something, same as a badge: goingson colours subtask // progress as success and an over-estimate as danger, which is real // information rather than decoration. Emitting the tones is what lets that // survive adoption instead of staying hand-written. for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { let _ = writeln!( css, ".{progress} > .{fill}[data-tone=\"{0}\"] {{\n background: var(--{0});\n}}", tone.token() ); } css } /// A strip of figures, and the two spans inside each one. /// /// Colour only, which is the deferral rule applied to a component that badly /// wants to break it. A figure reads as a figure because the value is set large /// over a small caption, and that is a size: `makeover-geometry` answers how /// much space and this crate answers what the thing is. Emitting `font-size` /// here would be this crate naming a value, which is the one thing it is defined /// by not doing, and `progress_rules` is the precedent — it emits the tones and /// never the width, because the width is not its to know. /// /// So the arrangement and the type scale are the app's, and what is generated is /// the part an app cannot get right by itself: which of the two spans carries /// the tone. fn figure_rules(opts: &Emit) -> String { let figure = class("figure", opts); let value = class("figure-value", opts); let caption = class("figure-caption", opts); let mut css = String::new(); let _ = writeln!( css, ".{figure} > .{value} {{\n color: var(--{});\n}}", Tone::Neutral.token() ); let _ = writeln!( css, ".{figure} > .{caption} {{\n color: var(--content-muted);\n}}" ); // A toned figure tones the value and never the caption. The caption is the // noun and stays muted; the number is the thing that is saying something. for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { let _ = writeln!( css, ".{figure}[data-tone=\"{0}\"] > .{value} {{\n color: var(--{0});\n}}", tone.token() ); } css } /// A region's stand-in, and the header of a table that can be reordered. /// /// Both are 0.12.0 members and both are colour and affordance only, which is /// where `figure_rules` landed after trying to emit a type scale. How much room /// a stand-in gets is a size — goingson has the same one at three, as /// `--compact`, `--dashboard` and `--padded` — and a size is /// `makeover-geometry`'s question. /// /// The caret is the one thing here that is neither colour nor affordance, and it /// is a renderer's own expression rather than a value the description named: /// `aria-sort` is what the table actually says, and this turns it into something /// visible for everyone not using a screen reader. A terminal draws its own; an /// immediate-mode painter draws its own. fn state_rules(opts: &Emit) -> String { let placeholder = class("placeholder", opts); let text = class("placeholder-text", opts); let heading = class("table-heading", opts); let mut css = String::new(); let _ = writeln!( css, ".{placeholder} > .{text} {{\n color: var(--content-muted);\n}}" ); // Only the failure is toned. An empty list is the normal state of a new // install, and `Readiness::tone` is what says so. let _ = writeln!( css, ".{placeholder}[data-tone=\"{0}\"] > .{text} {{\n color: var(--{0});\n}}", Tone::Danger.token() ); // A header that reorders the table is a control, and the pointer is the // only part of saying so that is not the app's own type and spacing. let _ = writeln!( css, ".{heading}[data-sortable] {{\n cursor: pointer;\n}}" ); for (direction, caret) in [("ascending", "\\2191"), ("descending", "\\2193")] { let _ = writeln!( css, ".{heading}[aria-sort=\"{direction}\"]::after {{\n content: \"{caret}\";\n}}" ); } css } /// The component layer: every named thing phase A emits. /// /// No scrollbar track. It was on the phase A list and came off: eight lines of /// `::-webkit-scrollbar` with no shape a terminal or an immediate-mode painter /// would want handed to it, so it stays with the apps. #[must_use] pub fn component_rules(opts: &Emit) -> String { let mut css = String::new(); css.push_str(&surface_rules(opts)); css.push_str(&token_rules(opts)); css.push_str(&selector_rules(opts)); css.push_str(&row_rules(opts)); css.push_str(&progress_rules(opts)); css.push_str(&figure_rules(opts)); css.push_str(&state_rules(opts)); css } /// The whole phase-A stylesheet: properties, depth rules and components, in /// [`CSS_LAYER`], under a generated-file banner. /// /// The banner sits outside the layer, because a comment participates in no /// cascade and a reader opening the file should see what it is before seeing /// an at-rule. #[must_use] pub fn stylesheet(opts: &Emit) -> String { format!( "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \ Depth is a fill and an edge together; naming them apart is what let\n \ them disagree. See the crate's README and wiki note makeover-layout.\n\n \ Everything below is in the `{CSS_LAYER}` cascade layer. Declare the\n \ order once in your own stylesheet, or this layer's position is decided\n \ by whichever generated file the browser happens to see first:\n\n \ @layer {CSS_LAYER}, base, components, responsive; */\n{}", in_css_layer(&format!( ":root {{\n{}}}\n\n{}\n{}", bevel_properties(opts), depth_rules(opts), component_rules(opts) )) ) } #[cfg(test)] mod tests { use super::*; use makeover_layout::Edge; #[test] fn the_emitted_bevel_matches_what_the_apps_already_hand_write() { // Balanced Breakfast's styles.css, verbatim. Adoption has to be a // deletion, not a redesign, or nobody will take it. let opts = Emit::default(); assert_eq!( bevel_shadow(Bevel::Raised, &opts), "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)" ); assert_eq!( bevel_shadow(Bevel::Inset, &opts), "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)" ); } #[test] fn no_colour_ever_reaches_the_output() { let css = stylesheet(&Emit::default()); assert!(!css.contains('#'), "a hex literal escaped into the CSS"); assert!( !css.contains("rgb"), "a colour function escaped into the CSS" ); // Every colour is named, never resolved. assert!(css.contains("var(--surface-raised)")); assert!(css.contains("var(--bevel-light)")); } #[test] fn a_well_falls_back_through_css_rather_than_through_rust() { assert_eq!( fill_var(Fill::Well), "var(--surface-well, var(--surface-page))" ); // Nothing else needs one. assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)"); assert_eq!(fill_var(Fill::Page), "var(--surface-page)"); } #[test] fn raised_and_well_do_not_collapse_onto_each_other() { let css = depth_rules(&Emit::default()); assert!(css.contains(".raised {")); assert!(css.contains(".well {")); assert!(css.contains("var(--bevel-raised)")); assert!(css.contains("var(--bevel-inset)")); } /// The cast shadow is composed here from the tone `makeover` derives, so /// neither crate has to hold the other's numbers. /// /// It is a `:root` property and deliberately not a depth class. There is no /// `Depth::Overlay` in the description layer, and adding one would be a /// claim about what a screen means rather than about how it is painted; /// until something asks for it, a consumer names the property on the rule /// for the menu or the toast it already has. #[test] fn the_cast_shadow_is_a_root_property_not_a_depth() { let css = bevel_properties(&Emit::default()); assert!(css.contains("--elevation-overlay:")); assert!(css.contains("var(--elevation)")); assert!( !depth_rules(&Emit::default()).contains("elevation"), "elevation is not a depth class" ); } #[test] fn the_cascade_carries_the_pressed_state() { let css = surface_rules(&Emit::default()); // The one thing this renderer gets free that the other two resolve by // hand, eighteen call sites deep in audiofiles' case. Asserted on a // named surface: pressing belongs to the control, not to the depth. assert!(css.contains(".card:active {")); assert!(css.contains(".button:active {")); } #[test] fn the_depth_class_is_a_surface_and_not_a_control() { let css = depth_rules(&Emit::default()); // The static surface the vocabulary was missing. Sixteen goingson // elements wore .card and cancelled its hover and press to get this, // because a raised object that is not pressable had no other spelling. for state in [":hover", ":active", ":focus-visible", ":disabled"] { assert!( !css.contains(&format!(".raised{state}")), "the depth class claimed {state}: {css}" ); } assert!(css.contains("var(--bevel-raised)"), "still raised: {css}"); } #[test] fn pressing_moves_the_fill_and_not_only_the_edge() { // The decision-1 guard, and the regression that mattered: emitting the // bevel flip alone is what left goingson hand-writing `background: // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none // of the three could be deleted. let pressed = interactive_rules("button", Depth::Raised, &Emit::default()); assert!(pressed.contains(".button:active {")); assert!( pressed.contains("background: var(--surface-well, var(--surface-page))"), "pressed dropped its fill: {pressed}" ); assert!(pressed.contains("box-shadow: var(--bevel-inset)")); } #[test] fn pressed_takes_its_fill_from_the_description_not_from_the_app() { // goingson presses to --surface-sunken. The description says a pressed // raised region reads as a well, and makeover says outright that // surface-sunken cannot serve as one, so the app is the thing that // moves. // // Scoped to the pressed rules rather than to the whole sheet: since // makeover-layout 0.3.0 an unchosen tab is legitimately // --surface-sunken, so the token appearing somewhere in the output no // longer means the app's choice leaked in. let css = stylesheet(&Emit::default()); let mut checked = 0; for rule in css.split("}\n") { if !rule.contains(":active") { continue; } checked += 1; assert!( !rule.contains("surface-sunken"), "a pressed rule took the app's fill: {rule}" ); } assert!(checked > 0, "no pressed rules found to check"); assert_eq!( Depth::Raised.pressed().fill(), Some(Fill::Well), "the description changed under us" ); } #[test] fn the_whole_stylesheet_is_emitted_in_the_family_layer() { // The point of 0.11.0. Unlayered normal declarations outrank every // named layer, so an app declaring `@layer base, components` loses // every rule it owns to this file until this file is layered too. let css = stylesheet(&Emit::default()); assert!(css.contains(&format!("@layer {CSS_LAYER} {{"))); // Exactly one layer block, and nothing outside it but the banner. assert_eq!(css.matches("@layer").count(), 2, "banner names it once"); let opened = css.find("@layer makeover {").expect("layer opens"); for (i, line) in css.lines().enumerate() { let before_layer = css.lines().take(i).map(str::len).sum::() < opened; if before_layer || line.is_empty() { continue; } assert!( line.starts_with(" ") || line == "}" || line.starts_with(" "), "line outside the layer: {line:?}" ); } } #[test] fn the_generated_sheet_carries_no_trailing_whitespace() { // A checked-in generated file that a formatter wants to rewrite is a // diff every time somebody saves it. let css = stylesheet(&Emit::default()); for (i, line) in css.lines().enumerate() { assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}"); } } #[test] fn the_banner_tells_an_app_how_to_order_the_layer() { // Without a declared order the layer's position depends on which // generated file the browser sees first, which is not a contract. let css = stylesheet(&Emit::default()); assert!(css.contains("@layer makeover, base, components, responsive;")); // And the banner is outside the layer, not a rule inside it. assert!(css.starts_with("/* Generated by makeover-webview")); } #[test] fn a_primitive_owns_every_state_it_implies() { // The whole point of 0.10.0. Anything emitting a hover rule owes the // other three, or the consuming app supplies them by out-specifying a // rule it does not own: 19 such rules in goingson, 21 in the MNW // server, and three focus rings that do not match. let css = stylesheet(&Emit::default()); for selector in ["button", "card", "chip", "tab", "segment", "toggle"] { assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}"); assert!( css.contains(&format!(".{selector}:active {{")), "{selector}" ); assert!( css.contains(&format!(".{selector}:focus-visible {{")), "{selector} has no focus ring" ); assert!( css.contains(&format!(".{selector}:disabled,")), "{selector} has no disabled state" ); } } #[test] fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() { // A text field does not light up under the pointer, so it gets the two // states it has and not the two it does not. let css = stylesheet(&Emit::default()); assert!(css.contains(".field:focus-visible {")); assert!(css.contains(".field:disabled,")); assert!(!css.contains(".field:hover {")); assert!(!css.contains(".field:active {")); } #[test] fn disabled_is_emitted_after_hover_so_source_order_settles_it() { // Every one of these selectors is specificity (0,2,0), so nothing but // order decides which wins. A disabled button taking the hover fill is // the exact bug goingson's `.button:disabled:hover` was written to fix, // and the reason it had to reach (0,3,0) to do it. let css = interactive_rules("button", Depth::Raised, &Emit::default()); let hover = css.find(":hover").expect("hover"); let active = css.find(":active").expect("active"); let focus = css.find(":focus-visible").expect("focus"); let disabled = css.find(":disabled").expect("disabled"); assert!(hover < active && active < focus && focus < disabled); // And it restores the surface, or the hover fill survives underneath. let tail = &css[disabled..]; assert!(tail.contains("background: var(--surface-raised)")); } #[test] fn a_disabled_state_reaches_things_that_cannot_be_disabled() { // `:disabled` matches form elements only, and a chip is a div. Keying // on the ARIA attribute too is the pattern the invalid field already // set: one fact, read by the styling and the accessibility tree alike. let css = disabled_rule("chip", Depth::Raised); assert!(css.contains(".chip:disabled,")); assert!(css.contains(".chip[aria-disabled=\"true\"]")); assert!(css.contains("cursor: not-allowed")); } #[test] fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() { // `outline` has its own property, so unlike the invalid ring there is // no bevel to restate beside it and nothing to keep in agreement. let opts = Emit::default(); let css = focus_rule("button", Depth::Raised, &opts); assert!(css.contains("outline: 2px solid var(--focus-ring)")); assert!(!css.contains("box-shadow"), "the ring restated the bevel"); } #[test] fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() { // One ring, placed by depth. The offset comes off `Depth::bevel` and // not off a per-component choice, which is what gave three apps three // different rings. let opts = Emit::default(); assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)")); assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px")); // Nothing to sit inside of, so it sits outside. assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px")); // And the ring is not the bevel. Reusing border_width emitted a 1px // ring that every consumer had already overridden. assert_ne!(opts.focus_width, opts.border_width); } #[test] fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() { // goingson's section 60 exists only to take back the hover state this // crate handed it. Gating at the source is what deletes that section // in all three apps rather than having each fight for it. let css = stylesheet(&Emit::default()); let condition = format!("@media {}", Density::Pointer.media_condition()); assert!(css.contains(&condition)); // The row reveal splits: hover inside the query, focus-within outside, // or a touchscreen with a keyboard loses its only way to the actions. // Compared by indentation rather than by brace-hunting, because both // sit inside the cascade layer now and every brace is nested. let indent = |needle: &str| { let line = css .lines() .find(|l| l.contains(needle)) .unwrap_or_else(|| panic!("no line for {needle}")); line.len() - line.trim_start().len() }; let hover = indent(".row:hover .row-actions"); let keyboard = indent(".row:focus-within .row-actions"); // The hide is gated with the reveal. Ungated it leaves a fingertip // with actions it cannot bring back, which is what both webview apps // were undoing by hand. let hide = css .lines() .position(|l| l.trim() == ".row-actions {") .expect("hide rule"); let query = css .lines() .take(hide) .enumerate() .filter(|(_, l)| l.trim_start().starts_with("@media")) .map(|(i, _)| i) .last() .expect("a query precedes it"); assert!(hide - query < 3, "the hide is not inside the query"); assert!( hover > keyboard, "hover reveal must be nested inside the capability query and the \ keyboard reveal must not be: hover indent {hover}, keyboard {keyboard}" ); } #[test] fn the_capability_answer_is_asked_for_and_not_assumed() { // Both halves come from the crates that own them. If `makeover-touch` // ever says a fingertip has hover, this stops gating on its own. assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact)); assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact)); assert_eq!(hover_condition(), Some(Density::Pointer.media_condition())); // And the size class passed to that call is not a claim about width. assert!(Affordance::Hover.reads_density()); for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] { assert!(!Affordance::Hover.available(Density::Touch, size)); } } #[test] fn hover_resolves_against_the_token_makeover_already_derives() { let css = interactive_rules("card", Depth::Raised, &Emit::default()); assert!(css.contains(".card:hover {")); assert!(css.contains("background: var(--hover-surface)")); // Not the app's choice, which was --surface-overlay. assert!(!css.contains("surface-overlay")); } #[test] fn a_badge_gets_no_edge_and_no_fill() { // Decision 2, and the one visible redesign in phase A. Token::Badge is // Flat: an edge on a label says it can be pressed. let css = token_rules(&Emit::default()); let badge = css .lines() .skip_while(|l| !l.starts_with(".badge {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}"); assert!(!badge.contains("background"), "badge kept a fill: {badge}"); assert_eq!(Token::Badge.depth(false), Depth::Flat); assert_eq!(Token::Badge.depth(true), Depth::Flat); } #[test] fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() { let css = token_rules(&Emit::default()); // Neutral is the absence of a status, not a status named "none". assert!(css.contains(".badge {\n color: var(--content-muted);")); assert!(!css.contains("data-tone=\"content-muted\"")); for tone in ["info", "success", "warning", "danger"] { assert!( css.contains(&format!(".badge[data-tone=\"{tone}\"]")), "missing tone {tone}" ); assert!(css.contains(&format!("color: var(--{tone})"))); } } #[test] fn a_chip_is_raised_and_latches_into_a_well() { let css = token_rules(&Emit::default()); assert!(css.contains(".chip {")); assert!(css.contains(".chip.latched {")); assert!(css.contains(".chip:active {")); // The whole difference from a badge: it answers a click. assert!(Token::Chip { removable: false }.interactive()); assert!(!Token::Badge.interactive()); } #[test] fn only_a_tab_comes_forward_when_chosen() { // The folder semantic. Collapsing the three selectors would lose it. let css = selector_rules(&Emit::default()); assert!(css.contains(".tab.chosen {")); assert!(css.contains(".segment.chosen {")); assert!(css.contains(".toggle.chosen {")); assert_eq!(Selector::Tabs.chosen(), Depth::Raised); assert_eq!(Selector::Segmented.chosen(), Depth::Well); assert_eq!(Selector::Toggle.chosen(), Depth::Well); let tab = css .lines() .skip_while(|l| !l.starts_with(".tab.chosen {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!( tab.contains("var(--bevel-raised)"), "tab was held in: {tab}" ); } #[test] fn an_unchosen_tab_recedes_without_looking_picked() { let css = selector_rules(&Emit::default()); // Recessed by colour and given no edge. An edge would make every option // look picked; flat would leave the chosen one nothing to come forward // from, which is the gap makeover-layout 0.3.0 closed. assert!( css.contains(".tab {\n background: var(--surface-sunken);\n}"), "unchosen tab is not recessed: {css}" ); assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken); assert!(css.contains(".tab:hover {")); } #[test] fn a_segment_stands_up_so_the_chosen_one_can_be_held_in() { // The inverse of the tab, and why the three selectors are not one // member with a flag. let css = selector_rules(&Emit::default()); assert!(css.contains(".segment {\n background: var(--surface-raised);")); assert_eq!(Selector::Segmented.unchosen(), Depth::Raised); assert_eq!(Selector::Segmented.chosen(), Depth::Well); } #[test] fn row_actions_are_revealed_without_moving_the_row() { let css = row_rules(&Emit::default()); assert!(css.contains(".row-actions {\n opacity: 0;")); // Not display:none, which would reflow the row under the pointer. assert!(!css.contains("display: none")); // Hover alone would lock the keyboard out. assert!(css.contains(".row:focus-within .row-actions")); assert!(RowPart::Actions.revealed_on_hover()); } #[test] fn a_hidden_row_action_is_still_focusable_and_not_tappable() { let css = row_rules(&Emit::default()); // `visibility: hidden` takes the actions out of the focus order, so the // `focus-within` reveal above could never fire from an action itself. assert!(!css.contains("visibility:")); // Opacity leaves the hit area behind, so the pair travels together or // the row carries an invisible tappable control. assert!( css.contains( ".row-actions {\n opacity: 0;\n pointer-events: none;\n }" ) ); assert!(css.contains("opacity: 1;\n pointer-events: auto;")); // And on a device with no hover the row carries no such control at // all, because the hide never applies there. That is what the comment // above used to be asking for and could not get: the hide is inside // the capability query with the reveal. let hide = css.find(".row-actions {").expect("hide rule"); let query = css[..hide].rfind("@media").expect("a query precedes it"); assert!( css[query..hide].find('}').is_none(), "the hide escaped the query" ); } #[test] fn the_three_text_parts_take_their_intents_and_actions_inherits() { let css = row_rules(&Emit::default()); assert!(css.contains(".row-primary {\n color: var(--content);")); assert!(css.contains(".row-secondary {\n color: var(--content-secondary);")); assert!(css.contains(".row-meta {\n color: var(--content-muted);")); // Actions carry controls, not text. Pinning the colour it would inherit // anyway is louder than saying nothing. assert!(!css.contains(".row-actions {\n color:")); } #[test] fn the_token_strip_takes_no_colour_of_its_own() { // makeover-layout 0.9.0. A token carries its own tone, so a colour on // the strip would be a rule fighting the things sitting in it -- the // same reasoning as actions, reached for a different reason. let css = row_rules(&Emit::default()); assert!(!css.contains(".row-tokens {\n color:")); } #[test] fn an_unknown_row_part_renders_plainly_rather_than_failing_to_build() { // What `#[non_exhaustive]` bought and what it cost. `part_class` can no // longer be exhaustive, so a member added upstream lands as a bare // class with no rule instead of stopping the build. Asserting the // fallback exists is what keeps it from being written as `unreachable!` // by someone who reads the match as closed. assert_eq!(part_class(RowPart::Tokens), "row-tokens"); assert_eq!(part_class(RowPart::Meta), "row-meta"); } #[test] fn the_progress_trough_is_a_well() { let css = progress_rules(&Emit::default()); assert!(css.contains(".progress {")); assert!(css.contains("box-shadow: var(--bevel-inset)")); assert!(css.contains(".progress > .progress-fill {")); assert!(css.contains("background: var(--action)")); // A bare `.fill` would catch things that have nothing to do with // progress once the sheet lands unprefixed. assert!(!css.contains("> .fill ")); } #[test] fn a_progress_bar_can_carry_a_tone_and_defaults_to_action() { let css = progress_rules(&Emit::default()); // Untoned is --action, not Tone::Neutral's content-muted: a bar with no // status is still reporting progress, and muted would read as disabled. assert!(css.contains(".progress > .progress-fill {\n background: var(--action);")); assert!(!css.contains("progress-fill {\n color: var(--content-muted)")); for tone in ["info", "success", "warning", "danger"] { assert!( css.contains(&format!(".progress > .progress-fill[data-tone=\"{tone}\"]")), "missing progress tone {tone}" ); } // goingson's two live cases, which is why the tones are emitted at all. assert!(css.contains("[data-tone=\"success\"] {\n background: var(--success);")); assert!(css.contains("[data-tone=\"danger\"] {\n background: var(--danger);")); } #[test] fn no_scrollbar_track_is_emitted() { // Decision 3's negative half. It was on the phase A list and came off; // this is what stops it drifting back in. let css = stylesheet(&Emit::default()); assert!(!css.contains("scrollbar")); assert!(!css.contains("::-webkit")); } #[test] fn an_invalid_field_is_ringed_without_being_lit() { let css = surface_rules(&Emit::default()); assert!(css.contains(".field {")); // The ARIA attribute, not a class: one fact, read by both the visual // and the accessible state, so they cannot drift. assert!(css.contains(".field[aria-invalid=\"true\"] {")); assert!(!css.contains(".field.invalid")); // A flat ring: this edge says "wrong", and a two-tone bevel would have // it say "raised" at the same time. assert!(css.contains("0 0 0 1px var(--danger)")); } #[test] fn an_invalid_field_keeps_the_well_underneath_it() { // box-shadow is not additive. A lone ring replaces the bevel and drops // the well out from under the field, which is what this emitted before // 0.5.0 and is the whole reason the rule composes. let css = surface_rules(&Emit::default()); let invalid = css .lines() .skip_while(|l| !l.starts_with(".field[aria-invalid")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!( invalid.contains("var(--bevel-inset)"), "the well was dropped: {invalid}" ); assert!(invalid.contains("var(--danger)")); } #[test] fn button_and_card_come_out_identical_by_construction() { // The duplication phase A deletes. They are the same composition, so // the only honest way to emit both is from one call. let opts = Emit::default(); let css = surface_rules(&opts); assert_eq!( depth_declarations(Depth::Raised), depth_declarations(Depth::Raised) ); assert!(css.contains(".button {")); assert!(css.contains(".card {")); assert_eq!( interactive_rules("button", Depth::Raised, &Emit::default()).replace("button", "card"), interactive_rules("card", Depth::Raised, &Emit::default()) ); } #[test] fn a_prefix_reaches_the_component_classes_too() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = stylesheet(&opts); for name in [ "mo-button", "mo-card", "mo-field", "mo-badge", "mo-chip", "mo-tab", "mo-row-primary", "mo-progress", "mo-progress-fill", ] { assert!(css.contains(&format!(".{name}")), "unprefixed: {name}"); } // The bare names must be gone entirely, or a prefixed build still // collides with the app's own stylesheet. assert!(!css.contains(".button {")); assert!(!css.contains(".card {")); assert!(!css.contains(".badge {")); } #[test] fn the_whole_sheet_still_names_every_colour() { // The crate's founding property, asserted over the component layer and // not only the primitives. let css = stylesheet(&Emit::default()); assert!(!css.contains('#')); assert!(!css.contains("rgb")); for line in css.lines() { // Declarations only: a selector or an at-rule can carry a colon of // its own (`:root`, `:hover`, `@media (hover: hover)`) and declares // nothing. Keyed on the trailing semicolon rather than on leading // indentation, which only ever worked as a proxy for nesting depth // and stopped when the sheet gained a cascade layer around it. let trimmed = line.trim(); if !trimmed.ends_with(';') { continue; } let Some((_, value)) = trimmed.split_once(": ") else { continue; }; if value.contains("var(--") { continue; } // Everything left has to be a keyword, a number or a // caller-supplied length, never a colour. // // The length arm is what the comment above always claimed and the // list never covered: `border_width` arrives from `Emit` and lands // bare in the focus ring's offset, where the bevel had only ever // used it inside an `inset` shadow. let opts = Emit::default(); assert!( value.contains("inset") || value.contains(opts.border_width) || value.contains(opts.focus_width) // 0.12.0's two: a sortable header is a control and says so // with the pointer, and the caret is this renderer's own // expression of `aria-sort`. Neither is a colour, which is // what this test is actually about, and neither is a size, // which is the other thing this crate must not name. || value.starts_with("\"\\2") || matches!( value.trim_end_matches(';'), "0" | "1" | "none" | "auto" | "not-allowed" | "pointer" ), "unrecognised literal value: {line}" ); } } #[test] fn flat_emits_nothing_at_all() { assert_eq!(depth_class(Depth::Flat, &Emit::default()), None); assert!(!depth_rules(&Emit::default()).contains("flat")); } #[test] fn a_prefix_namespaces_every_class() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = depth_rules(&opts); assert!(css.contains(".mo-raised {")); assert!(css.contains(".mo-well {")); assert!(!css.contains(".raised {")); } #[test] fn the_border_width_is_the_callers() { let opts = Emit { border_width: "2px", ..Emit::default() }; assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0")); } #[test] fn edges_agree_with_the_description() { // Not a tautology: it is the guard that a CSS-shaped convenience never // quietly reverses which side is lit. let (tl, br) = Bevel::Raised.edges(); assert_eq!(tl.token(), Edge::Light.token()); assert_eq!(br.token(), Edge::Dark.token()); } }