Skip to main content

makeover_webview/
lib.rs

1//! The webview renderer for [`makeover_layout`].
2//!
3//! <!-- wiki: makeover-webview -->
4//!
5//! # The renderer that needs no palette
6//!
7//! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui
8//! and a terminal need an actual colour before they can put anything on
9//! screen. A webview does not: `var(--surface-raised)` *is* the late binding,
10//! and the browser resolves it against whatever `themes.js` last wrote onto
11//! `:root`.
12//!
13//! So this crate emits text naming intents, and never learns a colour. It is
14//! the deferral rule with no adapter in the way, and it is why the webview was
15//! always the wrong renderer to derive a vocabulary from: it can express
16//! anything, so it never pushes back.
17//!
18//! # Phase A: the stylesheet
19//!
20//! This module emits component CSS and no markup, deliberately. GoingsOn has
21//! 145 `innerHTML` sites and Balanced Breakfast 175 `createElement` sites, so
22//! moving markup is a migration where adopting a generated stylesheet is not.
23//! The apps keep every line of their markup and gain the classes.
24//!
25//! It is not a deletion either, which this header claimed until the measurement
26//! came in. Adoption across goingson removed 49 declarations net and *added* 25
27//! lines: a rule loses its depth declarations and gains a variant selector next
28//! to it, so the file stays the same size. What phase A moves is where depth is
29//! defined, not how much CSS exists. Numbers and method in the wiki note under
30//! "The deletion test, run".
31//!
32//! The bevel properties are byte-identical to what both apps already
33//! hand-write, which is asserted below.
34//!
35//! Some of what phase A emits is not a look but the withdrawal of one. A
36//! renderer that picks its element from the description inherits that element's
37//! user-agent chrome, and [`reset`] is where a primitive says which parts of it
38//! were never asked for.
39//!
40//! # Phase B: the markup, one description at a time
41//!
42//! [`form`] renders [`makeover_layout::Field`], which is the half of phase B
43//! whose description is settled. It emits strings, because both apps
44//! interpolate their fields into larger string-built forms and returning nodes
45//! would rewrite those too. It owns its own escaping, on the reasoning in that
46//! module: a Rust encoder can cover element text and attribute values with one
47//! function, where the apps need four and have to choose correctly at every
48//! call site.
49//!
50//! [`facet`] renders `makeover_layout::Facet`: a dimension a set is narrowed by,
51//! and the one phase-B emitter whose markup an app is not keeping, because the
52//! markup it replaces was two mechanisms rather than one. A tag's selection and
53//! a tag's browse position were separate state on MNW's discover page, which is
54//! why every filter row there carries a tick box *and* a chevron; one gesture
55//! doing both is what lets the second one go.
56//!
57//! [`list`] is the other half: column tracks, the narrowing rules, and the cell
58//! containers a row is made of. It stops at the cell boundary and does not
59//! render what goes inside one, on the reasoning in that module. So phase B is
60//! now the frame around content in both directions, and what an app still owns
61//! is the content itself.
62//!
63//! # What phase A settled, and what it costs
64//!
65//! Measured against goingson's `styles.css` rather than against a component
66//! list: `.btn`, `.card` and `.tag, .badge` each hand-write the same
67//! composition, so three quarters of phase A is one rule with several names.
68//!
69//! Two of the four decisions change how goingson looks, and adoption should
70//! not be described as a pure deletion:
71//!
72//! - **Pressed carries its fill.** [`interactive_rules`] emits
73//!   [`Depth::pressed`] whole. goingson presses to `--surface-sunken` today and
74//!   will press to `--surface-well`, and hovers to `--surface-overlay` today
75//!   and will hover to `--hover-surface`. Since `surface-well` inverts by theme
76//!   where `surface-sunken` does not, a dark theme presses *lighter* than it
77//!   hovers. That falls out of `makeover`'s own derivation, which says outright
78//!   that `surface-sunken` cannot serve as a well, so if it reads wrong the
79//!   answer is there and not here.
80//! - **Badges go flat.** See [`token_rules`].
81//!
82//! The other two: the progress trough is renderer-local and the scrollbar
83//! track was dropped ([`component_rules`]), and no class prefix ships by
84//! default, so adoption means deleting the app's hand-written rule in the same
85//! commit that adds the generated one. `.card`, `.badge` and the tab classes
86//! all already exist in goingson, and while both rules exist the cascade order
87//! decides which wins. That is the one real risk in adopting this, and it is
88//! why the migration lands per component rather than in one commit.
89//!
90//! # Interaction states
91//!
92//! [`interactive_rules`] emits four states, in emission order, and the order is
93//! load-bearing: they are all specificity (0,2,0), so disabled beats hover by
94//! coming last and by nothing else. Nothing here reaches for `:not(:disabled)`,
95//! which would raise a selector this crate wraps in its own layer.
96//!
97//! Emitting the states here is what keeps an app from completing the primitive
98//! from outside, by out-specifying a rule it does not own. Those overrides are
99//! also what breaks under cascade layers: an app that declares `@layer` puts
100//! its own rules in a named layer, and unlayered declarations outrank every
101//! named layer regardless of specificity.
102//!
103//! Hover sits inside a capability query. `makeover-touch` answers whether a
104//! fingertip has hover and `makeover-geometry` spells the condition; this crate
105//! asks and does not decide, so no app has to take the hover state back on
106//! touch.
107//!
108//! # The layer contract
109//!
110//! [`stylesheet`] emits into the `makeover` cascade layer ([`CSS_LAYER`], which
111//! lives in `makeover-geometry` because that is the one crate every CSS emitter
112//! in the family already depends on). `makeover-geometry` does the same for
113//! `geometry.css`.
114//!
115//! The cascade resolves origin and importance, then layer, then specificity,
116//! then source order, and **unlayered normal declarations outrank every named
117//! layer**. An unlayered generated file therefore beats every rule an app owns,
118//! regardless of specificity and regardless of loading last. Nothing errors when
119//! that happens: the CSS is valid, the minifier is happy, and buttons and badges
120//! look subtly wrong. The layer belongs here rather than in each app, because an
121//! app cannot fix it from its own stylesheet: the fix is to layer the file it
122//! does not own.
123//!
124//! An app should declare the order once, or the layer's position is decided by
125//! whichever generated file the browser happens to see first:
126//!
127//! ```css
128//! @layer makeover, base, components, responsive;
129//! ```
130//!
131//! [`in_css_layer`] is re-exported for an app that assembles its own stylesheet
132//! from this crate's pieces. Rules an app generates from
133//! [`list::narrowing_css`] and [`list::grid_template_columns`] are as generated
134//! as the ones here, so they belong in the same layer and this crate cannot put
135//! them there on the app's behalf.
136//!
137//! # Suggestions
138//!
139//! `Outcome::Suggestions` carries `Candidate` rather than `Choice`, and a
140//! candidate has no `unavailable`: a suggestion that cannot be picked is a row
141//! a route should not have offered. What it has instead is a `detail`, the line
142//! that tells it from a row reading the same, and it is drawn in
143//! `--content-muted` rather than in the disabled token. A detail orients rather
144//! than refuses, and every other secondary line in this crate reads the same
145//! way. The class is `.form-suggestion-detail`.
146//!
147//! # An interval is one question with two ends
148//!
149//! [`makeover_layout::FieldKind::Interval`] emits a `role="group"` named by the
150//! field's label, holding one `<input type="number">` per end.
151//!
152//! - **The group carries the error and the descriptions**, on the split
153//!   [`makeover_layout::FieldKind::Radio`] already uses here: what is wrong is
154//!   the answer, and a crossed interval is not the fault of either end.
155//! - **Both boxes take the whole extent.** `min`, `max` and `step` describe the
156//!   axis, so they are written twice. The crossing rule is not emitted, because
157//!   HTML has no attribute for it and the description does not carry it: it
158//!   comes back as an error on the group, like every other refusal.
159//! - **Which end is which is `aria-label` and nothing more.** The description
160//!   states direction structurally, by which member holds which name, and never
161//!   in words. Visible Min and Max captions are a page's own and reach the
162//!   group through [`form::Filling::trailing`].
163//!
164//! [`form::Value::Between`] is the second value. A separator inside one string
165//! would make this crate the owner of a delimiter that either end could contain.
166//!
167//! # A number's unit is adjacent text
168//!
169//! HTML has no unit attribute and inventing one would be markup nothing reads,
170//! so `Field::unit` is a `<span>` after the control. It is named in
171//! `aria-describedby` rather than left as decoration, because a number and what
172//! it is measured in are one fact and reading the first without the second is
173//! reading it wrong. What that buys is a unit a consumer can read back rather
174//! than a suffix on a label it would have to parse.
175//!
176//! # A curve this renderer can carry, and one it declines
177//!
178//! A range takes its granularity from the curve (`Field::curve.step()`), every
179//! other kind keeps `Field::step`, and `Curve::Linear` emits a plain range.
180//!
181//! **A constant-ratio curve emits a linear track, and that is the answer, not a
182//! debt.** HTML has no logarithmic range input, so a described screen asking
183//! for one is asking the browser for something it does not have, the same class
184//! of request as [`makeover_layout::FieldKind::Date`] on a host with no
185//! calendar. The renderer answers with the nearest control the host really
186//! offers and keeps every fact that survives the translation: the extent, the
187//! granularity, and the value's own units. What does not survive is resolution
188//! at the small end. The value submitted is still a value in the field's own
189//! units, which is what every handler on this path reads.
190//!
191//! The alternatives are worse in the specific way this stack exists to avoid.
192//! Shipping JS that maps thumb position to value puts app code back in the
193//! renderer. Changing what the control submits from a value to a fraction moves
194//! the mapping to whoever reads the form, and a server reading these forms with
195//! its own handlers would take a fraction where a value is expected, silently.
196//!
197//! When this reopens: the day a described screen on the webview path asks for a
198//! non-linear range. The answer then is mapping in `quasi-router`, where one
199//! implementation serves every host, not JS here.
200//!
201//! # A markdown field gets a preview
202//!
203//! A [`makeover_layout::FieldKind::Rich`] field is marked
204//! `data-format="markdown"`, and [`form::editor_rules`] is what spends that
205//! mark. The Write/Preview pair is a segmented control, so it takes the depth,
206//! the focus ring and the chosen state from rules that already exist; the
207//! preview pane is a well, because it stands where the control stood. Both are
208//! gated on the attribute rather than on a class, which is what the attribute is
209//! for. A permission taken and not spent turns every conversion into a
210//! regression.
211//!
212//! **This crate renders no markdown.** The pane arrives empty and is filled by
213//! whatever binds the editor, which is where the host's sanitiser already is. A
214//! converter here would move that guarantee into a crate with no view of the
215//! host's content-security posture.
216//!
217//! # Ranges, ghost text, and an option that cannot be picked
218//!
219//! - `FieldKind::Range` emits `<input type="range">`, and `Field::step` emits
220//!   `step`. The step is emitted only when the description carries one: the
221//!   browser's own default is `step="1"`, which is what a description means by
222//!   saying nothing, and is also what turns a 0-to-1 threshold into a
223//!   two-position control.
224//! - A select with nothing chosen emits a disabled, selected, valueless first
225//!   option carrying `Field::placeholder`. HTML has no placeholder attribute on
226//!   `<select>`; this is the idiom, and `required` keeps working through it
227//!   because the option's value is empty.
228//! - `Choice::unavailable` emits `disabled` plus the reason. Where it goes
229//!   differs by control and the difference is forced: a radio group gets a
230//!   `.form-option-reason` span beside the label, and a `<select>` option has
231//!   room for no element at all, so the reason runs into its text.
232//! - `Choice::detail` takes the same split for the same reason: a
233//!   `.form-option-detail` span in a radio group, run into the text of a
234//!   `<select>`'s option. An option carrying both reads what it is before why it
235//!   cannot be picked.
236//!
237//! # A cell says what it holds
238//!
239//! [`CellPart`](makeover_layout::CellPart) names the four things a cell holds,
240//! and [`table_rules`] turns them into `.cell-value`, `.cell-tokens`,
241//! `.cell-actions` and `.cell-link`. Only the first takes a colour: a token
242//! carries its own tone, an action is a control rather than text, and a link
243//! takes the action colour from the anchor it is.
244//!
245//! The colour goes on `.cell-value` rather than on `.cell`. On the container it
246//! cascades into the parts that are not text, and a control in a cell is painted
247//! as text, which is the drift
248//! [`RowPart::intent`](makeover_layout::RowPart) prevents for list rows.
249//!
250//! [`list::Cell::part`] is `Option<CellPart>` and never `Option<RowPart>`: the
251//! two answer different questions, and only one of them is about a cell.
252//!
253//! # A table lays itself out
254//!
255//! [`list::narrowing_css`] emits the track list and has to be called with the
256//! columns, so it works where the columns are known at build time. A table a
257//! description produced knows its columns at render time, and the rules would
258//! have to travel with the markup: a `<style>` element per table, which needs
259//! `style-src 'unsafe-inline'`, or the head, which an htmx fragment swap does
260//! not carry. [`table_rules`] lays a table out with `display: table` instead,
261//! which aligns columns across rows knowing nothing about how many there are.
262//! [`Priority`](makeover_layout::Priority) hiding is one rule per drop class,
263//! and [`list::column_classes`] is what puts those classes on a cell. **A header
264//! row emitted by a renderer's own code has to call it too**, or the header and
265//! the body disagree about which column just dropped.
266//!
267//! `.button` takes the four tones as colour, off `data-tone`, the way the badge
268//! does, so a destructive button has somewhere for its tone to land. A list is
269//! reset rather than left as a bulleted list.
270//!
271//! `RowPart::revealed_on_hover` is not honoured. Hiding a row's actions until
272//! hover hides them from pointer users alone, who are the ones scanning a list
273//! to learn what can be done to a row, and every escape the rule grows
274//! (`focus-within` for the keyboard, a capability gate for a fingertip) is a
275//! report that hiding was wrong for somebody.
276//!
277//! # The depth classes are not controls
278//!
279//! `.raised` is a statement about shape and carries no interactive set, so the
280//! vocabulary has a raised surface that is merely an object. An app that wants
281//! one does not have to take a control class and cancel the control half.
282//!
283//! `.card` and `.button` are the same depth *and* controls, and they take their
284//! states from [`surface_rules`], which is where a state belongs: on the thing
285//! that claims to answer a pointer.
286//!
287//! # Substitution, three ways
288//!
289//! A theme with no `surface-well` is answered differently by each renderer,
290//! which is why substitution belongs to a renderer and not to the description:
291//!
292//! - `makeover-immediate` substitutes the page in Rust.
293//! - `makeover-tui` refuses to substitute and draws an edge instead, because a
294//!   terminal would quantise the two together.
295//! - here, CSS already has the mechanism: `var(--surface-well,
296//!   var(--surface-page))` falls back in the browser, and nothing in Rust
297//!   decides anything.
298
299#![forbid(unsafe_code)]
300
301pub mod facet;
302pub mod figure;
303pub mod form;
304pub mod list;
305pub mod meter;
306pub mod placeholder;
307pub mod reset;
308pub mod vocabulary;
309
310/// A render of every emitter, scraped for the classes it wrote.
311///
312/// Test-only, and the guard behind [`vocabulary::names`]. See the module's own
313/// header for why the check renders rather than reads the source.
314#[cfg(test)]
315mod corpus;
316
317use crate::list::{cell_part_class, part_class};
318use crate::reset::{Chrome, Reset};
319use makeover_geometry::{Density, SizeClass};
320// Re-exported rather than redefined. An app assembling its own stylesheet out
321// of this crate's pieces needs the same layer name, and most such apps depend
322// on this crate and not on `makeover-geometry` directly: goingson builds
323// `tables.css` in its own build.rs from [`list::narrowing_css`], and those
324// rules are as generated as the ones here.
325pub use makeover_geometry::{CSS_LAYER, in_css_layer};
326
327/// This crate's version, as the generated stylesheet reports it.
328///
329/// A consumer whose lockfile still pins an old `makeover-webview` gets a
330/// well-formed sheet with components missing and no error anywhere, so the
331/// emitter has to name itself in what it writes. Read by
332/// `makeover_build::layout_css` through [`stylesheet`].
333pub const VERSION: &str = env!("CARGO_PKG_VERSION");
334use makeover_layout::{
335    Bevel, CellPart, Depth, Fallback, Fill, Flow, Intent, RowPart, Selector, Sort, State, Token,
336    Tone,
337};
338use makeover_touch::Affordance;
339use std::fmt::Write as _;
340
341/// How the emitted CSS is shaped.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub struct Emit {
344    /// Bevel thickness, as a CSS length.
345    ///
346    /// A value, so it arrives from the caller: border widths belong to
347    /// `makeover-geometry` and will come from there once it carries them.
348    pub border_width: &'static str,
349    /// Focus ring thickness, as a CSS length.
350    ///
351    /// Separate from [`border_width`](Self::border_width), and never derived
352    /// from it: a bevel and a focus indicator answer different questions, and
353    /// only one of them has to be noticed from across a desk.
354    ///
355    /// The default is the measured consensus rather than a new opinion. Every
356    /// consumer had already written its own ring and all three chose at least
357    /// 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px,
358    /// goingson 2px on three rules and 3px on the one covering twelve
359    /// selectors. The design system was the only thing in the tree saying 1px.
360    pub focus_width: &'static str,
361    /// Prefix for emitted class names, without the leading dot.
362    pub class_prefix: &'static str,
363}
364
365impl Default for Emit {
366    fn default() -> Self {
367        Self {
368            border_width: "1px",
369            focus_width: "2px",
370            class_prefix: "",
371        }
372    }
373}
374
375/// A string as CSS escapes, for a `content` value.
376///
377/// `\u{25B2}` becomes `\25B2`. Emitted escaped rather than literally so the
378/// stylesheet is ASCII whatever the description spells: a `content` string is
379/// read by whatever encoding the consumer serves the file as, and a caret that
380/// depends on that is a caret that works on one machine.
381///
382/// Terminated by the closing quote at every site here. A CSS hex escape takes
383/// up to six digits and ends at the first character that cannot be one, so an
384/// escape followed by more text would need a space that these do not.
385fn css_escape(text: &str) -> String {
386    text.chars().fold(String::new(), |mut out, c| {
387        let _ = write!(out, "\\{:X}", c as u32);
388        out
389    })
390}
391
392/// The CSS custom property holding a bevel's composition.
393#[must_use]
394pub fn bevel_var(bevel: Bevel) -> &'static str {
395    match bevel {
396        Bevel::Raised => "--bevel-raised",
397        Bevel::Inset => "--bevel-inset",
398    }
399}
400
401/// A `var()` reference to a fill intent, with the browser's own fallback where
402/// the intent may be absent.
403///
404/// The fallback is CSS syntax, not a decision made here. That is the whole
405/// difference between this renderer and the other two.
406#[must_use]
407pub fn fill_var(fill: Fill) -> String {
408    match fill {
409        Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()),
410        other => format!("var(--{})", other.token()),
411    }
412}
413
414/// The two-tone edge as a `box-shadow` value.
415///
416/// Two inset shadows, one per corner pair: the light one offset down and
417/// right so it lands on the top and left edges, the dark one the other way.
418/// The same assignment `makeover-immediate` draws with polylines and
419/// `makeover-tui` draws with box-drawing characters.
420#[must_use]
421pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String {
422    let (top_left, bottom_right) = bevel.edges();
423    let w = opts.border_width;
424    format!(
425        "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})",
426        top_left.token(),
427        bottom_right.token()
428    )
429}
430
431/// The custom properties both bevels resolve through.
432///
433/// Emitted as properties rather than inlined into every rule because that is
434/// what the apps already do, and because a consumer that wants the edge
435/// without the fill reads the property directly.
436#[must_use]
437pub fn bevel_properties(opts: &Emit) -> String {
438    let mut css = String::new();
439    for bevel in [Bevel::Raised, Bevel::Inset] {
440        let _ = writeln!(
441            css,
442            "    {}: {};",
443            bevel_var(bevel),
444            bevel_shadow(bevel, opts)
445        );
446    }
447    css.push_str(ELEVATION_PROPERTY);
448    css
449}
450
451/// The cast shadow of a surface that floats over the page.
452///
453/// Composed here for the reason the bevel pair is: `makeover` derives the tone,
454/// this crate owns the geometry, and neither has to know the other's numbers.
455///
456/// **Only for a surface that overlays the page.** A menu, a toast, a popover, a
457/// dropdown. A surface *in* the page takes `.raised` and its bevel, and a rule
458/// that reaches for this on a card or a plate has renamed a literal rather than
459/// replaced it.
460///
461/// Two lengths rather than one, because a single blur reads as a smudge at
462/// plate size and as a halo at menu size. The offset is small and downward: a
463/// Platinum-era menu sits just off the page rather than hovering above it.
464const ELEVATION_PROPERTY: &str =
465    "    --elevation-overlay: 0 2px 4px var(--elevation), 0 8px 24px var(--elevation);\n";
466
467/// The class name for a depth.
468#[must_use]
469pub fn depth_class(depth: Depth, opts: &Emit) -> Option<String> {
470    let name = match depth {
471        Depth::Flat => return None,
472        Depth::Raised => "raised",
473        Depth::Well => "well",
474        Depth::Sunken => "sunken",
475        // A depth added to the description since this renderer was last
476        // built. No class, on the same footing as Flat: emitting a name
477        // whose rule body we cannot write would put a class in the markup
478        // that the stylesheet never defines.
479        _ => return None,
480    };
481    Some(format!("{}{name}", opts.class_prefix))
482}
483
484/// A prefixed class name.
485///
486/// Public, for the renderers that emit markup this crate does not. A screen
487/// renderer writing `class="row"` has to prefix it the way the stylesheet half
488/// does, or a prefixed app gets rules matching everything except the elements
489/// that renderer wrote, and the failure is invisible: the CSS stays valid and
490/// one element is unstyled. Call this rather than copying it.
491#[must_use]
492pub fn class(name: &str, opts: &Emit) -> String {
493    let mut out = String::with_capacity(opts.class_prefix.len() + name.len());
494    push_class(&mut out, name, opts);
495    out
496}
497
498/// A prefixed class name, written into a buffer the caller already has.
499///
500/// The form the emitters use, and the reason it exists is [`escape_into`]'s:
501/// putting every class on every element through a `format!` allocates even in
502/// the default case, where the prefix is empty and the answer is the argument.
503/// A described table row carries roughly eighty transient allocations that way,
504/// and this and the escaper are most of them.
505///
506/// [`class`] stays for callers holding a name rather than a buffer.
507///
508/// [`escape_into`]: crate::form::escape_into
509pub fn push_class(out: &mut String, name: &str, opts: &Emit) {
510    out.push_str(opts.class_prefix);
511    out.push_str(name);
512}
513
514/// The class an option of a selector carries, which is what the rules key off.
515///
516/// Named for the option and not for the group: [`selector_rules`] styles the
517/// thing that gets picked, so `Selector::Tabs` is `tab` and not `tabs`. The
518/// distinction is not pedantry. quasi-webview spelled these `tabs`, `segmented`
519/// and `option`, put `toggle` on the wrapping element rather than on the
520/// buttons inside it, and every described selector in that renderer came out
521/// with no depth, no focus ring and no chosen state, while the toggle group got
522/// a bevel meant for its buttons.
523///
524/// The chosen option additionally carries `chosen`, the same way a latched chip
525/// carries `latched`. That name is this crate's too; there is no reason for a
526/// caller to spell it, and [`selector_rules`] is where it is written down.
527#[must_use]
528pub fn option_class(selector: Selector) -> &'static str {
529    match selector {
530        Selector::Tabs => "tab",
531        Selector::Segmented => "segment",
532        Selector::Toggle => "toggle",
533    }
534}
535
536/// The fill and edge declarations for a depth, as a rule body.
537///
538/// Empty for [`Depth::Flat`], which has neither and inherits what it sits on.
539/// Callers lean on the emptiness to skip the rule rather than emit a class that
540/// sets nothing: a class that sets no properties is a class that means "I
541/// thought about this", which is what comments are for.
542///
543/// The two halves are emitted independently because [`Depth::Sunken`] has a
544/// fill and no bevel. Requiring both would silently drop the fill for exactly
545/// that case. Independent does not
546/// mean unpaired: both halves still come off one `Depth`, so they cannot
547/// disagree about what the region is.
548#[must_use]
549pub fn depth_declarations(depth: Depth) -> String {
550    let mut css = String::new();
551    if let Some(fill) = depth.fill() {
552        let _ = writeln!(css, "    background: {};", fill_var(fill));
553    }
554    if let Some(bevel) = depth.bevel() {
555        let _ = writeln!(css, "    box-shadow: var({});", bevel_var(bevel));
556    }
557    css
558}
559
560/// One rule giving a selector a depth, or nothing when the depth declares
561/// nothing.
562#[must_use]
563pub fn depth_rule(selector: &str, depth: Depth) -> String {
564    let body = depth_declarations(depth);
565    if body.is_empty() {
566        return String::new();
567    }
568    format!(".{selector} {{\n{body}}}\n")
569}
570
571/// The media condition a hover rule has to sit inside, or `None` if hover is
572/// unconditional.
573///
574/// Two crates answer this and neither answer is made here. `makeover-touch`
575/// owns *whether* hover exists at a density, and `makeover-geometry` owns how
576/// that capability is spelled as a media condition. Asking both is what stops
577/// this renderer minting a third opinion, which is what all three apps did:
578/// goingson sniffed the user agent, Balanced Breakfast used `(hover: none)`
579/// alone, and the MNW server had no gate at all.
580///
581/// [`SizeClass`] is required by [`Affordance::available`] and ignored by this
582/// member, which reports as much through `reads_size`. Passing Compact is not
583/// a claim about width; the test below pins that every class agrees.
584fn hover_condition() -> Option<&'static str> {
585    if Affordance::Hover.available(Density::Touch, SizeClass::Compact) {
586        // A fingertip grew a hover state. Nothing to gate, and this renderer
587        // should not invent a reason to gate anyway.
588        None
589    } else {
590        Some(Density::Pointer.media_condition())
591    }
592}
593
594/// Put a rule inside a media query, or leave it alone.
595fn gated(condition: Option<&str>, rule: &str) -> String {
596    let Some(condition) = condition else {
597        return rule.to_string();
598    };
599    let mut css = format!("@media {condition} {{\n");
600    for line in rule.lines() {
601        // Blank lines stay blank. Indenting one leaves trailing whitespace,
602        // which is the sort of thing a formatter later reverts and calls a diff.
603        if line.is_empty() {
604            css.push('\n');
605        } else {
606            let _ = writeln!(css, "    {line}");
607        }
608    }
609    css.push_str("}\n");
610    css
611}
612
613/// The keyboard focus ring, placed by the depth it lands on.
614///
615/// This is the webview's **focus ring** and nothing more. **Reach** and
616/// **focus** are both the browser's — the document decides what is reachable
617/// and `:focus-visible` decides which reached thing wears the ring — and no
618/// description states either. The three terms are defined once in
619/// `makeover_layout`'s crate header, "Reach, focus and the focus ring".
620///
621/// One ring for the whole system, because a focus ring's job is to be
622/// recognised and three apps having three of them is the failure. What varies
623/// is where it sits, and that comes off [`Depth`] rather than off a per-
624/// component choice: a well takes the ring inside its own edge, and anything
625/// standing proud of the page takes it outside.
626///
627/// `outline` rather than the composed `box-shadow` the invalid-field ring at
628/// [`field_rules`] uses, and deliberately the one place the two rings are built
629/// differently. A `box-shadow` ring has to restate the bevel beside it, because
630/// `box-shadow` is not additive and a lone ring silently drops the well out
631/// from under the element. That restatement is a second copy of the depth,
632/// living in a different function from the first, and it is exactly the
633/// duplication `Depth` exists to prevent. `outline` occupies its own property,
634/// so the bevel survives untouched and there is nothing to keep in agreement.
635/// They render the same: both are a flush ring one border-width wide.
636#[must_use]
637pub fn focus_rule(selector: &str, depth: Depth, opts: &Emit) -> String {
638    let w = opts.focus_width;
639    // Same magnitude either way, and only the sign comes off the depth. Both
640    // values are what the consumers had already converged on independently:
641    // 2px out is what all three wrote, and 2px in is the MNW server's own
642    // answer for the one inset ring it had.
643    let offset = match depth.bevel() {
644        // Inside the well, clear of its edge rather than painted over it.
645        Some(Bevel::Inset) => format!("calc(-1 * {w})"),
646        // Raised, or no edge at all. Outside, standing off by its own width.
647        _ => w.to_string(),
648    };
649    // The token by name. It is `makeover`'s, derived from the action colour,
650    // and reaching it through a description member was a second path to the
651    // same variable for as long as one existed.
652    format!(
653        ".{selector}:focus-visible {{\n    outline: {w} solid var(--focus-ring);\n    outline-offset: {offset};\n}}\n"
654    )
655}
656
657/// A rest depth said out loud on both axes, for a rule that has to beat the
658/// states above it.
659///
660/// [`depth_declarations`] states an axis only when the depth has something to
661/// say about it, which is right for a rest rule: a [`Depth::Flat`] region
662/// inherits what it sits on, and asserting `background: none` there would be
663/// the difference between level-with and painted-transparent. It is wrong for
664/// a rule whose whole job is to take a state back. An axis left unstated is an
665/// axis the state above keeps, so `Flat` re-asserted nothing at all and a
666/// disabled control kept whatever hover had given it.
667///
668/// So the axes the depth is silent on are withdrawn rather than skipped, and
669/// the withdrawal is spelled by [`reset`] rather than here, so a disabled
670/// control and a flat one say the same words. Reaches further than the fill:
671/// [`Depth::Sunken`] and [`Depth::Overlay`] have no bevel either, and the
672/// pressed rule above hands out an inset one.
673fn rest_declarations(depth: Depth) -> String {
674    let mut css = String::new();
675    match depth.fill() {
676        Some(fill) => {
677            let _ = writeln!(css, "    background: {};", fill_var(fill));
678        }
679        None => css.push_str(&Reset::NOTHING.and(Chrome::Fill).declarations()),
680    }
681    match depth.bevel() {
682        Some(bevel) => {
683            let _ = writeln!(css, "    box-shadow: var({});", bevel_var(bevel));
684        }
685        None => css.push_str(&Reset::NOTHING.and(Chrome::Shadow).declarations()),
686    }
687    css
688}
689
690/// Present, visible, and not answering.
691///
692/// Matches the ARIA attribute as well as the pseudo-class, because `:disabled`
693/// only matches form elements and half the things this crate emits are not
694/// one: a `div` carrying `.chip` or `.tab` can never be `:disabled`. Keying on
695/// the accessible state is the pattern [`field_rules`] already establishes for
696/// `aria-invalid`, on the reasoning that one fact read by both the styling and
697/// the accessibility tree cannot drift from itself.
698///
699/// The rest depth is re-asserted rather than assumed, because this rule has to
700/// beat the hover and pressed rules above it. It does that on source order at
701/// equal specificity, not by out-specifying them: every rule this function's
702/// caller emits is (0,2,0), and adding a `:not(:disabled)` anywhere would raise
703/// one of them and have to be unpicked when this output moves inside its own
704/// cascade layer.
705///
706/// Re-asserted on **both** axes, through [`rest_declarations`].
707/// `depth_declarations` alone is empty for [`Depth::Flat`], so a flat control
708/// would win the contest with nothing to say and keep the hover surface
709/// underneath a control that had stopped answering.
710#[must_use]
711pub fn disabled_rule(selector: &str, depth: Depth) -> String {
712    format!(
713        ".{selector}:disabled,\n.{selector}[aria-disabled=\"true\"] {{\n{}    color: var(--{});\n    cursor: not-allowed;\n}}\n",
714        rest_declarations(depth),
715        State::Disabled.token()
716    )
717}
718
719/// Every state a selector that answers a click implies: hover, pressed, focus
720/// and disabled, in that order.
721///
722/// Order is the whole cascade mechanism here. All four selectors are
723/// specificity (0,2,0), so disabled wins over hover and pressed by coming last
724/// and by nothing else.
725///
726/// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting
727/// only the edge is what left goingson hand-writing `background:
728/// var(--surface-sunken)` on three separate rules, and a fill that does not
729/// travel with its edge is precisely the disagreement `Depth` exists to make
730/// unrepresentable. So the pressed fill comes from the description
731/// (`--surface-well`) rather than from whatever each app reached for.
732///
733/// Hover has no member in the description and is renderer policy: a terminal
734/// and an immediate-mode painter have no hover to express. It resolves against
735/// `--hover-surface`, which `makeover` already derives and which nothing
736/// consumed until now. What it *is* gated on is capability, via
737/// [`hover_condition`]. Before that gate existed the apps each wrote their own:
738/// goingson's section 60 exists solely to take back the hover state this
739/// function had just handed it, by out-specifying a rule it does not own.
740///
741/// `depth` is the selector's **rest** depth, used to place the focus ring and
742/// to restore the surface under a disabled control. The pressed rule keeps
743/// inverting from [`Depth::Raised`] regardless: a tab's unchosen depth is
744/// [`Depth::Sunken`], and `Sunken.pressed()` is `Sunken`, so deriving the press
745/// from the rest depth would leave a tab with no press at all.
746#[must_use]
747pub fn interactive_rules(selector: &str, depth: Depth, opts: &Emit) -> String {
748    let mut css = gated(
749        hover_condition(),
750        &format!(".{selector}:hover {{\n    background: var(--hover-surface);\n}}\n"),
751    );
752    css.push_str(&depth_rule(
753        &format!("{selector}:active"),
754        Depth::Raised.pressed(),
755    ));
756    css.push_str(&focus_rule(selector, depth, opts));
757    css.push_str(&disabled_rule(selector, depth));
758    css
759}
760
761/// One rule per depth: its fill and its edge, together.
762///
763/// A depth and nothing else. `.raised` says a surface sits on what is behind
764/// it, which is a statement about the shape and not about what happens when a
765/// pointer arrives, so it emits no hover, press, focus or disabled rule. The
766/// named surfaces are where interaction lives: `.card` and `.button` are the
767/// same depth *and* controls, and they get their states from
768/// [`surface_rules`].
769///
770/// Giving this class the interactive set leaves the vocabulary with no raised
771/// surface that is merely an object, so a consumer that needs one has to take a
772/// control class and cancel half of it.
773#[must_use]
774pub fn depth_rules(opts: &Emit) -> String {
775    let mut css = String::new();
776    for depth in [Depth::Raised, Depth::Well] {
777        let Some(class) = depth_class(depth, opts) else {
778            continue;
779        };
780        css.push_str(&depth_rule(&class, depth));
781    }
782    css
783}
784
785/// The three surfaces that are a depth with a name.
786///
787/// `button` and `card` are both [`Depth::Raised`], and `field` is a
788/// [`Depth::Well`] because that is the reading `Depth`'s own documentation
789/// gives a text field. Their bodies come out identical by construction rather
790/// than by hand: three hand-written copies in goingson's stylesheet is what
791/// phase A deletes, and generating them from one call is what stops them
792/// drifting apart again.
793fn surface_rules(opts: &Emit) -> String {
794    let mut css = String::new();
795    for name in ["button", "card"] {
796        let c = class(name, opts);
797        css.push_str(&depth_rule(&c, Depth::Raised));
798        css.push_str(&interactive_rules(&c, Depth::Raised, opts));
799    }
800
801    let field = class("field", opts);
802    css.push_str(&depth_rule(&field, Depth::Well));
803
804    // A field takes focus and refuses input like everything else here, and got
805    // neither until now, which is why all three apps hand-write a focus ring
806    // for it and no two of them match. No hover or pressed: a text field does
807    // not light up under the pointer and does not invert when clicked, so the
808    // two states `interactive_rules` would add are the two it does not have.
809    css.push_str(&focus_rule(&field, Depth::Well, opts));
810    css.push_str(&disabled_rule(&field, Depth::Well));
811
812    // Keyed on the ARIA attribute rather than on a class, so the visual state
813    // and the accessible state cannot drift apart: there is one fact and both
814    // read it. goingson already drove its invalid styling this way and was
815    // right to; the `.invalid` class this emitted before 0.5.0 was a second
816    // place to forget.
817    //
818    // The ring composes *after* the bevel rather than replacing it. box-shadow
819    // is not additive, so a lone ring silently dropped the well out from under
820    // an invalid field. Flat and unlit: this edge is saying "wrong", and
821    // lighting one side would have it say "raised" at the same time.
822    let _ = writeln!(
823        css,
824        ".{field}[aria-invalid=\"true\"] {{\n    box-shadow: var({}), 0 0 0 {} var(--danger);\n}}",
825        bevel_var(Bevel::Inset),
826        opts.border_width
827    );
828    css
829}
830
831/// Badges and chips.
832///
833/// The one place phase A changes how goingson looks rather than only where its
834/// rules live. [`Token::Badge`] is [`Depth::Flat`], so a badge emits no fill
835/// and no edge at all, where goingson ships `.tag, .badge` as a single rule
836/// carrying the raised bevel. Splitting that means reading every call site to
837/// decide which of the two it always was.
838///
839/// What a badge does carry is a [`Tone`], the intent family it shares with
840/// notices and nothing else. Neutral is the bare class rather than a variant,
841/// because it is the absence of a status and not a status called "none".
842/// Text that goes somewhere.
843///
844/// The one inline control. A table cell carries `cell-link`, deliberately
845/// unruled because the cell's own rule covers it; this is the same thing
846/// outside a table, which is what a described run holds when a sentence
847/// contains a link.
848///
849/// Colour and underline only. Whether a link is inline in a sentence or sitting
850/// on its own line is the app's layout, and how much room it takes is
851/// `makeover-geometry`'s. What is here is the pair of signals that say "this
852/// goes somewhere" and nothing that says where it sits.
853///
854/// The visited arm is deliberately absent. A link inside an app points at the
855/// app's own screens, which the user is expected to have been to, so painting
856/// them differently marks almost everything and distinguishes nothing.
857fn link_rules(opts: &Emit) -> String {
858    let mut css = String::new();
859    let link = class("link", opts);
860
861    let _ = writeln!(
862        css,
863        ".{link} {{\n    color: var(--action);\n    \
864         text-decoration: underline;\n}}"
865    );
866    // The hover step is the same one every other control takes, and it is a
867    // colour rather than a surface: a link has no box to raise.
868    let _ = writeln!(
869        css,
870        "@media (hover: hover) and (pointer: fine) {{\n    .{link}:hover \
871         {{\n        color: var(--action-hover);\n    }}\n}}"
872    );
873    let _ = writeln!(
874        css,
875        ".{link}:focus-visible {{\n    outline: {} solid var(--focus-ring);\n    \
876         outline-offset: 2px;\n}}",
877        opts.focus_width
878    );
879    // A link is often a `<button>` rather than an `<a>`: a renderer picks the
880    // element from the method, so a link that writes is a button that has to
881    // stop looking like one. What that costs is named in [`reset`].
882    css.push_str(&Reset::TEXT_BUTTON.rule(&format!("button.{link}")));
883    css
884}
885
886fn token_rules(opts: &Emit) -> String {
887    let mut css = String::new();
888
889    // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat,
890    // and a label with an edge says it can be pressed.
891    let badge = class("badge", opts);
892    // `content-muted` literally, not `Tone::Neutral.token()`. What makes a
893    // badge quiet is `Token::Badge` answering no click, which this crate holds
894    // and `Tone` genuinely does not know. Routing it through Neutral put the
895    // claim where the evidence was not, and the bill arrived on the figure
896    // value: it took the same muting from the same call and read as its own
897    // caption. Neutral answers `content` from makeover-layout 0.36.0.
898    let _ = writeln!(css, ".{badge} {{\n    color: var(--content-muted);\n}}");
899    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
900        let _ = writeln!(
901            css,
902            ".{badge}[data-tone=\"{0}\"] {{\n    color: var(--{0});\n}}",
903            tone.token()
904        );
905    }
906
907    // A button carries the four tones a badge does. It had none, on the reading
908    // that a control's colour is its surface rather than its text, and that
909    // reading has one hole big enough to matter: the button that destroys
910    // something. Every consumer had written that rule itself, and a description
911    // that says `Tone::Danger` on an act had nowhere for it to land.
912    //
913    // Colour and not a fill, matching the badge. A red surface is a decision
914    // about emphasis that belongs to an app's own layer, and two of them
915    // fighting is worse than neither.
916    let button = class("button", opts);
917    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
918        let _ = writeln!(
919            css,
920            ".{button}[data-tone=\"{0}\"] {{\n    color: var(--{0});\n}}",
921            tone.token()
922        );
923    }
924
925    // A chip holds itself down, which is `Depth::pressed` arrived at
926    // independently by two apps. `removable` is a remove affordance, so it is
927    // markup and waits for phase B.
928    let chip = class("chip", opts);
929    let unlatched = Token::Chip { removable: false };
930    css.push_str(&depth_rule(&chip, unlatched.depth(false)));
931    css.push_str(&interactive_rules(&chip, unlatched.depth(false), opts));
932    css.push_str(&depth_rule(
933        &format!("{chip}.latched"),
934        unlatched.depth(true),
935    ));
936    css
937}
938
939/// The three selectors, each named by what it picks.
940///
941/// A tab comes *forward* to join the pane it opens, which is why
942/// [`Selector::Tabs`] chooses [`Depth::Raised`] where a segment and a toggle
943/// are held in. That is the folder semantic, and it is the whole reason the
944/// three are not one member with a flag.
945///
946/// [`Selector::abutting`] is not emitted: whether the options touch is
947/// spacing, and spacing is `makeover-geometry`'s question to answer.
948///
949/// Both states emit. Naming only the chosen option leaves an unchosen one
950/// falling through to [`Depth::Flat`] with nothing drawn for it, so an app has
951/// to hand-write the recess that makes its chosen tab read as forward.
952fn selector_rules(opts: &Emit) -> String {
953    let mut css = String::new();
954    for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
955        let c = class(option_class(selector), opts);
956        css.push_str(&depth_rule(&c, selector.unchosen()));
957        css.push_str(&interactive_rules(&c, selector.unchosen(), opts));
958        css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen()));
959    }
960    css
961}
962
963/// The parts of a list row.
964///
965/// The list is written out rather than derived because `RowPart` is
966/// `#[non_exhaustive]`, so there is nothing to iterate. A member added upstream emits no rule until it is named here, which
967/// is the trade `non_exhaustive` makes: a silent gap instead of a build break.
968/// [`part_class`] carries the same list and the same obligation.
969fn row_rules(opts: &Emit) -> String {
970    let mut css = String::new();
971
972    // The container the rows sit in, giving back what a `<ul>` brought. Not a
973    // size: there is no magnitude in it, which is the line this crate holds.
974    css.push_str(&Reset::BULLETS.rule(&format!(".{}", class("list", opts))));
975
976    for part in [
977        RowPart::Primary,
978        RowPart::Secondary,
979        RowPart::Meta,
980        RowPart::Actions,
981        RowPart::Tokens,
982        RowPart::Proportion,
983    ] {
984        let c = class(part_class(part), opts);
985
986        // Actions carry controls rather than text, and `RowPart::intent` says
987        // so by returning the same intent inheriting already gives. Pinning it
988        // would be louder than saying nothing. Tokens answer alike, for their
989        // own reason: each token carries its own tone, and a colour on the
990        // strip would fight the things sitting in it. A proportion is the same
991        // case again: the meter inside carries the tone.
992        if !matches!(
993            part,
994            RowPart::Actions | RowPart::Tokens | RowPart::Proportion
995        ) {
996            let _ = writeln!(css, ".{c} {{\n    color: var(--{});\n}}", part.intent());
997        }
998
999        // A row's actions are shown at rest. `RowPart::revealed_on_hover` said
1000        // otherwise and was not honoured here from 0.23.0; makeover-layout
1001        // 0.13.0 retired the method, so there is no longer a description saying
1002        // one thing and a renderer doing another.
1003        //
1004        // The rule was `opacity: 0` gated to pointer devices, revealed on
1005        // `:hover` and on `:focus-within`. Each escape it needed was a report
1006        // that hiding was wrong for somebody: `focus-within` because tabbing
1007        // could never reach an action; the gate because a fingertip had no way
1008        // to unhide, which both webview apps had already hand-written
1009        // `opacity: 1` to undo. What was left was a control hidden from
1010        // exactly one group: people using a pointer, who are also the group
1011        // scanning a list to find out what can be done to a row.
1012        //
1013        // A settings screen is where that reads worst: the whole reason to be
1014        // on it is to remove a key, and the button doing so was invisible
1015        // until pointed at. A table cell's actions were never hidden, so the
1016        // two arrangements now agree.
1017    }
1018
1019    // A part that may take two lines. `Flow::Tight` gets no rule: one line is
1020    // what a run already does, and restating it here would put a declaration on
1021    // every part in every row to say nothing.
1022    //
1023    // This is the shape both webview apps had already written by hand and
1024    // commented -- Balanced Breakfast on a feed row's title, goingson on a
1025    // problem's body -- which is the whole argument for the description
1026    // carrying it. `-webkit-` prefixed and unprefixed together: the prefixed
1027    // trio is what every engine actually implements, and `line-clamp` is the
1028    // standard property landing behind it.
1029    let _ = writeln!(
1030        css,
1031        ".{} {{\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: {lines};\n    line-clamp: {lines};\n    overflow: hidden;\n}}",
1032        class("row-relaxed", opts),
1033        lines = Flow::Relaxed.lines()
1034    );
1035
1036    // A row inside a hierarchy: a tree, an outline, a threaded list.
1037    // `edf33114`, decided 2026-08-30 (Max). makeover-layout names the concept
1038    // as `Nesting` -- deliberately not `Depth`, which is surface bevel in that
1039    // crate -- and this is the rule.
1040    //
1041    // Measured 2026-08-30: quasi-webview has been emitting `row-nested` with
1042    // `style="--row-depth:N"` on every described hierarchy, and **no stylesheet
1043    // anywhere in the tree read any of it**. So a described outline in a
1044    // browser was a flat list with chevrons in it -- the folding worked and the
1045    // indent did not exist.
1046    //
1047    // The magnitude is a custom property with a fallback, which is this crate's
1048    // own shape: `--awaiting-gap` above is the precedent, and an app overriding
1049    // `--row-indent` is how a level becomes worth more or less. What a level IS
1050    // stays the description's; what it is WORTH is a renderer's, and a terminal
1051    // spending columns for the same fact is not disagreeing.
1052    //
1053    // Here rather than in each app, and that was a live option rather than an
1054    // oversight. `row-select`, `row-current` and `row-chosen` are app-styled by
1055    // design; the indent is not decoration, it is what the description MEANS,
1056    // and three apps agreeing about it by accident is not agreement.
1057    //
1058    // `padding` and not `margin`: a row is a box that can be selected and
1059    // hovered, and indenting with margin would take the indent out of the
1060    // highlight, so the shading under a nested row would start where its text
1061    // does rather than where its row does.
1062    let _ = writeln!(
1063        css,
1064        ".{} {{\n    padding-inline-start: calc(var(--row-depth, 0) * var(--row-indent, 1.5ch));\n}}",
1065        class("row-nested", opts)
1066    );
1067
1068    // The two halves of a branch, emitted by quasi-webview and unstyled until
1069    // now for the same reason.
1070    //
1071    // A branch row is the one a reader can fold, and the chevron is its hit
1072    // target. The chevron is drawn by the app or the description -- this says
1073    // where it sits and how big the target is, which is the accessibility fact
1074    // rather than the decorative one: a control smaller than this is one a
1075    // finger misses.
1076    let _ = writeln!(
1077        css,
1078        ".{} {{\n    display: flex;\n    align-items: baseline;\n    gap: var(--row-disclose-gap, 0.5ch);\n}}",
1079        class("row-branch", opts)
1080    );
1081    let _ = writeln!(
1082        css,
1083        ".{} {{\n    flex: none;\n    min-inline-size: var(--tap-target, 2rem);\n    min-block-size: var(--tap-target, 2rem);\n    background: none;\n    border: 0;\n    color: inherit;\n    cursor: pointer;\n}}",
1084        class("row-disclose", opts)
1085    );
1086
1087    css
1088}
1089
1090/// A row of things that share their space, and what each fallback gets here.
1091///
1092/// Ruling: wiki `layout-room-and-fallback`, Max. Rule 1 is that every described
1093/// member is in flow, and these rules are how that is kept rather than asked
1094/// for. A member taken out of flow with `position: absolute` contributes zero
1095/// width to the row it shares, so nothing can collide with it and nothing
1096/// prevents the collision.
1097///
1098/// # The floor, which is most of the fix
1099///
1100/// `.run > *` gets `min-width: min-content`. That is the derived minimum the
1101/// ruling asks for, in this renderer's own unit and stated by the browser
1102/// rather than by anybody: a member cannot be squeezed narrower than what is
1103/// in it, so members in one flow push each other instead of overlapping. It
1104/// costs no query and no number, and it is what fixes all four measured widths
1105/// whichever fallback the group declared.
1106///
1107/// # What each fallback gets, exactly
1108///
1109/// [`Fallback::Wrap`] is `flex-wrap: wrap`, which is exact. The browser wraps
1110/// the run when the members no longer fit, deciding that from their own
1111/// intrinsic widths, which is the derived minimum doing the whole job.
1112///
1113/// [`Fallback::Stack`] is wrap plus `flex: 1 1 max-content` on the members, so
1114/// a member that cannot sit beside its sibling takes a line of its own and
1115/// fills it. For the two-member run this was ruled on -- a tab strip and a
1116/// band -- that is precisely "a row becomes a column".
1117///
1118/// [`Fallback::Shed`] and [`Fallback::Menu`] get wrap, and this renderer is
1119/// honouring less than the description says. **CSS cannot express either one
1120/// without breaking the ruling's own first constraint.** Both need to know that
1121/// the run is out of room in order to take a member out of it, a container
1122/// query is the only construct that can ask, and `@container` compares against
1123/// a `<length>` -- there is no `@container (inline-size < min-content)`. So
1124/// every honest spelling of Shed here needs an authored breakpoint, which is
1125/// the thing the ruling exists to forbid, and the dishonest ones are worse: a
1126/// clamped height clips by document order rather than by [`Priority`], and
1127/// `display: none` under a viewport `@media` is the `nth-child(n+5)` bug the
1128/// vocabulary replaced.
1129///
1130/// Wrapping is the right thing to do instead. It keeps every member reachable,
1131/// which is the property that was actually broken -- goingson's new-contact
1132/// button left the viewport entirely at 560 -- and it keeps rule 1. A renderer
1133/// answering with less than was described is precedented and deliberate here:
1134/// [`makeover_layout::Region::Columns`] says a terminal stacking a board's
1135/// columns is honouring the description rather than degrading it.
1136///
1137/// The real mechanism needs the shed members to have somewhere to go, which is
1138/// markup and belongs to quasi-webview: an overflow control is a member of the
1139/// run, and the description does not yet say that a member *is* one.
1140///
1141/// # Menu, once a script is measuring
1142///
1143/// That is now built, in `quasi-webview`'s `menu.js`, and this crate's half of
1144/// it is two classes and one override. A script that has taken a menu run over
1145/// marks it `data-menu`, and a marked run goes back to `nowrap`: wrapping is
1146/// what hides the overflow condition the script is trying to measure. The
1147/// unmarked rule above is untouched, so a page that ships no script still
1148/// wraps, which is rule 1 — every member reachable — rather than a strip with
1149/// tabs squeezed off the end.
1150///
1151/// `.run-overflow` is the control the shed members move into and
1152/// `.run-overflow-items` is where they land. The geometry is this crate's the
1153/// way every other surface's is; what is *in* it is the script's, because which
1154/// members no longer fit is a measurement and not a description.
1155fn run_rules(opts: &Emit) -> String {
1156    let run = class("run", opts);
1157    let mut css = String::new();
1158
1159    // `flex-wrap: nowrap` is stated rather than left to the default, because
1160    // the fallbacks below are read as overrides of this line and a reader
1161    // should not have to know which way flexbox leans to see that.
1162    //
1163    // No gap. Spacing between members is the app's, the same way this crate
1164    // states no margins anywhere else; a gap here would be a size, and the one
1165    // hardcoded size in the mechanism is makeover-geometry's contact patch.
1166    let _ = writeln!(
1167        css,
1168        ".{run} {{\n    display: flex;\n    flex-wrap: nowrap;\n    align-items: center;\n}}"
1169    );
1170
1171    // The derived minimum, and the whole reason a member can no longer be
1172    // overlapped. `min-width: auto` is flexbox's default for a flex item and is
1173    // *not* the same thing: auto lets an item be compressed below its content
1174    // in a nowrap run, which is how a toolbar ends up drawn over a tab strip
1175    // even without anything leaving the flow.
1176    let _ = writeln!(css, ".{run} > * {{\n    min-width: min-content;\n}}");
1177
1178    for fallback in [
1179        Fallback::Wrap,
1180        Fallback::Stack,
1181        Fallback::Shed,
1182        Fallback::Menu,
1183    ] {
1184        let name = fallback_class(fallback);
1185        let c = class(name, opts);
1186        let _ = writeln!(css, ".{c} {{\n    flex-wrap: wrap;\n}}");
1187        if matches!(fallback, Fallback::Stack) {
1188            let _ = writeln!(css, ".{c} > * {{\n    flex: 1 1 max-content;\n}}");
1189        }
1190    }
1191
1192    // A menu run a script has taken over. The mark is the script's and this is
1193    // the only rule that reads it: wrapping is what a run does when nothing is
1194    // measuring, and it is also what makes the overflow unmeasurable, since a
1195    // wrapped run always fits. The two cannot both be on.
1196    let menu = class(fallback_class(Fallback::Menu), opts);
1197    let _ = writeln!(css, ".{menu}[data-menu] {{\n    flex-wrap: nowrap;\n}}");
1198
1199    // The overflow control, and it is a member of the run like any other: in
1200    // flow, at the end, taking the width of what is in it. `relative` is what
1201    // the items hang off.
1202    let overflow = class("run-overflow", opts);
1203    let items = class("run-overflow-items", opts);
1204    let _ = writeln!(css, ".{overflow} {{\n    position: relative;\n}}");
1205
1206    // Overlaid rather than in flow, for the reason every menu is: a control
1207    // that pushed the page down when it opened would change the layout it was
1208    // opened to escape. `inset-inline-end: 0` rather than a left, so the panel
1209    // stays on the page in both writing directions.
1210    //
1211    // No width, no padding and no border. All three are sizes and sizes are
1212    // makeover-geometry's; what is stated here is placement, the surface and
1213    // the shadow that separates it from the page, which is the same division
1214    // `figure_rules` and the timeline entry make. The elevation shadow is what
1215    // an overlaid surface takes instead of an edge -- see `ELEVATION_PROPERTY`.
1216    let _ = writeln!(
1217        css,
1218        ".{items} {{\n    \
1219         position: absolute;\n    \
1220         inset-block-start: 100%;\n    \
1221         inset-inline-end: 0;\n    \
1222         z-index: 1;\n    \
1223         display: flex;\n    \
1224         flex-direction: column;\n    \
1225         align-items: stretch;\n    \
1226         background: var(--surface-raised);\n    \
1227         box-shadow: var(--elevation-overlay);\n\
1228         }}"
1229    );
1230
1231    // `hidden` is how the script closes it, and a flex display would otherwise
1232    // beat the attribute's own `display: none`.
1233    let _ = writeln!(css, ".{items}[hidden] {{\n    display: none;\n}}");
1234
1235    css
1236}
1237
1238/// Every class [`fallback_class`] can return, plus the run itself.
1239///
1240/// [`ROW_PART_CLASSES`](crate::list::ROW_PART_CLASSES)'s reasoning and the same
1241/// obligation: a `match` over a `#[non_exhaustive]` enum cannot be enumerated
1242/// from outside, so the list sits beside it and a test holds the two together.
1243/// `run` is in it because it is emitted in its own right rather than only as a
1244/// fallback's fallback.
1245pub const RUN_CLASSES: &[&str] = &[
1246    "run",
1247    "run-wrap",
1248    "run-stack",
1249    "run-shed",
1250    "run-menu",
1251    // Not returned by `fallback_class`: these two are the overflow control a
1252    // measuring renderer builds, and they are in the list because the list is
1253    // what a host seals its vocabulary against. A class emitted by a script and
1254    // missing from here is a control with no surface and no edge.
1255    "run-overflow",
1256    "run-overflow-items",
1257];
1258
1259/// The class a run carries for what it does when it is tight.
1260///
1261/// A run always carries `.run` as well, so an unrecognised fallback -- the enum
1262/// is `#[non_exhaustive]` -- lands as a plain nowrap row with the min-content
1263/// floor still under it. That is the safe failure: every member in flow and
1264/// none overlapped, which is the property, with only the rearrangement missing.
1265#[must_use]
1266pub fn fallback_class(fallback: Fallback) -> &'static str {
1267    match fallback {
1268        Fallback::Wrap => "run-wrap",
1269        Fallback::Stack => "run-stack",
1270        Fallback::Shed => "run-shed",
1271        Fallback::Menu => "run-menu",
1272        _ => "run",
1273    }
1274}
1275
1276/// The progress trough these rules fill.
1277///
1278/// [`meter::meter_html`](crate::meter::meter_html) is what fills these.
1279///
1280/// The rules stay a superset of what a description can ask for. An app drawing
1281/// its own bar keeps these classes, which is what the four goingson grew
1282/// independently were adopted onto.
1283///
1284/// The trough is a [`Depth::Well`], the same reading a text field gets:
1285/// something with its content down inside it.
1286fn progress_rules(opts: &Emit) -> String {
1287    let progress = class("progress", opts);
1288    // `progress-fill` rather than a bare `fill`: an unprefixed build claims
1289    // these names in the app's own stylesheet, and `.fill` is grabby enough to
1290    // catch things that have nothing to do with progress. goingson already
1291    // calls it `.progress-fill`, so this is also the name that deletes.
1292    let fill = class("progress-fill", opts);
1293    let mut css = depth_rule(&progress, Depth::Well);
1294
1295    // The untoned bar is `--action`, not [`Tone::Neutral`]. That is the one
1296    // place this differs from the badge rules, and deliberately: a badge with
1297    // no status is a muted label, while a bar with no status is still
1298    // reporting progress, and `content-muted` would read as disabled.
1299    let _ = writeln!(
1300        css,
1301        ".{progress} > .{fill} {{\n    background: var(--action);\n}}"
1302    );
1303
1304    // A bar can be saying something, same as a badge: goingson colours subtask
1305    // progress as success and an over-estimate as danger, which is real
1306    // information rather than decoration. Emitting the tones is what lets that
1307    // survive adoption instead of staying hand-written.
1308    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1309        let _ = writeln!(
1310            css,
1311            ".{progress} > .{fill}[data-tone=\"{0}\"] {{\n    background: var(--{0});\n}}",
1312            tone.token()
1313        );
1314    }
1315    css
1316}
1317
1318/// What a wait looks like, for the attribute that has been describing one to
1319/// nobody.
1320///
1321/// Wiki `loading-and-progress-standard`, phase 2. `data-awaiting` is emitted
1322/// as `data-awaiting="determinate"` with a `data-awaiting-amount` beside it, or
1323/// `data-awaiting="indeterminate"` alone. This is the half that styles them,
1324/// without which the two render identically.
1325///
1326/// # Why it is keyed on `aria-busy` and not on the attribute alone
1327///
1328/// `data-awaiting` is a fact about the control: pressing this waits. It is true
1329/// when the page is painted and it stays true. Whether a wait is *running* is
1330/// true only between two events, so it is the binder's to set, and `aria-busy`
1331/// is the standard spelling of it — announced as well as drawn, which a class
1332/// of our own would not be.
1333///
1334/// That also keeps this crate out of any one client library's vocabulary.
1335/// `quasi-webview` sets `aria-busy` from htmx's request events; a host driving
1336/// the same markup another way sets it the same way and gets the same drawing.
1337///
1338/// # The two drawings
1339///
1340/// One pseudo-element either way, so no renderer has to emit an extra node.
1341///
1342/// Indeterminate is the activity mark of rule 2: a small square that blinks.
1343/// Determinate is a trough with a fill, drawn as a single gradient whose stop is
1344/// `--awaiting-share`, a plain number from 0 to 1 that the binder sets from
1345/// bytes it has actually watched land. A determinate control with nothing
1346/// setting the share draws an empty trough rather than a full one, which is the
1347/// honest reading: the size is known and the delivery is not.
1348///
1349/// **What the bar may not do**, from rule 1 and from `Awaiting`'s own docs:
1350/// what is done over what there is, and never a remaining time, an arrival time
1351/// or a rate extrapolated forward. Nothing here can express one, which is
1352/// deliberate — the only input is a share of a measured payload.
1353///
1354/// # Sizes, and the deferral rule
1355///
1356/// `progress_rules` emits the tones and never the width, because the width is
1357/// not this crate's to know. A pseudo-element has no intrinsic size at all, so
1358/// the same treatment would render nothing anywhere. Both sizes are therefore
1359/// custom properties with defaults: an app that wants a different mark sets
1360/// `--awaiting-mark` and `--awaiting-bar` once, and one that says nothing gets a
1361/// mark that is visible.
1362///
1363/// # The cadence, and what happens without it
1364///
1365/// `--cadence-activity` comes from `makeover-timing` through `makeover-build`,
1366/// and is a half-period, so a full cycle is twice it. It is used bare rather
1367/// than with a fallback: a number written here would be a second heartbeat for
1368/// a mark three renderers draw.
1369///
1370/// A sheet assembled without the time axis leaves `animation-duration` invalid,
1371/// which resolves to `0s`, which runs no animation and leaves the base style —
1372/// a lit, still mark. That is also exactly what the reduced-motion block does,
1373/// since it sets the cadence to `0ms`. Both fall out of one rule because the
1374/// base style is lit and the keyframes do the dimming, which is the ordering
1375/// `makeover_timing::reduced_motion_css` asks its consumers for by name.
1376fn awaiting_rules(opts: &Emit) -> String {
1377    let mut css = String::new();
1378
1379    // Dimming rather than lighting, so a zero-length animation leaves a lit
1380    // mark rather than a blank one. See `makeover_timing::reduced_motion_css`.
1381    css.push_str(
1382        "@keyframes makeover-activity {\n    \
1383         0%, 49.99% {\n        background: var(--action);\n    }\n    \
1384         50%, 100% {\n        background: var(--surface-sunken);\n    }\n\
1385         }\n",
1386    );
1387
1388    // Nothing is drawn until something is waiting. `content` on the base rule
1389    // rather than on the busy one keeps the box the same box across the
1390    // transition, so a mark appearing does not reflow the line it is in.
1391    let _ = writeln!(
1392        css,
1393        "[data-awaiting]::after {{\n    \
1394         content: \"\";\n    \
1395         display: none;\n    \
1396         margin-inline-start: var(--awaiting-gap, 0.5ch);\n    \
1397         vertical-align: baseline;\n\
1398         }}"
1399    );
1400
1401    let _ = writeln!(
1402        css,
1403        "[data-awaiting][aria-busy=\"true\"]::after {{\n    \
1404         display: inline-block;\n    \
1405         inline-size: var(--awaiting-mark, 0.5em);\n    \
1406         block-size: var(--awaiting-mark, 0.5em);\n    \
1407         background: var(--action);\n    \
1408         opacity: 1;\n    \
1409         animation: makeover-activity calc(var(--cadence-activity) * 2) \
1410         step-end infinite;\n\
1411         }}"
1412    );
1413
1414    // The measured half. A wider box, no blink, and a gradient whose stop is
1415    // the share: the fill and the trough in one paint, so the markup stays one
1416    // pseudo-element on both branches.
1417    //
1418    // `--awaiting-share` unset is an empty trough, not a full one. A bar that
1419    // read full because nobody was counting would be the confidently-wrong
1420    // drawing rule 1 exists to forbid.
1421    //
1422    // The trough takes an edge for the reason a well does: a bar at zero share
1423    // is otherwise a rectangle of the surface it sits on, which is nothing at
1424    // all. `border_width` rather than a literal, the way `depth_rule` and
1425    // `track_rules` write theirs.
1426    let _ = writeln!(
1427        css,
1428        "[data-awaiting=\"determinate\"][aria-busy=\"true\"]::after {{\n    \
1429         inline-size: var(--awaiting-bar, 6em);\n    \
1430         animation: none;\n    \
1431         outline: {} solid var(--border);\n    \
1432         outline-offset: -{};\n    \
1433         background: linear-gradient(\n        \
1434         to inline-end,\n        \
1435         var(--action) 0 calc(var(--awaiting-share, 0) * 100%),\n        \
1436         var(--surface-sunken) 0\n    \
1437         );\n\
1438         }}",
1439        opts.border_width, opts.border_width
1440    );
1441
1442    css
1443}
1444
1445/// A strip of figures, and the two spans inside each one.
1446///
1447/// Colour only, which is the deferral rule applied to a component that badly
1448/// wants to break it. A figure reads as a figure because the value is set large
1449/// over a small caption, and that is a size: `makeover-geometry` answers how
1450/// much space and this crate answers what the thing is. Emitting `font-size`
1451/// here would be this crate naming a value, which is the one thing it is defined
1452/// by not doing, and `progress_rules` is the precedent — it emits the tones and
1453/// never the width, because the width is not its to know.
1454///
1455/// So the arrangement and the type scale are the app's, and what is generated is
1456/// the part an app cannot get right by itself: which of the two spans carries
1457/// the tone.
1458fn figure_rules(opts: &Emit) -> String {
1459    let figure = class("figure", opts);
1460    let value = class("figure-value", opts);
1461    let caption = class("figure-caption", opts);
1462    let change = class("figure-change", opts);
1463    let mut css = String::new();
1464
1465    // `content` literally. A figure's value is the thing itself, at full
1466    // weight -- wiki `three-tone-convention` classes it "active, emphasised",
1467    // and both other renderers already draw it that way (makeover-tui
1468    // `piece.rs:391` bold, makeover-immediate `widget.rs:244`). It reached
1469    // here through `Tone::Neutral.token()` and came out `content-muted`, so
1470    // the headline number sat at the colour of its own caption.
1471    let _ = writeln!(
1472        css,
1473        ".{figure} > .{value} {{\n    color: var(--content);\n}}"
1474    );
1475    let _ = writeln!(
1476        css,
1477        ".{figure} > .{caption} {{\n    color: var(--content-muted);\n}}"
1478    );
1479    let _ = writeln!(
1480        css,
1481        ".{figure} > .{change} {{\n    color: var(--content-muted);\n}}"
1482    );
1483
1484    // A toned figure tones one part and never the caption. The caption is the
1485    // noun and stays muted.
1486    //
1487    // Which part depends on whether there is a change, and that is the whole of
1488    // what 0.13.0 changed here. A figure with a delta is an ordinary number that
1489    // has moved in a direction worth reading, so the delta takes the colour and
1490    // the number stays plain; a figure without one has nowhere else to put it.
1491    // `:has` is what lets one attribute mean both, and the alternative was the
1492    // emitter deciding by writing the attribute onto a different element, which
1493    // leaves two elements able to disagree about a figure's one meaning.
1494    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1495        let _ = writeln!(
1496            css,
1497            ".{figure}[data-tone=\"{0}\"] > .{change} {{\n    color: var(--{0});\n}}",
1498            tone.token()
1499        );
1500        let _ = writeln!(
1501            css,
1502            ".{figure}[data-tone=\"{0}\"]:not(:has(> .{change})) > .{value} \
1503             {{\n    color: var(--{0});\n}}",
1504            tone.token()
1505        );
1506    }
1507    css
1508}
1509
1510/// A picture, its frame and its caption.
1511///
1512/// # The frame is a border, and this is the one place a bevel is wrong
1513///
1514/// Emitting [`Depth::Raised`] here through [`depth_rule`], the same call
1515/// `button` and `card` make, is wrong, and MNW's landing page is where it
1516/// showed: **the frames had no visible edge at all.**
1517///
1518/// `Depth::Raised`'s edge is `--bevel-raised`, which is an *inset* shadow — a
1519/// 1px light run at the top-left and a dark one at the bottom-right, drawn
1520/// **inside** the element's box. On a button or a card that box is a surface
1521/// this crate owns, so an inset edge reads as the surface catching the light.
1522/// On a picture it is drawn on top of the picture, over whatever pixels the
1523/// image happens to have at its border. MNW's screenshots are light-on-light
1524/// parchment, so the light half landed on a light image and the frame
1525/// disappeared.
1526///
1527/// **You cannot bevel a surface you do not own.** A picture's content is the
1528/// app's, arrives at request time, and can be any colour, so its edge has to
1529/// sit *outside* the content rather than on it. That is a border.
1530///
1531/// The fill stays, and it is not decoration: it is what shows through a
1532/// transparent PNG and what stands in the frame's place while the image is
1533/// still loading.
1534///
1535/// This is a real limit on [`Depth`] rather than a special case. Every other
1536/// consumer of a depth draws its own surface; a picture is the first member
1537/// whose surface belongs to someone else.
1538///
1539/// # No size
1540///
1541/// `width: 100%` and nothing else. How large a picture is depends on the box it
1542/// was put in, which is the app's arrangement and `makeover-geometry`'s scales,
1543/// and a renderer that picked one would be answering for every consumer at
1544/// once. This is where `figure_rules` landed for the same reason.
1545fn picture_rules(opts: &Emit) -> String {
1546    let picture = class("picture", opts);
1547    let img = class("picture-img", opts);
1548    let caption = class("picture-caption", opts);
1549    let mut css = String::new();
1550
1551    // Block, or an inline image sits on the text baseline and carries a
1552    // descender's worth of space under it that no app ever wants and every app
1553    // deletes by hand. The border is the frame; see the type docs for why it is
1554    // not the bevel every other surface here gets.
1555    let _ = writeln!(
1556        css,
1557        ".{img} {{\n    display: block;\n    width: 100%;\n    height: auto;\n    \
1558         background: var(--{});\n    border: {} solid var(--border);\n}}",
1559        Fill::Raised.token(),
1560        opts.border_width
1561    );
1562
1563    // The two fits that need a rule. `Fit::Natural` emits no attribute at all,
1564    // so it is the bare rule above and needs nothing here.
1565    let _ = writeln!(
1566        css,
1567        ".{img}[data-fit=\"cover\"] {{\n    height: 100%;\n    object-fit: cover;\n}}"
1568    );
1569    let _ = writeln!(
1570        css,
1571        ".{img}[data-fit=\"contain\"] {{\n    height: 100%;\n    object-fit: contain;\n}}"
1572    );
1573
1574    // A caption reads back one step, which is `figure-caption`'s answer and the
1575    // same claim: it says what the thing above it is, and it is not the thing.
1576    let _ = writeln!(
1577        css,
1578        ".{picture} > .{caption} {{\n    color: var(--content-muted);\n}}"
1579    );
1580
1581    css
1582}
1583
1584/// A region showing one child at a time, and the chrome that moves between them.
1585///
1586/// [`makeover_layout::Showing`] lets a description say that a region holds
1587/// several children and shows some of them. A renderer derives its own chrome
1588/// from that, which is what stops every renderer growing a `match` on a widget
1589/// name; these are the rules the derived chrome needs.
1590///
1591/// # Why the default is every child, and the enhancement takes them away
1592///
1593/// The controls are a lie until something binds them. A prev button rendered
1594/// into a document with no script is a control that looks live and answers
1595/// nothing, and the reader it lies to is exactly the one who cannot see the
1596/// other children either — the collapsing and the moving are the same half.
1597///
1598/// So the rules run in the direction the enhancement does. Nothing here hides a
1599/// child and nothing here shows a control. Whatever binds the region sets
1600/// `data-ready` on it, and that is what collapses the stack to one and reveals
1601/// the row that moves it. A reader with no script gets every child in order and
1602/// no controls, which is more content rather than less, and a reader with
1603/// script gets a settled page rather than a stack that jumps to one frame after
1604/// load.
1605///
1606/// MNW proved this shape by hand — a `<noscript>` stylesheet opening its
1607/// carousel back out — and it is here rather than there because the property is
1608/// the description's, not one app's.
1609///
1610/// # Not spacing
1611///
1612/// The row's gaps are `makeover-geometry`'s question and are absent for
1613/// [`row_rules`]'s reason. What is here is `display`, which carries no
1614/// magnitude, and the muted readout, which is the same claim `picture-caption`
1615/// makes: it says where you are among the children and it is not one of them.
1616fn showing_rules(opts: &Emit) -> String {
1617    let controls = class("showing", opts);
1618    let position = class("showing-position", opts);
1619    let frame = class("showing-frame", opts);
1620    let mut css = String::new();
1621
1622    // Hidden until something binds it, which is the whole argument above.
1623    let _ = writeln!(css, ".{controls} {{\n    display: none;\n}}");
1624    // Block, and nothing about how the three sit in it. A button and a span are
1625    // inline already, so they make a row without this crate saying so, and
1626    // saying so is where `align-items` and a gap would follow -- both spacing,
1627    // both `makeover-geometry`'s, and `row_rules` refuses them for the same
1628    // reason.
1629    let _ = writeln!(
1630        css,
1631        "[data-ready] > .{controls} {{\n    display: block;\n}}"
1632    );
1633
1634    // A child is in flow until the region is bound, and then only the current
1635    // one is. `.current` is a modifier for the reason `.chosen` and `.latched`
1636    // are: one name for the state, set by whoever knows it.
1637    let _ = writeln!(
1638        css,
1639        "[data-ready] > .{frame}:not(.current) {{\n    display: none;\n}}"
1640    );
1641
1642    // Reads back one step. `picture-caption`'s rule and its reason.
1643    let _ = writeln!(css, ".{position} {{\n    color: var(--content-muted);\n}}");
1644
1645    css
1646}
1647
1648/// A time axis: the container, its gridlines and ruler, and the placed things.
1649///
1650/// [`Track`](makeover_layout::Track), the one member here whose whole point is
1651/// *position*. That makes the magnitude line this crate keeps worth restating
1652/// rather than assuming.
1653///
1654/// # Where the numbers come from
1655///
1656/// Every value that varies per item is a custom property the caller sets
1657/// inline, and every rule here reads one. `--track-at` and `--track-for` are
1658/// percentages of the span, which
1659/// [`Track::fraction`](makeover_layout::Track::fraction) computes once so three
1660/// renderers cannot disagree about it. Nothing here knows a pixel.
1661///
1662/// That is what lets the stylesheet stay static while the items move: an entry
1663/// carries `style="--track-at: 37.5%; --track-for: 4.166%"` and the rule below
1664/// turns it into a box. The alternative was emitting a rule per item, which is
1665/// a stylesheet that grows with the data.
1666///
1667/// **The height of the track is the app's**, not this crate's. 96 quarter-hour
1668/// slots at some slot height is a size, and a size is `makeover-geometry`'s
1669/// question -- the same refusal `figure_rules` and `placeholder_rules` make.
1670/// Percentages need a resolved height above them, so `.track` gets
1671/// `position: relative` and nothing else; the app says how tall a day is.
1672///
1673/// # Overlap
1674///
1675/// Two things at the same time is intrinsic to an axis and has no analogue in a
1676/// list. The description does not declare it -- `Placement::overlaps` derives it
1677/// from the times -- so what arrives here is a lane index and a lane count, and
1678/// the rule divides the width. A renderer that would rather stack them ignores
1679/// both properties and the defaults give it one full-width lane.
1680fn track_rules(opts: &Emit) -> String {
1681    let track = class("track", opts);
1682    let slot = class("track-slot", opts);
1683    let tick = class("track-tick", opts);
1684    let entry = class("track-entry", opts);
1685    let mut css = String::new();
1686
1687    // The positioning context every entry resolves against, and the whole of
1688    // what this crate says about the container. No height: see the doc above.
1689    let _ = writeln!(css, ".{track} {{\n    position: relative;\n}}");
1690
1691    // Gridlines and the ruler are the axis reading itself back, which is
1692    // `picture-caption` and `showing-position`'s claim: about the thing rather
1693    // than one of the things.
1694    //
1695    // `border_width` rather than a literal, the way `depth_rule` writes its
1696    // edge. A gridline is the one place a timeline would most naturally reach
1697    // for a hardcoded 1px, and a hardcoded 1px is this crate naming a size.
1698    let _ = writeln!(
1699        css,
1700        ".{slot} {{\n    border-top: {} solid var(--border);\n}}",
1701        opts.border_width
1702    );
1703    let _ = writeln!(css, ".{tick} {{\n    color: var(--content-muted);\n}}");
1704
1705    // The one rule that does real work. Top and height are the placement;
1706    // left and width are the lane. Both lane properties default so an entry
1707    // that names neither is full width, which is the common case and the one a
1708    // renderer gets for free.
1709    let _ = writeln!(
1710        css,
1711        ".{entry} {{\n    \
1712         position: absolute;\n    \
1713         top: var(--track-at, 0%);\n    \
1714         height: var(--track-for, 100%);\n    \
1715         left: calc(var(--track-lane, 0) / var(--track-lanes, 1) * 100%);\n    \
1716         width: calc(100% / var(--track-lanes, 1));\n\
1717         }}"
1718    );
1719
1720    css
1721}
1722
1723/// A region's stand-in, and the header of a table that can be reordered.
1724///
1725/// Both are colour and affordance only, the same place `figure_rules` lands:
1726/// emitting a type scale is not this crate's. How much room
1727/// a stand-in gets is a size — goingson has the same one at three, as
1728/// `--compact`, `--dashboard` and `--padded` — and a size is
1729/// `makeover-geometry`'s question.
1730///
1731/// The caret is the one thing here that is neither colour nor affordance, and it
1732/// is a renderer's own expression rather than a value the description named:
1733/// `aria-sort` is what the table actually says, and this turns it into something
1734/// visible for everyone not using a screen reader. A terminal draws its own; an
1735/// immediate-mode painter draws its own.
1736fn state_rules(opts: &Emit) -> String {
1737    let placeholder = class("placeholder", opts);
1738    let text = class("placeholder-text", opts);
1739    let heading = class("table-heading", opts);
1740    let mut css = String::new();
1741
1742    let _ = writeln!(
1743        css,
1744        ".{placeholder} > .{text} {{\n    color: var(--content-muted);\n}}"
1745    );
1746    // Only the failure is toned. An empty list is the normal state of a new
1747    // install, and `Readiness::tone` is what says so.
1748    let _ = writeln!(
1749        css,
1750        ".{placeholder}[data-tone=\"{0}\"] > .{text} {{\n    color: var(--{0});\n}}",
1751        Tone::Danger.token()
1752    );
1753
1754    // A header that reorders the table is a control, and the pointer is the
1755    // only part of saying so that is not the app's own type and spacing.
1756    let _ = writeln!(
1757        css,
1758        ".{heading}[data-sortable] {{\n    cursor: pointer;\n}}"
1759    );
1760    // The caret carries its own leading space, the way `makeover-tui` and
1761    // `makeover-immediate` both write `" \u{25B2}"`. It used to be emitted bare,
1762    // and both apps that adopted the vocabulary had to put the gap back in their
1763    // own stylesheets on the same afternoon -- each having to work out first that
1764    // app CSS outranks this crate's cascade layer, so adding the space the
1765    // obvious way, as `content`, silently wins over the glyph and leaves the
1766    // heading with no caret at all. A consumer should not have to know that, and
1767    // with the space emitted here there is nothing left for one to add.
1768    //
1769    // Three states, three tones, on the convention in wiki
1770    // `three-tone-convention`. A column in force is `content`; a column offering
1771    // to reorder and not doing it now is `content-secondary`, because it still
1772    // answers a press; a column that is not sortable emits no caret at all and
1773    // takes nothing. The idle arm used to hide its glyph and reserve the box,
1774    // which cost a reflow-free press and said nothing. It draws now, and the
1775    // reservation stops being a thing to get right.
1776    //
1777    // The glyph is `Sort::glyph`, escaped rather than written: a CSS `content`
1778    // string cannot carry the character literally through this file's own
1779    // escaping, and spelling it here as well would put the third copy back that
1780    // `makeover-layout` 0.27.5 exists to remove.
1781    let _ = writeln!(
1782        css,
1783        ".{heading}[data-sortable]::after \
1784         {{\n    content: \" {}\";\n    color: var(--content-secondary);\n}}",
1785        css_escape(Sort::Ascending.glyph())
1786    );
1787    for direction in [Sort::Ascending, Sort::Descending] {
1788        let _ = writeln!(
1789            css,
1790            ".{heading}[aria-sort=\"{}\"]::after \
1791             {{\n    content: \" {}\";\n    color: var(--content);\n}}",
1792            direction.as_str(),
1793            css_escape(direction.glyph())
1794        );
1795    }
1796    css
1797}
1798
1799/// The frame a table sits in.
1800///
1801/// # Why this is a CSS table and not the grid the rest of the module assumes
1802///
1803/// A grid row needs `grid-template-columns`, which has to name every column in
1804/// order, so it cannot be written without knowing the columns. That is what
1805/// [`list::narrowing_css`] is for, and it works: goingson builds `tables.css` in
1806/// its own `build.rs` out of it, and nothing here changes that.
1807///
1808/// It does not work for a table a *description* produced. Those columns are
1809/// known at render time rather than at build time, and the rules would have to
1810/// travel with the markup: a `<style>` element per table, which needs
1811/// `style-src 'unsafe-inline'` and so blocks the MNW server's standing plan to
1812/// drop it. The head is not an escape: a table swapped in by htmx after a
1813/// delete arrives as a fragment with no head at all.
1814///
1815/// A CSS table aligns its columns across rows knowing nothing about how many
1816/// there are, so there is no track list to emit and nothing per-table to carry.
1817/// The cost is that a described table cannot take a per-column fixed length,
1818/// which costs nothing today: [`Sizing`](list::Sizing) is looked up by column
1819/// name and a description carries no lengths to put in it, so every track a
1820/// described table could ask for is already content, fill or auto.
1821///
1822/// [`Priority`] hiding is unchanged in kind. It moves from a generated
1823/// `display: none` per dropped column to one rule per drop class, which is the
1824/// same fact addressed by class rather than by cutoff, and still never by
1825/// position.
1826fn table_rules(opts: &Emit) -> String {
1827    let table = class("table", opts);
1828    let head = class("table-head", opts);
1829    let row = class("table-row", opts);
1830    let heading = class("table-heading", opts);
1831    let cell = class("cell", opts);
1832    let mut css = String::new();
1833
1834    let _ = writeln!(
1835        css,
1836        ".{table} {{\n    display: table;\n    width: 100%;\n}}"
1837    );
1838    let _ = writeln!(css, ".{head},\n.{row} {{\n    display: table-row;\n}}");
1839    // Scoped under `.{table}` rather than keyed on the classes alone. The
1840    // table model is what a cell takes *by being in a table*, and only there:
1841    // goingson's task headings are `.table-heading` inside a CSS grid, so the
1842    // unscoped rule reached them, and the sort caret and the sortable cursor
1843    // -- which `state_rules` keys on `.table-heading` and rightly still does
1844    // -- came with a `display` the grid had to blockify away.
1845    //
1846    // It costs no markup anywhere: quasi-webview always nests the heading
1847    // inside the table (`quasi-webview/src/node.rs:1591`, `:1605`, `:1615`).
1848    let _ = writeln!(
1849        css,
1850        ".{table} .{heading},\n.{table} .{cell} {{\n    display: table-cell;\n}}"
1851    );
1852
1853    // A content column shrinks to what is in it. `width: 1%` is how a CSS table
1854    // is told that: auto layout hands the slack to the columns that asked for
1855    // room, and a column asking for almost none gets what it needs and no more.
1856    // The `nowrap` is what stops it being given less by wrapping.
1857    let _ = writeln!(
1858        css,
1859        ".{} {{\n    white-space: nowrap;\n    width: 1%;\n}}",
1860        class("cell-content", opts)
1861    );
1862
1863    // A fixed column has no length to be fixed to. The description carries none
1864    // and `Sizing` is not reachable from here, so it behaves as content: the
1865    // honest answer to a width nobody supplied, and the same one `Sizing::track`
1866    // gives it.
1867    let _ = writeln!(
1868        css,
1869        ".{} {{\n    white-space: nowrap;\n}}",
1870        class("cell-fixed", opts)
1871    );
1872
1873    // Optional columns go at the narrowest class, secondary ones go with them,
1874    // which is the cutoff walk `kept_at` describes said as two media queries.
1875    // Essential columns have no rule at all, because never dropping is what not
1876    // being mentioned already means.
1877    for (size, drops) in [
1878        (
1879            SizeClass::Compact,
1880            &["cell-drops-first", "cell-drops-next"][..],
1881        ),
1882        (SizeClass::Medium, &["cell-drops-first"][..]),
1883    ] {
1884        let selectors: Vec<String> = drops
1885            .iter()
1886            .map(|drop| format!(".{}", class(drop, opts)))
1887            .collect();
1888        css.push_str(&gated(
1889            Some(&size.media_condition()),
1890            &format!("{} {{\n    display: none;\n}}\n", selectors.join(",\n")),
1891        ));
1892    }
1893
1894    // What is inside a cell, which the table side could not say until
1895    // makeover-layout 0.14.0. Every cell was one `.cell` and one content
1896    // colour, so a button in a cell was painted as text -- the drift
1897    // `RowPart::intent` has prevented for list rows since 0.2.0 and prevented
1898    // for nothing here.
1899    //
1900    // The colour goes on `.cell-value` rather than on `.cell`, and that
1901    // placement is the whole fix. On the container it would cascade into the
1902    // tokens and the controls sitting beside the text, which is the bug said
1903    // in one rule; on the part that is text, it reaches text and stops.
1904    for part in [
1905        CellPart::Value,
1906        CellPart::Tokens,
1907        CellPart::Actions,
1908        CellPart::Link,
1909    ] {
1910        // Three of the four inherit, each for its own reason: a token carries
1911        // its own tone, an action is a control rather than text, and a link
1912        // takes the action colour from the anchor it is. `CellPart::intent`
1913        // says so by answering with the intent inheriting already gives, and
1914        // pinning that would be louder than saying nothing.
1915        //
1916        // Written as a skip-list rather than as a match on Value, so a member
1917        // added upstream gets its intent emitted rather than being silently
1918        // dropped. That is the same trade `part_class`'s fallback makes: land
1919        // plainly, never land as nothing.
1920        if !matches!(part, CellPart::Tokens | CellPart::Actions | CellPart::Link) {
1921            let _ = writeln!(
1922                css,
1923                ".{} {{\n    color: var(--{});\n}}",
1924                class(cell_part_class(part), opts),
1925                part.intent()
1926            );
1927        }
1928    }
1929
1930    css
1931}
1932
1933/// The component layer: every named thing phase A emits.
1934///
1935/// No scrollbar track. It was on the phase A list and came off: eight lines of
1936/// `::-webkit-scrollbar` with no shape a terminal or an immediate-mode painter
1937/// would want handed to it, so it stays with the apps.
1938#[must_use]
1939pub fn component_rules(opts: &Emit) -> String {
1940    let mut css = String::new();
1941    css.push_str(&surface_rules(opts));
1942    css.push_str(&link_rules(opts));
1943    css.push_str(&token_rules(opts));
1944    css.push_str(&selector_rules(opts));
1945    css.push_str(&row_rules(opts));
1946    css.push_str(&run_rules(opts));
1947    css.push_str(&progress_rules(opts));
1948    css.push_str(&awaiting_rules(opts));
1949    css.push_str(&figure_rules(opts));
1950    css.push_str(&picture_rules(opts));
1951    css.push_str(&showing_rules(opts));
1952    css.push_str(&track_rules(opts));
1953    css.push_str(&state_rules(opts));
1954    css.push_str(&table_rules(opts));
1955    css.push_str(&facet::facet_rules(opts));
1956    css.push_str(&form::editor_rules(opts));
1957    css.push_str(&form::suggestion_rules(opts));
1958    css.push_str(&form::unit_rules(opts));
1959    css.push_str(&form::option_detail_rules(opts));
1960    css.push_str(&form::note_rules(opts));
1961    css.push_str(&leaving_rules());
1962    css
1963}
1964
1965/// How a transient notice goes away.
1966///
1967/// `makeover-timing` says that `Intent::Dismiss` is how long a notice lives
1968/// *before it starts to leave*, and that the leaving itself is `Motion::Fade`.
1969/// `makeover-build` writes `--motion-fade` into every consumer's `timing.css`,
1970/// and this is the rule that reads it. Removing the node the moment the dismiss
1971/// is up skips the leaving entirely.
1972///
1973/// # Why an attribute and not a class
1974///
1975/// [`Emit`]'s prefix moves every class this crate writes, so a class here would
1976/// have to be resolved through `class()` by whoever sets it -- and the party
1977/// setting it is a script, which has no prefix to hand. `data-leaving` is
1978/// outside that namespace, so a renderer can set it from JavaScript with no
1979/// coordination.
1980///
1981/// The transition sits on the notice and the opacity on the leaving state, so
1982/// the element is transitionable before the attribute arrives; a transition
1983/// declared in the same rule as the value it changes has nothing to animate
1984/// from.
1985///
1986/// # Reduced motion is handled by the token, not by a second rule here
1987///
1988/// `timing.css` already zeroes `--motion-fade` under `prefers-reduced-motion`.
1989/// The reader who asked for less motion gets an instant change rather than a
1990/// fade, and the renderer that sets the attribute must still remove the node on
1991/// a timer rather than on `transitionend` -- a zero-length transition may fire
1992/// no event at all, and a node waiting on one that never comes stays forever.
1993///
1994/// The fallback is `0ms` and not a guessed duration: a page with no timing
1995/// sheet has not opted into this vocabulary, and the honest answer there is the
1996/// behaviour it had before, which is the notice going away at once.
1997fn leaving_rules() -> String {
1998    let mut css = String::new();
1999    let _ = writeln!(
2000        css,
2001        "[data-notice] {{\n    transition: opacity var(--motion-fade, 0ms) \
2002         ease-out;\n}}"
2003    );
2004    let _ = writeln!(css, "[data-notice][data-leaving] {{\n    opacity: 0;\n}}");
2005    css
2006}
2007
2008/// The whole phase-A stylesheet: properties, depth rules and components, in
2009/// [`CSS_LAYER`], under a generated-file banner.
2010///
2011/// The banner sits outside the layer, because a comment participates in no
2012/// cascade and a reader opening the file should see what it is before seeing
2013/// an at-rule.
2014#[must_use]
2015pub fn stylesheet(opts: &Emit) -> String {
2016    let body = in_css_layer(&format!(
2017        ":root {{\n{}}}\n\n{}\n{}",
2018        bevel_properties(opts),
2019        depth_rules(opts),
2020        component_rules(opts)
2021    ));
2022    // Counted from the body rather than through `vocabulary::names`, which
2023    // calls back into here.
2024    let classes = vocabulary::classes_in_css(&body).len();
2025    let version = VERSION;
2026    format!(
2027        "/* Generated by makeover-webview {version} from makeover-layout, \
2028         {classes} classes.\n   \
2029         Do not edit. The version and the count are here because a stale\n   \
2030         lockfile fails silently: an older emitter writes a well-formed sheet\n   \
2031         with components missing, and nothing else in the file says so. If\n   \
2032         this version trails what the manifest asks for, re-resolve.\n\n   \
2033         Depth is a fill and an edge together; naming them apart is what let\n   \
2034         them disagree. See the crate's README and wiki note makeover-layout.\n\n   \
2035         Everything below is in the `{CSS_LAYER}` cascade layer. Declare the\n   \
2036         order once in your own stylesheet, or this layer's position is decided\n   \
2037         by whichever generated file the browser happens to see first:\n\n   \
2038         @layer {CSS_LAYER}, base, components, responsive; */\n{body}"
2039    )
2040}
2041
2042#[cfg(test)]
2043mod tests {
2044    use super::*;
2045    use makeover_layout::Edge;
2046
2047    #[test]
2048    fn every_fallback_class_is_one_a_checker_knows_about() {
2049        // The obligation ROW_PART_CLASSES carries, for the same reason: a class
2050        // this crate can write and the vocabulary list does not carry is
2051        // invisible to the dead-vocabulary seal and to the overlap check both.
2052        for fallback in [
2053            Fallback::Wrap,
2054            Fallback::Stack,
2055            Fallback::Shed,
2056            Fallback::Menu,
2057        ] {
2058            assert!(
2059                RUN_CLASSES.contains(&fallback_class(fallback)),
2060                "{fallback:?} is missing from RUN_CLASSES"
2061            );
2062        }
2063        let names = crate::vocabulary::names(&Emit::default());
2064        for name in RUN_CLASSES {
2065            assert!(names.contains(*name), "{name} is not in the vocabulary");
2066        }
2067    }
2068
2069    #[test]
2070    fn a_run_gives_every_member_a_floor_it_cannot_be_squeezed_below() {
2071        // The whole of what stops the overlap, and it is not a fallback: it
2072        // applies to every run whatever the group declared. flexbox's default
2073        // min-width is auto, which lets an item be compressed below its own
2074        // content in a nowrap row, and that is how a toolbar is drawn over a
2075        // tab strip even with nothing out of flow.
2076        let css = run_rules(&Emit::default());
2077        assert!(css.contains(".run > * {\n    min-width: min-content;\n}"));
2078        // No number anywhere in it. The minimum is derived by the browser from
2079        // what the members contain, which is the ruling's own requirement.
2080        assert!(!css.contains("px"));
2081        assert!(!css.contains("rem"));
2082        assert!(!css.contains("@media"));
2083    }
2084
2085    #[test]
2086    fn room_is_never_asked_of_the_viewport() {
2087        // The 913 case: a window in SizeClass::Expanded holding a group out of
2088        // room. A viewport query answers about the window and would be wrong
2089        // about the group, which is why the table's @media walk is not the
2090        // precedent this follows.
2091        let css = run_rules(&Emit::default());
2092        for size in [SizeClass::Compact, SizeClass::Medium] {
2093            assert!(!css.contains(&size.media_condition()));
2094        }
2095    }
2096
2097    #[test]
2098    fn every_fallback_lands_as_a_class_and_an_unknown_one_lands_plainly() {
2099        let css = run_rules(&Emit::default());
2100        for fallback in [
2101            Fallback::Wrap,
2102            Fallback::Stack,
2103            Fallback::Shed,
2104            Fallback::Menu,
2105        ] {
2106            let class = fallback_class(fallback);
2107            assert!(css.contains(&format!(".{class} {{")), "{class} unemitted");
2108        }
2109        // Stack is the one that also says what a member does with the line it
2110        // took, which is what separates it from wrapping.
2111        assert!(css.contains(".run-stack > * {\n    flex: 1 1 max-content;\n}"));
2112    }
2113
2114    #[test]
2115    fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
2116        // Balanced Breakfast's styles.css, verbatim. Adoption has to be a
2117        // deletion, not a redesign, or nobody will take it.
2118        let opts = Emit::default();
2119        assert_eq!(
2120            bevel_shadow(Bevel::Raised, &opts),
2121            "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
2122        );
2123        assert_eq!(
2124            bevel_shadow(Bevel::Inset, &opts),
2125            "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
2126        );
2127    }
2128
2129    #[test]
2130    fn no_colour_ever_reaches_the_output() {
2131        let css = stylesheet(&Emit::default());
2132        assert!(!css.contains('#'), "a hex literal escaped into the CSS");
2133        assert!(
2134            !css.contains("rgb"),
2135            "a colour function escaped into the CSS"
2136        );
2137        // Every colour is named, never resolved.
2138        assert!(css.contains("var(--surface-raised)"));
2139        assert!(css.contains("var(--bevel-light)"));
2140    }
2141
2142    #[test]
2143    fn a_well_falls_back_through_css_rather_than_through_rust() {
2144        assert_eq!(
2145            fill_var(Fill::Well),
2146            "var(--surface-well, var(--surface-page))"
2147        );
2148        // Nothing else needs one.
2149        assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
2150        assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
2151    }
2152
2153    #[test]
2154    fn raised_and_well_do_not_collapse_onto_each_other() {
2155        let css = depth_rules(&Emit::default());
2156        assert!(css.contains(".raised {"));
2157        assert!(css.contains(".well {"));
2158        assert!(css.contains("var(--bevel-raised)"));
2159        assert!(css.contains("var(--bevel-inset)"));
2160    }
2161
2162    /// The cast shadow is composed here from the tone `makeover` derives, so
2163    /// neither crate has to hold the other's numbers.
2164    ///
2165    /// It is a `:root` property and deliberately not a depth class. There is no
2166    /// `Depth::Overlay` in the description layer, and adding one would be a
2167    /// claim about what a screen means rather than about how it is painted;
2168    /// until something asks for it, a consumer names the property on the rule
2169    /// for the menu or the toast it already has.
2170    #[test]
2171    fn the_cast_shadow_is_a_root_property_not_a_depth() {
2172        let css = bevel_properties(&Emit::default());
2173        assert!(css.contains("--elevation-overlay:"));
2174        assert!(css.contains("var(--elevation)"));
2175        assert!(
2176            !depth_rules(&Emit::default()).contains("elevation"),
2177            "elevation is not a depth class"
2178        );
2179    }
2180
2181    #[test]
2182    fn the_cascade_carries_the_pressed_state() {
2183        let css = surface_rules(&Emit::default());
2184        // The one thing this renderer gets free that the other two resolve by
2185        // hand, eighteen call sites deep in audiofiles' case. Asserted on a
2186        // named surface: pressing belongs to the control, not to the depth.
2187        assert!(css.contains(".card:active {"));
2188        assert!(css.contains(".button:active {"));
2189    }
2190
2191    #[test]
2192    fn the_depth_class_is_a_surface_and_not_a_control() {
2193        let css = depth_rules(&Emit::default());
2194        // The static surface the vocabulary was missing. Sixteen goingson
2195        // elements wore .card and cancelled its hover and press to get this,
2196        // because a raised object that is not pressable had no other spelling.
2197        for state in [":hover", ":active", ":focus-visible", ":disabled"] {
2198            assert!(
2199                !css.contains(&format!(".raised{state}")),
2200                "the depth class claimed {state}: {css}"
2201            );
2202        }
2203        assert!(css.contains("var(--bevel-raised)"), "still raised: {css}");
2204    }
2205
2206    #[test]
2207    fn pressing_moves_the_fill_and_not_only_the_edge() {
2208        // The decision-1 guard, and the regression that mattered: emitting the
2209        // bevel flip alone is what left goingson hand-writing `background:
2210        // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
2211        // of the three could be deleted.
2212        let pressed = interactive_rules("button", Depth::Raised, &Emit::default());
2213        assert!(pressed.contains(".button:active {"));
2214        assert!(
2215            pressed.contains("background: var(--surface-well, var(--surface-page))"),
2216            "pressed dropped its fill: {pressed}"
2217        );
2218        assert!(pressed.contains("box-shadow: var(--bevel-inset)"));
2219    }
2220
2221    #[test]
2222    fn pressed_takes_its_fill_from_the_description_not_from_the_app() {
2223        // goingson presses to --surface-sunken. The description says a pressed
2224        // raised region reads as a well, and makeover says outright that
2225        // surface-sunken cannot serve as one, so the app is the thing that
2226        // moves.
2227        //
2228        // Scoped to the pressed rules rather than to the whole sheet: since
2229        // makeover-layout 0.3.0 an unchosen tab is legitimately
2230        // --surface-sunken, so the token appearing somewhere in the output no
2231        // longer means the app's choice leaked in.
2232        let css = stylesheet(&Emit::default());
2233        let mut checked = 0;
2234        for rule in css.split("}\n") {
2235            if !rule.contains(":active") {
2236                continue;
2237            }
2238            checked += 1;
2239            assert!(
2240                !rule.contains("surface-sunken"),
2241                "a pressed rule took the app's fill: {rule}"
2242            );
2243        }
2244        assert!(checked > 0, "no pressed rules found to check");
2245        assert_eq!(
2246            Depth::Raised.pressed().fill(),
2247            Some(Fill::Well),
2248            "the description changed under us"
2249        );
2250    }
2251
2252    #[test]
2253    fn the_whole_stylesheet_is_emitted_in_the_family_layer() {
2254        // The point of 0.11.0. Unlayered normal declarations outrank every
2255        // named layer, so an app declaring `@layer base, components` loses
2256        // every rule it owns to this file until this file is layered too.
2257        let css = stylesheet(&Emit::default());
2258        assert!(css.contains(&format!("@layer {CSS_LAYER} {{")));
2259
2260        // Exactly one layer block, and nothing outside it but the banner.
2261        assert_eq!(css.matches("@layer").count(), 2, "banner names it once");
2262        let opened = css.find("@layer makeover {").expect("layer opens");
2263        for (i, line) in css.lines().enumerate() {
2264            let before_layer = css.lines().take(i).map(str::len).sum::<usize>() < opened;
2265            if before_layer || line.is_empty() {
2266                continue;
2267            }
2268            assert!(
2269                line.starts_with("    ") || line == "}" || line.starts_with("   "),
2270                "line outside the layer: {line:?}"
2271            );
2272        }
2273    }
2274
2275    #[test]
2276    fn the_generated_sheet_carries_no_trailing_whitespace() {
2277        // A checked-in generated file that a formatter wants to rewrite is a
2278        // diff every time somebody saves it.
2279        let css = stylesheet(&Emit::default());
2280        for (i, line) in css.lines().enumerate() {
2281            assert_eq!(line, line.trim_end(), "trailing whitespace on line {i}");
2282        }
2283    }
2284
2285    #[test]
2286    fn the_banner_tells_an_app_how_to_order_the_layer() {
2287        // Without a declared order the layer's position depends on which
2288        // generated file the browser sees first, which is not a contract.
2289        let css = stylesheet(&Emit::default());
2290        assert!(css.contains("@layer makeover, base, components, responsive;"));
2291        // And the banner is outside the layer, not a rule inside it.
2292        assert!(css.starts_with("/* Generated by makeover-webview"));
2293    }
2294
2295    #[test]
2296    fn the_banner_names_the_emitter_so_a_stale_pin_is_visible_on_sight() {
2297        // A consumer whose lockfile pins an old version gets a well-formed
2298        // sheet with components missing and no error. balanced_breakfast ran
2299        // on 657 bytes from a 0.1.0 emitter while its manifest asked for
2300        // 0.5.1, and the only way it surfaced was diffing two apps' generated
2301        // files. The version and the count are what the file says instead.
2302        let css = stylesheet(&Emit::default());
2303        let banner = css.lines().next().unwrap();
2304        assert!(
2305            banner.contains(VERSION),
2306            "{banner} does not name the emitter"
2307        );
2308        let classes = vocabulary::classes_in_css(&css).len();
2309        assert!(classes > 0);
2310        assert!(
2311            banner.contains(&format!("{classes} classes")),
2312            "{banner} does not carry the class count"
2313        );
2314    }
2315
2316    #[test]
2317    fn a_primitive_owns_every_state_it_implies() {
2318        // The whole point of 0.10.0. Anything emitting a hover rule owes the
2319        // other three, or the consuming app supplies them by out-specifying a
2320        // rule it does not own: 19 such rules in goingson, 21 in the MNW
2321        // server, and three focus rings that do not match.
2322        let css = stylesheet(&Emit::default());
2323        for selector in ["button", "card", "chip", "tab", "segment", "toggle"] {
2324            assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}");
2325            assert!(
2326                css.contains(&format!(".{selector}:active {{")),
2327                "{selector}"
2328            );
2329            assert!(
2330                css.contains(&format!(".{selector}:focus-visible {{")),
2331                "{selector} has no focus ring"
2332            );
2333            assert!(
2334                css.contains(&format!(".{selector}:disabled,")),
2335                "{selector} has no disabled state"
2336            );
2337        }
2338    }
2339
2340    #[test]
2341    fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() {
2342        // A text field does not light up under the pointer, so it gets the two
2343        // states it has and not the two it does not.
2344        let css = stylesheet(&Emit::default());
2345        assert!(css.contains(".field:focus-visible {"));
2346        assert!(css.contains(".field:disabled,"));
2347        assert!(!css.contains(".field:hover {"));
2348        assert!(!css.contains(".field:active {"));
2349    }
2350
2351    #[test]
2352    fn disabled_is_emitted_after_hover_so_source_order_settles_it() {
2353        // Every one of these selectors is specificity (0,2,0), so nothing but
2354        // order decides which wins. A disabled button taking the hover fill is
2355        // the exact bug goingson's `.button:disabled:hover` was written to fix,
2356        // and the reason it had to reach (0,3,0) to do it.
2357        let css = interactive_rules("button", Depth::Raised, &Emit::default());
2358        let hover = css.find(":hover").expect("hover");
2359        let active = css.find(":active").expect("active");
2360        let focus = css.find(":focus-visible").expect("focus");
2361        let disabled = css.find(":disabled").expect("disabled");
2362        assert!(hover < active && active < focus && focus < disabled);
2363
2364        // And it restores the surface, or the hover fill survives underneath.
2365        let tail = &css[disabled..];
2366        assert!(tail.contains("background: var(--surface-raised)"));
2367    }
2368
2369    #[test]
2370    fn a_flat_control_takes_its_hover_fill_back_when_it_stops_answering() {
2371        // The same contest one depth over, and the half `depth_declarations`
2372        // could not state. Flat declares neither axis, so before 0.68.0 the
2373        // disabled rule won on source order with nothing to say and the hover
2374        // surface stayed under a control that had stopped answering. Reaches
2375        // both facet arms and a suggestion entry.
2376        for depth in [Depth::Flat, Depth::Sunken, Depth::Overlay] {
2377            let css = disabled_rule("x", depth);
2378            assert!(css.contains("box-shadow"), "{depth:?}: {css}");
2379        }
2380        let flat = disabled_rule("x", Depth::Flat);
2381        assert!(flat.contains("background: none;"), "{flat}");
2382        assert!(flat.contains("box-shadow: none;"), "{flat}");
2383
2384        // Every rule the caller emits states both axes now, so whichever wins
2385        // the source-order contest leaves nothing of the one below it.
2386        let css = interactive_rules("x", Depth::Flat, &Emit::default());
2387        let disabled = css.find(":disabled").expect("disabled");
2388        assert!(css[disabled..].contains("background: none;"), "{css}");
2389    }
2390
2391    #[test]
2392    fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
2393        // `:disabled` matches form elements only, and a chip is a div. Keying
2394        // on the ARIA attribute too is the pattern the invalid field already
2395        // set: one fact, read by the styling and the accessibility tree alike.
2396        let css = disabled_rule("chip", Depth::Raised);
2397        assert!(css.contains(".chip:disabled,"));
2398        assert!(css.contains(".chip[aria-disabled=\"true\"]"));
2399        assert!(css.contains("cursor: not-allowed"));
2400    }
2401
2402    #[test]
2403    fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() {
2404        // `outline` has its own property, so unlike the invalid ring there is
2405        // no bevel to restate beside it and nothing to keep in agreement.
2406        let opts = Emit::default();
2407        let css = focus_rule("button", Depth::Raised, &opts);
2408        assert!(css.contains("outline: 2px solid var(--focus-ring)"));
2409        assert!(!css.contains("box-shadow"), "the ring restated the bevel");
2410    }
2411
2412    #[test]
2413    fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() {
2414        // One ring, placed by depth. The offset comes off `Depth::bevel` and
2415        // not off a per-component choice, which is what gave three apps three
2416        // different rings.
2417        let opts = Emit::default();
2418        assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)"));
2419        assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px"));
2420        // Nothing to sit inside of, so it sits outside.
2421        assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px"));
2422
2423        // And the ring is not the bevel. Reusing border_width emitted a 1px
2424        // ring that every consumer had already overridden.
2425        assert_ne!(opts.focus_width, opts.border_width);
2426    }
2427
2428    #[test]
2429    fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() {
2430        // goingson's section 60 exists only to take back the hover state this
2431        // crate handed it. Gating at the source is what deletes that section
2432        // in all three apps rather than having each fight for it.
2433        let css = stylesheet(&Emit::default());
2434        let condition = format!("@media {}", Density::Pointer.media_condition());
2435        assert!(css.contains(&condition));
2436
2437        // What is gated is every hover state the surfaces carry. The row's
2438        // actions used to be the other half of this test and are not gated any
2439        // more, because they are not hidden any more: a rule that reveals
2440        // nothing needs no capability answer.
2441        let gated: Vec<&str> = css.lines().filter(|line| line.contains(":hover")).collect();
2442        assert!(!gated.is_empty(), "{css}");
2443        for line in gated {
2444            let indent = line.len() - line.trim_start().len();
2445            assert!(indent > 4, "an ungated hover rule: {line}");
2446        }
2447        assert!(!css.contains(".row:hover"), "{css}");
2448    }
2449
2450    #[test]
2451    fn the_capability_answer_is_asked_for_and_not_assumed() {
2452        // Both halves come from the crates that own them. If `makeover-touch`
2453        // ever says a fingertip has hover, this stops gating on its own.
2454        assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact));
2455        assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact));
2456        assert_eq!(hover_condition(), Some(Density::Pointer.media_condition()));
2457
2458        // And the size class passed to that call is not a claim about width.
2459        assert!(Affordance::Hover.reads_density());
2460        for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] {
2461            assert!(!Affordance::Hover.available(Density::Touch, size));
2462        }
2463    }
2464
2465    #[test]
2466    fn hover_resolves_against_the_token_makeover_already_derives() {
2467        let css = interactive_rules("card", Depth::Raised, &Emit::default());
2468        assert!(css.contains(".card:hover {"));
2469        assert!(css.contains("background: var(--hover-surface)"));
2470        // Not the app's choice, which was --surface-overlay.
2471        assert!(!css.contains("surface-overlay"));
2472    }
2473
2474    #[test]
2475    fn a_badge_gets_no_edge_and_no_fill() {
2476        // Decision 2, and the one visible redesign in phase A. Token::Badge is
2477        // Flat: an edge on a label says it can be pressed.
2478        let css = token_rules(&Emit::default());
2479        let badge = css
2480            .lines()
2481            .skip_while(|l| !l.starts_with(".badge {"))
2482            .take_while(|l| !l.starts_with('}'))
2483            .collect::<Vec<_>>()
2484            .join("\n");
2485        assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}");
2486        assert!(!badge.contains("background"), "badge kept a fill: {badge}");
2487        assert_eq!(Token::Badge.depth(false), Depth::Flat);
2488        assert_eq!(Token::Badge.depth(true), Depth::Flat);
2489    }
2490
2491    #[test]
2492    fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() {
2493        let css = token_rules(&Emit::default());
2494        // Neutral is the absence of a status, not a status named "none".
2495        assert!(css.contains(".badge {\n    color: var(--content-muted);"));
2496        assert!(!css.contains("data-tone=\"content-muted\""));
2497        for tone in ["info", "success", "warning", "danger"] {
2498            assert!(
2499                css.contains(&format!(".badge[data-tone=\"{tone}\"]")),
2500                "missing tone {tone}"
2501            );
2502            assert!(css.contains(&format!("color: var(--{tone})")));
2503        }
2504    }
2505
2506    #[test]
2507    fn a_chip_is_raised_and_latches_into_a_well() {
2508        let css = token_rules(&Emit::default());
2509        assert!(css.contains(".chip {"));
2510        assert!(css.contains(".chip.latched {"));
2511        assert!(css.contains(".chip:active {"));
2512        // The whole difference from a badge: it answers a click.
2513        assert!(Token::Chip { removable: false }.interactive());
2514        assert!(!Token::Badge.interactive());
2515    }
2516
2517    #[test]
2518    fn only_a_tab_comes_forward_when_chosen() {
2519        // The folder semantic. Collapsing the three selectors would lose it.
2520        let css = selector_rules(&Emit::default());
2521        assert!(css.contains(".tab.chosen {"));
2522        assert!(css.contains(".segment.chosen {"));
2523        assert!(css.contains(".toggle.chosen {"));
2524        assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
2525        assert_eq!(Selector::Segmented.chosen(), Depth::Well);
2526        assert_eq!(Selector::Toggle.chosen(), Depth::Well);
2527
2528        let tab = css
2529            .lines()
2530            .skip_while(|l| !l.starts_with(".tab.chosen {"))
2531            .take_while(|l| !l.starts_with('}'))
2532            .collect::<Vec<_>>()
2533            .join("\n");
2534        assert!(
2535            tab.contains("var(--bevel-raised)"),
2536            "tab was held in: {tab}"
2537        );
2538    }
2539
2540    #[test]
2541    fn an_unchosen_tab_recedes_without_looking_picked() {
2542        let css = selector_rules(&Emit::default());
2543        // Recessed by colour and given no edge. An edge would make every option
2544        // look picked; flat would leave the chosen one nothing to come forward
2545        // from, which is the gap makeover-layout 0.3.0 closed.
2546        assert!(
2547            css.contains(".tab {\n    background: var(--surface-sunken);\n}"),
2548            "unchosen tab is not recessed: {css}"
2549        );
2550        assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken);
2551        assert!(css.contains(".tab:hover {"));
2552    }
2553
2554    #[test]
2555    fn a_segment_stands_up_so_the_chosen_one_can_be_held_in() {
2556        // The inverse of the tab, and why the three selectors are not one
2557        // member with a flag.
2558        let css = selector_rules(&Emit::default());
2559        assert!(css.contains(".segment {\n    background: var(--surface-raised);"));
2560        assert_eq!(Selector::Segmented.unchosen(), Depth::Raised);
2561        assert_eq!(Selector::Segmented.chosen(), Depth::Well);
2562    }
2563
2564    #[test]
2565    fn a_rows_actions_are_shown_at_rest() {
2566        let css = row_rules(&Emit::default());
2567
2568        // The hover reveal is gone, and with it every escape it needed. What
2569        // it hid was hidden from pointer users alone, who are the ones
2570        // scanning a list to learn what can be done to a row.
2571        assert!(!css.contains("opacity"), "{css}");
2572        assert!(!css.contains("pointer-events"), "{css}");
2573        assert!(!css.contains(":hover"), "{css}");
2574        assert!(!css.contains(":focus-within"), "{css}");
2575
2576        // Nor is it hidden any other way. `display: none` would reflow the row
2577        // and `visibility: hidden` would take the actions out of the focus
2578        // order; the point is that neither is reached for.
2579        assert!(!css.contains("display: none"), "{css}");
2580        assert!(!css.contains("visibility:"), "{css}");
2581    }
2582
2583    #[test]
2584    fn a_figures_tone_lands_on_the_delta_when_there_is_one() {
2585        // 0.13.0. The delta is the part that reads as good or bad; the number
2586        // itself is an ordinary fact. A figure with no delta has nowhere else to
2587        // put the colour, so the value takes it, and `:has` is what lets one
2588        // attribute mean both without the emitter choosing an element.
2589        let css = stylesheet(&Emit::default());
2590
2591        assert!(
2592            css.contains(".figure[data-tone=\"success\"] > .figure-change"),
2593            "{css}"
2594        );
2595        assert!(
2596            css.contains(
2597                ".figure[data-tone=\"success\"]:not(:has(> .figure-change)) > .figure-value"
2598            ),
2599            "{css}"
2600        );
2601        // The caption is the noun and never takes the tone.
2602        assert!(
2603            !css.contains("[data-tone=\"success\"] > .figure-caption"),
2604            "{css}"
2605        );
2606    }
2607
2608    #[test]
2609    fn the_three_text_parts_take_their_intents_and_actions_inherits() {
2610        let css = row_rules(&Emit::default());
2611        assert!(css.contains(".row-primary {\n    color: var(--content);"));
2612        assert!(css.contains(".row-secondary {\n    color: var(--content-secondary);"));
2613        assert!(css.contains(".row-meta {\n    color: var(--content-muted);"));
2614        // Actions carry controls, not text. Pinning the colour it would inherit
2615        // anyway is louder than saying nothing.
2616        assert!(!css.contains(".row-actions {\n    color:"));
2617    }
2618
2619    #[test]
2620    fn the_token_strip_takes_no_colour_of_its_own() {
2621        // makeover-layout 0.9.0. A token carries its own tone, so a colour on
2622        // the strip would be a rule fighting the things sitting in it -- the
2623        // same reasoning as actions, reached for a different reason.
2624        let css = row_rules(&Emit::default());
2625        assert!(!css.contains(".row-tokens {\n    color:"));
2626    }
2627
2628    #[test]
2629    fn an_unknown_row_part_renders_plainly_rather_than_failing_to_build() {
2630        // What `#[non_exhaustive]` bought and what it cost. `part_class` can no
2631        // longer be exhaustive, so a member added upstream lands as a bare
2632        // class with no rule instead of stopping the build. Asserting the
2633        // fallback exists is what keeps it from being written as `unreachable!`
2634        // by someone who reads the match as closed.
2635        assert_eq!(part_class(RowPart::Tokens), "row-tokens");
2636        assert_eq!(part_class(RowPart::Meta), "row-meta");
2637    }
2638
2639    #[test]
2640    fn a_link_takes_the_action_colour_the_theme_actually_defines() {
2641        // `--action-primary` shipped here for months and no theme has ever
2642        // defined it, so every `.link` dropped its colour declaration outright
2643        // and fell back to inherited text. Nothing caught it because the sheet
2644        // is valid CSS either way; MNW's no-undefined-token lint is what found
2645        // it, 2026-08-14. The hover arm two lines below was always `--action-hover`,
2646        // which is what makes the typo legible in hindsight.
2647        let css = link_rules(&Emit::default());
2648        assert!(css.contains("color: var(--action);"));
2649        assert!(!css.contains("--action-primary"));
2650        assert!(css.contains("color: var(--action-hover);"));
2651    }
2652
2653    #[test]
2654    fn the_progress_trough_is_a_well() {
2655        let css = progress_rules(&Emit::default());
2656        assert!(css.contains(".progress {"));
2657        assert!(css.contains("box-shadow: var(--bevel-inset)"));
2658        assert!(css.contains(".progress > .progress-fill {"));
2659        assert!(css.contains("background: var(--action)"));
2660        // A bare `.fill` would catch things that have nothing to do with
2661        // progress once the sheet lands unprefixed.
2662        assert!(!css.contains("> .fill "));
2663    }
2664
2665    #[test]
2666    fn a_progress_bar_can_carry_a_tone_and_defaults_to_action() {
2667        let css = progress_rules(&Emit::default());
2668        // Untoned is --action, not Tone::Neutral's content-muted: a bar with no
2669        // status is still reporting progress, and muted would read as disabled.
2670        assert!(css.contains(".progress > .progress-fill {\n    background: var(--action);"));
2671        assert!(!css.contains("progress-fill {\n    color: var(--content-muted)"));
2672        for tone in ["info", "success", "warning", "danger"] {
2673            assert!(
2674                css.contains(&format!(".progress > .progress-fill[data-tone=\"{tone}\"]")),
2675                "missing progress tone {tone}"
2676            );
2677        }
2678        // goingson's two live cases, which is why the tones are emitted at all.
2679        assert!(css.contains("[data-tone=\"success\"] {\n    background: var(--success);"));
2680        assert!(css.contains("[data-tone=\"danger\"] {\n    background: var(--danger);"));
2681    }
2682
2683    #[test]
2684    fn no_scrollbar_track_is_emitted() {
2685        // Decision 3's negative half. It was on the phase A list and came off;
2686        // this is what stops it drifting back in.
2687        let css = stylesheet(&Emit::default());
2688        assert!(!css.contains("scrollbar"));
2689        assert!(!css.contains("::-webkit"));
2690    }
2691
2692    #[test]
2693    fn an_invalid_field_is_ringed_without_being_lit() {
2694        let css = surface_rules(&Emit::default());
2695        assert!(css.contains(".field {"));
2696        // The ARIA attribute, not a class: one fact, read by both the visual
2697        // and the accessible state, so they cannot drift.
2698        assert!(css.contains(".field[aria-invalid=\"true\"] {"));
2699        assert!(!css.contains(".field.invalid"));
2700        // A flat ring: this edge says "wrong", and a two-tone bevel would have
2701        // it say "raised" at the same time.
2702        assert!(css.contains("0 0 0 1px var(--danger)"));
2703    }
2704
2705    #[test]
2706    fn an_invalid_field_keeps_the_well_underneath_it() {
2707        // box-shadow is not additive. A lone ring replaces the bevel and drops
2708        // the well out from under the field, which is what this emitted before
2709        // 0.5.0 and is the whole reason the rule composes.
2710        let css = surface_rules(&Emit::default());
2711        let invalid = css
2712            .lines()
2713            .skip_while(|l| !l.starts_with(".field[aria-invalid"))
2714            .take_while(|l| !l.starts_with('}'))
2715            .collect::<Vec<_>>()
2716            .join("\n");
2717        assert!(
2718            invalid.contains("var(--bevel-inset)"),
2719            "the well was dropped: {invalid}"
2720        );
2721        assert!(invalid.contains("var(--danger)"));
2722    }
2723
2724    #[test]
2725    fn button_and_card_come_out_identical_by_construction() {
2726        // The duplication phase A deletes. They are the same composition, so
2727        // the only honest way to emit both is from one call.
2728        let opts = Emit::default();
2729        let css = surface_rules(&opts);
2730        assert_eq!(
2731            depth_declarations(Depth::Raised),
2732            depth_declarations(Depth::Raised)
2733        );
2734        assert!(css.contains(".button {"));
2735        assert!(css.contains(".card {"));
2736        assert_eq!(
2737            interactive_rules("button", Depth::Raised, &Emit::default()).replace("button", "card"),
2738            interactive_rules("card", Depth::Raised, &Emit::default())
2739        );
2740    }
2741
2742    #[test]
2743    fn a_prefix_reaches_the_component_classes_too() {
2744        let opts = Emit {
2745            class_prefix: "mo-",
2746            ..Emit::default()
2747        };
2748        let css = stylesheet(&opts);
2749        for name in [
2750            "mo-button",
2751            "mo-card",
2752            "mo-field",
2753            "mo-badge",
2754            "mo-chip",
2755            "mo-tab",
2756            "mo-row-primary",
2757            "mo-progress",
2758            "mo-progress-fill",
2759        ] {
2760            assert!(css.contains(&format!(".{name}")), "unprefixed: {name}");
2761        }
2762        // The bare names must be gone entirely, or a prefixed build still
2763        // collides with the app's own stylesheet.
2764        assert!(!css.contains(".button {"));
2765        assert!(!css.contains(".card {"));
2766        assert!(!css.contains(".badge {"));
2767    }
2768
2769    #[test]
2770    fn the_class_a_renderer_puts_on_an_option_is_the_one_the_rules_key_off() {
2771        // `option_class` is the contract a screen renderer writes markup
2772        // against, and the rules below are the other half of it. They come off
2773        // one mapping now, so this asserts the mapping is the one that reaches
2774        // the stylesheet rather than that two lists still agree.
2775        let css = stylesheet(&Emit::default());
2776        for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
2777            let name = option_class(selector);
2778            assert!(css.contains(&format!(".{name} {{")), "{name}: {css}");
2779            assert!(css.contains(&format!(".{name}.chosen {{")), "{name}: {css}");
2780        }
2781        assert_eq!(option_class(Selector::Tabs), "tab");
2782    }
2783
2784    #[test]
2785    fn the_caret_brings_its_own_gap_and_is_the_glyph_the_description_names() {
2786        let css = stylesheet(&Emit::default());
2787
2788        // The space is inside the glyph, which is what the other two renderers
2789        // write. Emitted bare, every consumer has to add it back, and the
2790        // obvious way to add it -- `content` in an app stylesheet, which is
2791        // unlayered and so outranks this sheet -- deletes the caret instead.
2792        assert!(css.contains("content: \" \\25B2\";"), "{css}");
2793        assert!(css.contains("content: \" \\25BC\";"), "{css}");
2794        assert!(!css.contains("content: \"\\2"), "{css}");
2795
2796        // The arrows this renderer used to draw alone are gone. Composition
2797        // rather than agreement: the glyph comes from `Sort::glyph`, so a
2798        // fourth spelling cannot appear here without appearing everywhere.
2799        assert!(!css.contains("2191") && !css.contains("2193"), "{css}");
2800        assert!(css.contains(&css_escape(Sort::Ascending.glyph())), "{css}");
2801
2802        // Three states, three tones. An idle sortable heading draws its caret
2803        // now rather than reserving a hidden box for it, so there is no
2804        // visibility to order and no reflow left to guard against; what
2805        // separates the states is the colour, and the sorted arms come after
2806        // the idle one because the specificity is the same.
2807        let idle = css
2808            .find(".table-heading[data-sortable]::after")
2809            .expect("the idle caret is emitted");
2810        let sorted = css
2811            .find(".table-heading[aria-sort=\"ascending\"]::after")
2812            .expect("the ascending caret is emitted");
2813        assert!(idle < sorted, "{css}");
2814        assert!(
2815            css[idle..sorted].contains("color: var(--content-secondary);"),
2816            "{css}"
2817        );
2818        assert!(css[sorted..].contains("color: var(--content);"), "{css}");
2819        assert!(!css.contains("visibility: hidden;"), "{css}");
2820    }
2821
2822    #[test]
2823    fn a_destructive_button_has_somewhere_for_its_tone_to_land() {
2824        let css = component_rules(&Emit::default());
2825
2826        for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
2827            assert!(
2828                css.contains(&format!(".button[data-tone=\"{}\"]", tone.token())),
2829                "{css}"
2830            );
2831        }
2832        // Colour, not a fill. A red surface is an app's decision about emphasis.
2833        assert!(!css.contains(".button[data-tone=\"danger\"] {\n    background"));
2834    }
2835
2836    #[test]
2837    fn the_table_model_is_scoped_to_the_table_but_the_caret_is_not() {
2838        // goingson's task headings are `.table-heading` inside a CSS grid.
2839        // They want the sort caret and the sortable cursor; they do not want
2840        // `display: table-cell`, which a grid item blockifies away anyway.
2841        // Scoping the one and not the other is what separates them.
2842        let css = component_rules(&Emit::default());
2843
2844        assert!(
2845            css.contains(".table .table-heading,\n.table .cell {\n    display: table-cell;"),
2846            "{css}"
2847        );
2848        assert!(
2849            !css.contains(".table-heading,\n.cell {\n    display: table-cell;"),
2850            "the table model is still unscoped: {css}"
2851        );
2852
2853        let states = state_rules(&Emit::default());
2854        assert!(
2855            states.contains(".table-heading[aria-sort"),
2856            "the caret got scoped along with the model: {states}"
2857        );
2858    }
2859
2860    #[test]
2861    fn a_figure_value_is_the_thing_itself_and_a_badge_is_quiet() {
2862        // Both used to read `Tone::Neutral.token()`, which answered
2863        // `content-muted`, so the headline number sat at the colour of its own
2864        // caption. Neutral answers `content` now; each site states its own
2865        // claim rather than borrowing one from the status axis.
2866        let css = component_rules(&Emit::default());
2867
2868        assert!(
2869            css.contains(".figure > .figure-value {\n    color: var(--content);"),
2870            "{css}"
2871        );
2872        assert!(
2873            css.contains(".figure > .figure-caption {\n    color: var(--content-muted);"),
2874            "{css}"
2875        );
2876        assert!(
2877            css.contains(".badge {\n    color: var(--content-muted);"),
2878            "{css}"
2879        );
2880    }
2881
2882    #[test]
2883    fn a_described_list_is_not_a_bulleted_list() {
2884        let css = component_rules(&Emit::default());
2885        assert!(css.contains(".list {\n    list-style: none;"), "{css}");
2886    }
2887
2888    #[test]
2889    fn a_table_lays_itself_out_without_being_told_its_columns() {
2890        // The whole point of the CSS table. A described table's columns are
2891        // known at render time, so anything the stylesheet has to be told about
2892        // them would have to travel with the markup.
2893        let css = component_rules(&Emit::default());
2894
2895        assert!(css.contains(".table {\n    display: table;"), "{css}");
2896        assert!(css.contains("display: table-row;"), "{css}");
2897        assert!(css.contains("display: table-cell;"), "{css}");
2898        assert!(!css.contains("grid-template-columns"), "{css}");
2899    }
2900
2901    #[test]
2902    fn a_control_in_a_cell_is_not_painted_as_text() {
2903        // The point of makeover-layout 0.14.0's CellPart, and the table-side
2904        // twin of `the_three_text_parts_take_their_intents_and_actions_inherits`
2905        // above. One `.cell` and one content colour meant a button in a cell
2906        // inherited it.
2907        let css = table_rules(&Emit::default());
2908
2909        assert!(
2910            css.contains(".cell-value {\n    color: var(--content);"),
2911            "{css}"
2912        );
2913        assert!(!css.contains(".cell-actions {\n    color:"), "{css}");
2914        assert!(!css.contains(".cell-tokens {\n    color:"), "{css}");
2915        assert!(!css.contains(".cell-link {\n    color:"), "{css}");
2916
2917        // The colour is on the part that is text, never on the container. On
2918        // `.cell` it would cascade into the three parts that are not text,
2919        // which is the bug written as one rule.
2920        assert!(!css.contains(".cell {\n    color:"), "{css}");
2921    }
2922
2923    #[test]
2924    fn an_unknown_cell_part_renders_plainly_rather_than_failing_to_build() {
2925        // `part_class`'s obligation, taken on for the table side too. CellPart
2926        // is `#[non_exhaustive]`, so a member added upstream must land as a
2927        // bare class rather than as a build that stops.
2928        assert_eq!(cell_part_class(CellPart::Value), "cell-value");
2929        assert_eq!(cell_part_class(CellPart::Actions), "cell-actions");
2930    }
2931
2932    #[test]
2933    fn a_column_drops_by_its_priority_and_never_by_its_position() {
2934        let css = component_rules(&Emit::default());
2935
2936        // Optional goes at the narrowest class and secondary goes with it,
2937        // which is `kept_at`'s cutoff walk said as two queries.
2938        let compact = css
2939            .find(&format!("@media {}", SizeClass::Compact.media_condition()))
2940            .expect("a compact query");
2941        let medium = css
2942            .find(&format!("@media {}", SizeClass::Medium.media_condition()))
2943            .expect("a medium query");
2944        assert!(css[compact..].contains(".cell-drops-next"), "{css}");
2945        assert!(!css[medium..].contains(".cell-drops-next"), "{css}");
2946
2947        // Essential columns are never mentioned, because not being mentioned is
2948        // already what never dropping means.
2949        assert!(!css.contains(".cell-keeps"), "{css}");
2950
2951        // And nothing counts. `nth-child` is the bug the priority vocabulary
2952        // exists to end.
2953        assert!(!css.contains("nth-child"), "{css}");
2954    }
2955
2956    #[test]
2957    fn a_track_places_by_custom_property_and_never_by_a_size() {
2958        let css = track_rules(&Emit::default());
2959
2960        // Placement arrives from the caller, computed once by Track::fraction.
2961        // If either of these becomes a literal, three renderers have started
2962        // disagreeing about where 09:30 is.
2963        assert!(css.contains("top: var(--track-at"), "{css}");
2964        assert!(css.contains("height: var(--track-for"), "{css}");
2965
2966        // Overlap lanes default so an entry naming neither is full width.
2967        assert!(css.contains("--track-lane, 0"), "{css}");
2968        assert!(css.contains("--track-lanes, 1"), "{css}");
2969
2970        // The refusal that matters. A slot height here would be this crate
2971        // deciding how tall a quarter of an hour is, which is the thing
2972        // makeover-geometry owns and the reason `.track` gets no height at all.
2973        assert!(!css.contains("height: var(--track-slot"), "{css}");
2974        for size in ["px", "rem", "em", "vh"] {
2975            let bare = css
2976                .lines()
2977                .filter(|l| !l.contains("var(--"))
2978                .any(|l| l.contains(size));
2979            assert!(!bare, "track_rules named a {size} outside a var(): {css}");
2980        }
2981    }
2982
2983    #[test]
2984    fn a_relaxed_part_clamps_and_a_tight_one_says_nothing() {
2985        let css = stylesheet(&Emit::default());
2986        assert!(css.contains(".row-relaxed {"));
2987    }
2988
2989    /// The done condition: a row carrying a level indents in a browser with no
2990    /// app-authored CSS.
2991    ///
2992    /// quasi-webview emits `row-nested` with `--row-depth` on every described
2993    /// hierarchy. Without a rule reading it, a described outline is a flat list
2994    /// with chevrons in it.
2995    #[test]
2996    fn a_nested_row_indents_by_its_level_and_an_app_can_say_what_a_level_is_worth() {
2997        let css = stylesheet(&Emit::default());
2998
2999        assert!(css.contains(".row-nested {"), "{css}");
3000        assert!(
3001            css.contains(
3002                "padding-inline-start: calc(var(--row-depth, 0) * var(--row-indent, 1.5ch));"
3003            ),
3004            "{css}"
3005        );
3006        // The magnitude is a custom property with a fallback, which is
3007        // `--awaiting-gap`'s shape: what a level IS stays the description's and
3008        // what it is WORTH is this renderer's, overridable by an app. Padding
3009        // rather than margin, so the indent is inside the box a selection
3010        // shades.
3011        assert!(
3012            !css.contains("margin-inline-start: calc(var(--row-depth"),
3013            "{css}"
3014        );
3015
3016        // The branch and its chevron, emitted and unstyled for the same reason.
3017        assert!(css.contains(".row-branch {"), "{css}");
3018        assert!(css.contains(".row-disclose {"), "{css}");
3019
3020        for name in ["row-nested", "row-branch", "row-disclose"] {
3021            assert!(
3022                crate::vocabulary::names(&Emit::default()).contains(name),
3023                "{name} is declared"
3024            );
3025        }
3026        assert!(css.contains("-webkit-line-clamp: 2;"));
3027        // The count is `Flow`'s, not this crate's. If the tier ever means three
3028        // lines, this fails here rather than in an app.
3029        assert!(css.contains(&format!("line-clamp: {};", Flow::Relaxed.lines())));
3030        // Tight gets no rule at all: one line is what a run already does, and a
3031        // class per part saying so is a declaration that changes nothing.
3032        assert!(!css.contains("row-tight"));
3033        assert_eq!(crate::list::flow_class(Flow::Relaxed), Some("row-relaxed"));
3034        assert_eq!(crate::list::flow_class(Flow::Tight), None);
3035        // Emitted, therefore checkable: an app's dead-vocabulary seal and the
3036        // overlap check both read `vocabulary::names`, so a class the renderer
3037        // can write and that list does not carry is invisible to both.
3038        assert!(crate::vocabulary::names(&Emit::default()).contains("row-relaxed"));
3039    }
3040
3041    #[test]
3042    fn the_two_kinds_of_wait_stop_rendering_identically() {
3043        // The state `d43ea1c5` fixes: the emitter had been writing
3044        // `data-awaiting` for months and nothing styled either value, so a
3045        // measured wait and an unmeasured one drew the same nothing.
3046        let css = stylesheet(&Emit::default());
3047        assert!(css.contains("[data-awaiting]::after"), "{css}");
3048        assert!(
3049            css.contains("[data-awaiting][aria-busy=\"true\"]::after"),
3050            "the mark is drawn only while something is actually waiting"
3051        );
3052        assert!(
3053            css.contains("[data-awaiting=\"determinate\"][aria-busy=\"true\"]::after"),
3054            "the measured half is its own drawing"
3055        );
3056    }
3057
3058    #[test]
3059    fn the_blink_takes_its_cadence_and_never_names_one() {
3060        // Three renderers draw this mark. A number written here would be a
3061        // second heartbeat for one wait.
3062        let css = stylesheet(&Emit::default());
3063        assert!(
3064            css.contains("calc(var(--cadence-activity) * 2)"),
3065            "a half-period doubled, not a literal"
3066        );
3067        assert!(!css.contains("500ms"), "{css}");
3068    }
3069
3070    #[test]
3071    fn a_bar_nobody_is_counting_is_empty_rather_than_full() {
3072        // Rule 1. The share defaults to zero, so a determinate control with no
3073        // binder watching bytes draws a trough. A default of 1 would be the
3074        // confidently-wrong drawing the rule exists to forbid.
3075        let css = stylesheet(&Emit::default());
3076        assert!(css.contains("var(--awaiting-share, 0)"), "{css}");
3077    }
3078
3079    #[test]
3080    fn motion_off_leaves_the_mark_lit_because_the_keyframes_do_the_dimming() {
3081        // `makeover_timing::reduced_motion_css` sets `--cadence-activity: 0ms`,
3082        // and a zero-length animation leaves the element in its base style
3083        // rather than at its last keyframe. So the base has to be the lit one.
3084        // The inverted spelling would blank the mark for a reader who asked for
3085        // less motion, which answers a request nobody made.
3086        let css = stylesheet(&Emit::default());
3087        let busy = css
3088            .split("[data-awaiting][aria-busy=\"true\"]::after {")
3089            .nth(1)
3090            .expect("the busy rule");
3091        let busy = busy.split('}').next().expect("its body");
3092        assert!(
3093            busy.contains("background: var(--action);"),
3094            "the base state is lit: {busy}"
3095        );
3096    }
3097
3098    #[test]
3099    fn the_whole_sheet_still_names_every_colour() {
3100        // The crate's founding property, asserted over the component layer and
3101        // not only the primitives.
3102        let css = stylesheet(&Emit::default());
3103        assert!(!css.contains('#'));
3104        assert!(!css.contains("rgb"));
3105        for line in css.lines() {
3106            // Declarations only: a selector or an at-rule can carry a colon of
3107            // its own (`:root`, `:hover`, `@media (hover: hover)`) and declares
3108            // nothing. Keyed on the trailing semicolon rather than on leading
3109            // indentation, which only ever worked as a proxy for nesting depth
3110            // and stopped when the sheet gained a cascade layer around it.
3111            let trimmed = line.trim();
3112            if !trimmed.ends_with(';') {
3113                continue;
3114            }
3115            let Some((_, value)) = trimmed.split_once(": ") else {
3116                continue;
3117            };
3118            if value.contains("var(--") {
3119                continue;
3120            }
3121            // Everything left has to be a keyword, a number or a
3122            // caller-supplied length, never a colour.
3123            //
3124            // The length arm is what the comment above always claimed and the
3125            // list never covered: `border_width` arrives from `Emit` and lands
3126            // bare in the focus ring's offset, where the bevel had only ever
3127            // used it inside an `inset` shadow.
3128            let opts = Emit::default();
3129            assert!(
3130                value.contains("inset")
3131                    || value.contains(opts.border_width)
3132                    || value.contains(opts.focus_width)
3133                    // 0.12.0's two: a sortable header is a control and says so
3134                    // with the pointer, and the caret is this renderer's own
3135                    // expression of `aria-sort`. Neither is a colour, which is
3136                    // what this test is actually about, and neither is a size,
3137                    // which is the other thing this crate must not name. The
3138                    // leading space inside the glyph is the same thing the other
3139                    // two renderers write into theirs, so it is part of the
3140                    // caret rather than spacing this crate decided on.
3141                    || value
3142                        .trim_start_matches('"')
3143                        .trim_start()
3144                        .starts_with("\\2")
3145                    || matches!(
3146                        value.trim_end_matches(';'),
3147                        "0" | "1"
3148                            // The wait's mark and bar, 0.60.0. An empty
3149                            // `content` is what brings a pseudo-element into
3150                            // existence with nothing in it, and `step-end`
3151                            // is an easing: the blink is two states, not a
3152                            // slide between them. Neither is a colour, and
3153                            // neither is a magnitude.
3154                            | "\"\""
3155                            // Where the mark sits on the line it joins.
3156                            // Alignment is structure, the same way `display`
3157                            // is, and `baseline` is the initial value said out
3158                            // loud so a host stylesheet cannot leave it
3159                            // wherever an earlier rule put it.
3160                            | "baseline"
3161                            | "inline-block"
3162                            | "none"
3163                            | "auto"
3164                            | "not-allowed"
3165                            | "pointer"
3166                            // The caret's reserved box. Visibility is presence,
3167                            // not magnitude and not colour.
3168                            | "hidden"
3169                            | "visible"
3170                            // The link's two signals. `underline` is a line and
3171                            // `inherit` defers to whatever the app set, so
3172                            // neither names a colour or a magnitude.
3173                            | "underline"
3174                            | "inherit"
3175                            // The table frame. `display` is structure and not a
3176                            // size; `nowrap` is what makes a content column
3177                            // content. The two widths are the awkward pair and
3178                            // they are still not sizes: `100%` is "all of
3179                            // whatever you were given" and `1%` is the CSS
3180                            // table idiom for "shrink to fit", which is a
3181                            // behaviour spelled as a number because CSS has no
3182                            // keyword for it. Neither names a magnitude, which
3183                            // is the thing this crate leaves to
3184                            // makeover-geometry.
3185                            | "table"
3186                            | "table-row"
3187                            | "table-cell"
3188                            | "nowrap"
3189                            | "100%"
3190                            | "1%"
3191                            // The time axis, 0.42.0. `position` is the one
3192                            // property whose whole job is where a thing sits,
3193                            // which is exactly what this crate spent its life
3194                            // refusing to say -- so it is worth being exact
3195                            // about why these two are not that refusal
3196                            // breaking.
3197                            //
3198                            // Neither names a magnitude. `relative` says the
3199                            // track is what its entries resolve against, and
3200                            // `absolute` says an entry is placed rather than
3201                            // flowed. *Where* each entry lands is
3202                            // `--track-at` and `--track-for`, custom
3203                            // properties the caller sets from
3204                            // `Track::fraction`, and they are skipped by the
3205                            // `var(--` arm above like every other value this
3206                            // crate refuses to decide.
3207                            //
3208                            // The rule that would break the refusal is a slot
3209                            // height, and there is none: the track's height is
3210                            // the app's, so the percentages have something to
3211                            // resolve against and this crate still never says
3212                            // how tall a day is.
3213                            | "relative"
3214                            | "absolute"
3215                            // A run's five, 0.49.0. `flex`, `wrap` and
3216                            // `center` are structure and alignment, the same
3217                            // reading `table` gets: which way members are laid
3218                            // out and how they line up, never how much of
3219                            // anything.
3220                            //
3221                            // The two intrinsic keywords are the interesting
3222                            // pair and they are the opposite of a size. A
3223                            // magnitude is a number somebody chose;
3224                            // `min-content` and `max-content` are the browser
3225                            // being asked what the members themselves come to,
3226                            // which is the derived minimum the room ruling
3227                            // requires and the reason no breakpoint appears
3228                            // anywhere in these rules. `1 1 max-content` is
3229                            // grow, shrink and that basis, so its two digits
3230                            // are ratios rather than lengths.
3231                            | "flex"
3232                            | "wrap"
3233                            | "center"
3234                            | "min-content"
3235                            | "1 1 max-content"
3236                            // A menu run's overflow control, 0.64.0.
3237                            // `column` and `stretch` are the same reading
3238                            // `flex` and `center` get one line up: which way
3239                            // the shed members stack inside the panel and how
3240                            // they line up across it. Neither is a magnitude.
3241                            //
3242                            // `100%` is already above and reused here as the
3243                            // panel's `inset-block-start`, which is "the whole
3244                            // of the control it hangs from" rather than a
3245                            // distance anybody picked.
3246                            | "column"
3247                            | "stretch"
3248                            // A picture's three, 0.36.0. `block` is structure
3249                            // for the reason `table` is: an inline image sits
3250                            // on the baseline and carries a descender's worth
3251                            // of space under it, which is a fact about
3252                            // replaced elements rather than a size this crate
3253                            // chose. `cover` and `contain` are `Fit`'s two
3254                            // named members reaching CSS unchanged, which is
3255                            // an intent arriving rather than a value being
3256                            // picked.
3257                            | "block"
3258                            | "cover"
3259                            | "contain"
3260                            // A relaxed part's three, and the third is the
3261                            // awkward one. `-webkit-box` and `vertical` are
3262                            // structure: they say the part is a box of lines
3263                            // stacked downward, which is the only way CSS lets
3264                            // anyone ask for a clamp at all.
3265                            //
3266                            // `2` is a count of lines, not a length. The
3267                            // distinction this crate holds is between naming a
3268                            // magnitude -- a padding, a height, a font size,
3269                            // all of which belong to makeover-geometry -- and
3270                            // naming how many of something there are. A line's
3271                            // height is still the app's, so two lines is
3272                            // whatever two of the app's lines come to, and
3273                            // nothing here decides how tall that is. It is also
3274                            // not a value picked here: it is `Flow::Relaxed`'s
3275                            // own answer arriving unchanged, the same way
3276                            // `cover` and `contain` are `Fit`'s.
3277                            | "-webkit-box"
3278                            | "vertical"
3279                            | "2"
3280                    ),
3281                "unrecognised literal value: {line}"
3282            );
3283        }
3284    }
3285
3286    #[test]
3287    fn flat_emits_nothing_at_all() {
3288        assert_eq!(depth_class(Depth::Flat, &Emit::default()), None);
3289        assert!(!depth_rules(&Emit::default()).contains("flat"));
3290    }
3291
3292    #[test]
3293    fn a_prefix_namespaces_every_class() {
3294        let opts = Emit {
3295            class_prefix: "mo-",
3296            ..Emit::default()
3297        };
3298        let css = depth_rules(&opts);
3299        assert!(css.contains(".mo-raised {"));
3300        assert!(css.contains(".mo-well {"));
3301        assert!(!css.contains(".raised {"));
3302    }
3303
3304    #[test]
3305    fn the_border_width_is_the_callers() {
3306        let opts = Emit {
3307            border_width: "2px",
3308            ..Emit::default()
3309        };
3310        assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0"));
3311    }
3312
3313    #[test]
3314    fn edges_agree_with_the_description() {
3315        // Not a tautology: it is the guard that a CSS-shaped convenience never
3316        // quietly reverses which side is lit.
3317        let (tl, br) = Bevel::Raised.edges();
3318        assert_eq!(tl.token(), Edge::Light.token());
3319        assert_eq!(br.token(), Edge::Dark.token());
3320    }
3321}