//! The webview renderer for [`makeover_layout`]. //! //! //! //! # The renderer that needs no palette //! //! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui //! and a terminal need an actual colour before they can put anything on //! screen. A webview does not: `var(--surface-raised)` *is* the late binding, //! and the browser resolves it against whatever `themes.js` last wrote onto //! `:root`. //! //! So this crate emits text naming intents, and never learns a colour. It is //! the deferral rule with no adapter in the way, and it is why the webview was //! always the wrong renderer to derive a vocabulary from: it can express //! anything, so it never pushes back. //! //! # Phase A: the stylesheet only //! //! This emits component CSS and no markup, deliberately. GoingsOn has 145 //! `innerHTML` sites and Balanced Breakfast 175 `createElement` sites; moving //! markup is a migration, while adopting a generated stylesheet is a deletion. //! The apps keep every line of their markup and gain the classes. //! //! The bevel properties are byte-identical to what both apps already //! hand-write, which is asserted below. //! //! # What phase A settled, and what it costs //! //! Decided 2026-07-29 against goingson's `styles.css` rather than against a //! component list. The useful finding there was that `.btn` (line 644), //! `.card` (768) and `.tag, .badge` (882) each hand-write the same //! composition, so three quarters of phase A is one rule with several names. //! //! Two of the four decisions change how goingson looks, and adoption should //! not be described as a pure deletion: //! //! - **Pressed carries its fill.** [`interactive_rules`] emits //! [`Depth::pressed`] whole. goingson presses to `--surface-sunken` today and //! will press to `--surface-well`, and hovers to `--surface-overlay` today //! and will hover to `--hover-surface`. Since `surface-well` inverts by theme //! where `surface-sunken` does not, a dark theme presses *lighter* than it //! hovers. That falls out of `makeover`'s own derivation, which says outright //! that `surface-sunken` cannot serve as a well, so if it reads wrong the //! answer is there and not here. //! - **Badges go flat.** See [`token_rules`]. //! //! The other two: the progress trough is renderer-local and the scrollbar //! track was dropped ([`component_rules`]), and no class prefix ships by //! default, so adoption means deleting the app's hand-written rule in the same //! commit that adds the generated one. `.card`, `.badge` and the tab classes //! all already exist in goingson, and while both rules exist the cascade order //! decides which wins. That is the one real risk in adopting this, and it is //! why the migration lands per component rather than in one commit. //! //! # Substitution, three ways //! //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer //! answers that differently, which is the evidence that dropping //! `Fill::fallback` from the description was right: //! //! - `makeover-immediate` substitutes the page in Rust. //! - `makeover-tui` refuses to substitute and draws an edge instead, because a //! terminal would quantise the two together. //! - here, CSS already has the mechanism: `var(--surface-well, //! var(--surface-page))` falls back in the browser, and nothing in Rust //! decides anything. #![forbid(unsafe_code)] use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone}; use std::fmt::Write as _; /// How the emitted CSS is shaped. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Emit { /// Bevel thickness, as a CSS length. /// /// A value, so it arrives from the caller: border widths belong to /// `makeover-geometry` and will come from there once it carries them. pub border_width: &'static str, /// Prefix for emitted class names, without the leading dot. pub class_prefix: &'static str, } impl Default for Emit { fn default() -> Self { Self { border_width: "1px", class_prefix: "", } } } /// The CSS custom property holding a bevel's composition. #[must_use] pub fn bevel_var(bevel: Bevel) -> &'static str { match bevel { Bevel::Raised => "--bevel-raised", Bevel::Inset => "--bevel-inset", } } /// A `var()` reference to a fill intent, with the browser's own fallback where /// the intent may be absent. /// /// The fallback is CSS syntax, not a decision made here. That is the whole /// difference between this renderer and the other two. #[must_use] pub fn fill_var(fill: Fill) -> String { match fill { Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()), other => format!("var(--{})", other.token()), } } /// The two-tone edge as a `box-shadow` value. /// /// Two inset shadows, one per corner pair: the light one offset down and /// right so it lands on the top and left edges, the dark one the other way. /// The same assignment `makeover-immediate` draws with polylines and /// `makeover-tui` draws with box-drawing characters. #[must_use] pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String { let (top_left, bottom_right) = bevel.edges(); let w = opts.border_width; format!( "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})", top_left.token(), bottom_right.token() ) } /// The custom properties both bevels resolve through. /// /// Emitted as properties rather than inlined into every rule because that is /// what the apps already do, and because a consumer that wants the edge /// without the fill reads the property directly. #[must_use] pub fn bevel_properties(opts: &Emit) -> String { let mut css = String::new(); for bevel in [Bevel::Raised, Bevel::Inset] { let _ = writeln!( css, " {}: {};", bevel_var(bevel), bevel_shadow(bevel, opts) ); } css } /// The class name for a depth. #[must_use] pub fn depth_class(depth: Depth, opts: &Emit) -> Option { let name = match depth { Depth::Flat => return None, Depth::Raised => "raised", Depth::Well => "well", }; Some(format!("{}{name}", opts.class_prefix)) } /// A prefixed class name. fn class(name: &str, opts: &Emit) -> String { format!("{}{name}", opts.class_prefix) } /// The fill and edge declarations for a depth, as a rule body. /// /// Empty for [`Depth::Flat`], which has neither and inherits what it sits on. /// Callers lean on the emptiness to skip the rule rather than emit a class that /// sets nothing: a class that sets no properties is a class that means "I /// thought about this", which is what comments are for. #[must_use] pub fn depth_declarations(depth: Depth) -> String { let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else { return String::new(); }; format!( " background: {};\n box-shadow: var({});\n", fill_var(fill), bevel_var(bevel) ) } /// One rule giving a selector a depth, or nothing when the depth declares /// nothing. #[must_use] pub fn depth_rule(selector: &str, depth: Depth) -> String { let body = depth_declarations(depth); if body.is_empty() { return String::new(); } format!(".{selector} {{\n{body}}}\n") } /// Hover and pressed, for a selector that answers a click. /// /// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting /// only the edge is what left goingson hand-writing `background: /// var(--surface-sunken)` on three separate rules, and a fill that does not /// travel with its edge is precisely the disagreement `Depth` exists to make /// unrepresentable. So the pressed fill comes from the description /// (`--surface-well`) rather than from whatever each app reached for. /// /// Hover has no member in the description and is renderer policy: a terminal /// and an immediate-mode painter have no hover to express. It resolves against /// `--hover-surface`, which `makeover` already derives and which nothing /// consumed until now. #[must_use] pub fn interactive_rules(selector: &str) -> String { format!( ".{selector}:hover {{\n background: var(--hover-surface);\n}}\n{}", depth_rule(&format!("{selector}:active"), Depth::Raised.pressed()) ) } /// One rule per depth: its fill and its edge, together. /// /// A pressed rule rides along with the raised one, because the cascade can /// carry a state that an immediate-mode renderer has to resolve per call site. /// That is the one thing this renderer gets for free and the others do not. #[must_use] pub fn depth_rules(opts: &Emit) -> String { let mut css = String::new(); for depth in [Depth::Raised, Depth::Well] { let Some(class) = depth_class(depth, opts) else { continue; }; css.push_str(&depth_rule(&class, depth)); } if let Some(raised) = depth_class(Depth::Raised, opts) { css.push_str(&interactive_rules(&raised)); } css } /// The three surfaces that are a depth with a name. /// /// `button` and `card` are both [`Depth::Raised`], and `field` is a /// [`Depth::Well`] because that is the reading `Depth`'s own documentation /// gives a text field. Their bodies come out identical by construction rather /// than by hand: three hand-written copies in goingson's stylesheet is what /// phase A deletes, and generating them from one call is what stops them /// drifting apart again. fn surface_rules(opts: &Emit) -> String { let mut css = String::new(); for name in ["button", "card"] { let c = class(name, opts); css.push_str(&depth_rule(&c, Depth::Raised)); css.push_str(&interactive_rules(&c)); } let field = class("field", opts); css.push_str(&depth_rule(&field, Depth::Well)); // `Field::invalid` marks the whole group rather than only the message, so // the ring goes on the control. Flat, not a two-tone bevel: this edge is // saying "wrong", and lighting one side of it would have it say "raised" // at the same time. let _ = writeln!( css, ".{field}.invalid {{\n box-shadow: inset 0 0 0 {} var(--danger);\n}}", opts.border_width ); css } /// Badges and chips. /// /// The one place phase A changes how goingson looks rather than only where its /// rules live. [`Token::Badge`] is [`Depth::Flat`], so a badge emits no fill /// and no edge at all, where goingson ships `.tag, .badge` as a single rule /// carrying the raised bevel. Splitting that means reading every call site to /// decide which of the two it always was. /// /// What a badge does carry is a [`Tone`], the intent family it shares with /// notices and nothing else. Neutral is the bare class rather than a variant, /// because it is the absence of a status and not a status called "none". fn token_rules(opts: &Emit) -> String { let mut css = String::new(); // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat, // and a label with an edge says it can be pressed. let badge = class("badge", opts); let _ = writeln!( css, ".{badge} {{\n color: var(--{});\n}}", Tone::Neutral.token() ); for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] { let _ = writeln!( css, ".{badge}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}", tone.token() ); } // A chip holds itself down, which is `Depth::pressed` arrived at // independently by two apps. `removable` is a remove affordance, so it is // markup and waits for phase B. let chip = class("chip", opts); let unlatched = Token::Chip { removable: false }; css.push_str(&depth_rule(&chip, unlatched.depth(false))); css.push_str(&interactive_rules(&chip)); css.push_str(&depth_rule( &format!("{chip}.latched"), unlatched.depth(true), )); css } /// The three selectors, each named by what it picks. /// /// A tab comes *forward* to join the pane it opens, which is why /// [`Selector::Tabs`] chooses [`Depth::Raised`] where a segment and a toggle /// are held in. That is the folder semantic, and it is the whole reason the /// three are not one member with a flag. /// /// [`Selector::abutting`] is not emitted: whether the options touch is /// spacing, and spacing is `makeover-geometry`'s question to answer. fn selector_rules(opts: &Emit) -> String { let mut css = String::new(); for (selector, name) in [ (Selector::Tabs, "tab"), (Selector::Segmented, "segment"), (Selector::Toggle, "toggle"), ] { // The unchosen option is flat and emits no depth of its own. Giving it // an edge would make every option look picked. let c = class(name, opts); css.push_str(&interactive_rules(&c)); css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen())); } css } /// The four parts of a list row. fn row_rules(opts: &Emit) -> String { let mut css = String::new(); let row = class("row", opts); for part in [ RowPart::Primary, RowPart::Secondary, RowPart::Meta, RowPart::Actions, ] { let name = match part { RowPart::Primary => "row-primary", RowPart::Secondary => "row-secondary", RowPart::Meta => "row-meta", RowPart::Actions => "row-actions", }; let c = class(name, opts); // Actions carry controls rather than text, and `RowPart::intent` says // so by returning the same intent inheriting already gives. Pinning it // would be louder than saying nothing. if !matches!(part, RowPart::Actions) { let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent()); } if part.revealed_on_hover() { // Hidden rather than absent: the row must not change height when // the pointer arrives. `focus-within` carries the keyboard, which // hover on its own would lock out. let _ = writeln!(css, ".{c} {{\n visibility: hidden;\n}}"); let _ = writeln!( css, ".{row}:hover .{c},\n.{row}:focus-within .{c} {{\n visibility: visible;\n}}" ); } } css } /// The progress trough, which has nothing behind it in the description. /// /// Renderer-local chrome, on the same licence the skeletons hold as the /// webview's expression of `Readiness::Pending`: a determinate bar is a shape /// CSS draws readily and a terminal would rather not be told about. It earns /// the place empirically, goingson having grown four independent progress bars /// before anything named one. /// /// The trough is a [`Depth::Well`], the same reading a text field gets: /// something with its content down inside it. fn progress_rules(opts: &Emit) -> String { let progress = class("progress", opts); // `progress-fill` rather than a bare `fill`: an unprefixed build claims // these names in the app's own stylesheet, and `.fill` is grabby enough to // catch things that have nothing to do with progress. goingson already // calls it `.progress-fill`, so this is also the name that deletes. let fill = class("progress-fill", opts); format!( "{}.{progress} > .{fill} {{\n background: var(--action);\n}}\n", depth_rule(&progress, Depth::Well) ) } /// The component layer: every named thing phase A emits. /// /// No scrollbar track. It was on the phase A list and came off: eight lines of /// `::-webkit-scrollbar` with no shape a terminal or an immediate-mode painter /// would want handed to it, so it stays with the apps. #[must_use] pub fn component_rules(opts: &Emit) -> String { let mut css = String::new(); css.push_str(&surface_rules(opts)); css.push_str(&token_rules(opts)); css.push_str(&selector_rules(opts)); css.push_str(&row_rules(opts)); css.push_str(&progress_rules(opts)); css } /// The whole phase-A stylesheet: properties, depth rules and components, with /// a generated-file banner. #[must_use] pub fn stylesheet(opts: &Emit) -> String { format!( "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \ Depth is a fill and an edge together; naming them apart is what let\n \ them disagree. See the crate's README and wiki note makeover-layout. */\n\ :root {{\n{}}}\n\n{}\n{}", bevel_properties(opts), depth_rules(opts), component_rules(opts) ) } #[cfg(test)] mod tests { use super::*; use makeover_layout::Edge; #[test] fn the_emitted_bevel_matches_what_the_apps_already_hand_write() { // Balanced Breakfast's styles.css, verbatim. Adoption has to be a // deletion, not a redesign, or nobody will take it. let opts = Emit::default(); assert_eq!( bevel_shadow(Bevel::Raised, &opts), "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)" ); assert_eq!( bevel_shadow(Bevel::Inset, &opts), "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)" ); } #[test] fn no_colour_ever_reaches_the_output() { let css = stylesheet(&Emit::default()); assert!(!css.contains('#'), "a hex literal escaped into the CSS"); assert!( !css.contains("rgb"), "a colour function escaped into the CSS" ); // Every colour is named, never resolved. assert!(css.contains("var(--surface-raised)")); assert!(css.contains("var(--bevel-light)")); } #[test] fn a_well_falls_back_through_css_rather_than_through_rust() { assert_eq!( fill_var(Fill::Well), "var(--surface-well, var(--surface-page))" ); // Nothing else needs one. assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)"); assert_eq!(fill_var(Fill::Page), "var(--surface-page)"); } #[test] fn raised_and_well_do_not_collapse_onto_each_other() { let css = depth_rules(&Emit::default()); assert!(css.contains(".raised {")); assert!(css.contains(".well {")); assert!(css.contains("var(--bevel-raised)")); assert!(css.contains("var(--bevel-inset)")); } #[test] fn the_cascade_carries_the_pressed_state() { let css = depth_rules(&Emit::default()); // The one thing this renderer gets free that the other two resolve by // hand, eighteen call sites deep in audiofiles' case. assert!(css.contains(".raised:active {")); } #[test] fn pressing_moves_the_fill_and_not_only_the_edge() { // The decision-1 guard, and the regression that mattered: emitting the // bevel flip alone is what left goingson hand-writing `background: // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none // of the three could be deleted. let pressed = interactive_rules("button"); assert!(pressed.contains(".button:active {")); assert!( pressed.contains("background: var(--surface-well, var(--surface-page))"), "pressed dropped its fill: {pressed}" ); assert!(pressed.contains("box-shadow: var(--bevel-inset)")); } #[test] fn pressed_takes_its_fill_from_the_description_not_from_the_app() { // goingson presses to --surface-sunken. The description says a pressed // raised region reads as a well, and makeover says outright that // surface-sunken cannot serve as one, so the app is the thing that // moves. let css = stylesheet(&Emit::default()); assert!( !css.contains("surface-sunken"), "the app's pressed fill leaked into the generated sheet" ); assert_eq!( Depth::Raised.pressed().fill(), Some(Fill::Well), "the description changed under us" ); } #[test] fn hover_resolves_against_the_token_makeover_already_derives() { let css = interactive_rules("card"); assert!(css.contains(".card:hover {")); assert!(css.contains("background: var(--hover-surface)")); // Not the app's choice, which was --surface-overlay. assert!(!css.contains("surface-overlay")); } #[test] fn a_badge_gets_no_edge_and_no_fill() { // Decision 2, and the one visible redesign in phase A. Token::Badge is // Flat: an edge on a label says it can be pressed. let css = token_rules(&Emit::default()); let badge = css .lines() .skip_while(|l| !l.starts_with(".badge {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}"); assert!(!badge.contains("background"), "badge kept a fill: {badge}"); assert_eq!(Token::Badge.depth(false), Depth::Flat); assert_eq!(Token::Badge.depth(true), Depth::Flat); } #[test] fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() { let css = token_rules(&Emit::default()); // Neutral is the absence of a status, not a status named "none". assert!(css.contains(".badge {\n color: var(--content-muted);")); assert!(!css.contains("data-tone=\"content-muted\"")); for tone in ["info", "success", "warning", "danger"] { assert!( css.contains(&format!(".badge[data-tone=\"{tone}\"]")), "missing tone {tone}" ); assert!(css.contains(&format!("color: var(--{tone})"))); } } #[test] fn a_chip_is_raised_and_latches_into_a_well() { let css = token_rules(&Emit::default()); assert!(css.contains(".chip {")); assert!(css.contains(".chip.latched {")); assert!(css.contains(".chip:active {")); // The whole difference from a badge: it answers a click. assert!(Token::Chip { removable: false }.interactive()); assert!(!Token::Badge.interactive()); } #[test] fn only_a_tab_comes_forward_when_chosen() { // The folder semantic. Collapsing the three selectors would lose it. let css = selector_rules(&Emit::default()); assert!(css.contains(".tab.chosen {")); assert!(css.contains(".segment.chosen {")); assert!(css.contains(".toggle.chosen {")); assert_eq!(Selector::Tabs.chosen(), Depth::Raised); assert_eq!(Selector::Segmented.chosen(), Depth::Well); assert_eq!(Selector::Toggle.chosen(), Depth::Well); let tab = css .lines() .skip_while(|l| !l.starts_with(".tab.chosen {")) .take_while(|l| !l.starts_with('}')) .collect::>() .join("\n"); assert!(tab.contains("var(--bevel-raised)"), "tab was held in: {tab}"); } #[test] fn an_unchosen_option_has_no_edge_of_its_own() { let css = selector_rules(&Emit::default()); // `.tab {` with a body would make every option look picked. Only the // interaction states and the chosen rule may exist. assert!(!css.contains(".tab {\n")); assert!(css.contains(".tab:hover {")); } #[test] fn row_actions_are_revealed_without_moving_the_row() { let css = row_rules(&Emit::default()); assert!(css.contains(".row-actions {\n visibility: hidden;")); // Not display:none, which would reflow the row under the pointer. assert!(!css.contains("display: none")); // Hover alone would lock the keyboard out. assert!(css.contains(".row:focus-within .row-actions")); assert!(RowPart::Actions.revealed_on_hover()); } #[test] fn the_three_text_parts_take_their_intents_and_actions_inherits() { let css = row_rules(&Emit::default()); assert!(css.contains(".row-primary {\n color: var(--content);")); assert!(css.contains(".row-secondary {\n color: var(--content-secondary);")); assert!(css.contains(".row-meta {\n color: var(--content-muted);")); // Actions carry controls, not text. Pinning the colour it would inherit // anyway is louder than saying nothing. assert!(!css.contains(".row-actions {\n color:")); } #[test] fn the_progress_trough_is_a_well() { let css = progress_rules(&Emit::default()); assert!(css.contains(".progress {")); assert!(css.contains("box-shadow: var(--bevel-inset)")); assert!(css.contains(".progress > .progress-fill {")); assert!(css.contains("background: var(--action)")); // A bare `.fill` would catch things that have nothing to do with // progress once the sheet lands unprefixed. assert!(!css.contains("> .fill ")); } #[test] fn no_scrollbar_track_is_emitted() { // Decision 3's negative half. It was on the phase A list and came off; // this is what stops it drifting back in. let css = stylesheet(&Emit::default()); assert!(!css.contains("scrollbar")); assert!(!css.contains("::-webkit")); } #[test] fn an_invalid_field_is_ringed_without_being_lit() { let css = surface_rules(&Emit::default()); assert!(css.contains(".field {")); assert!(css.contains(".field.invalid {")); // A flat ring: this edge says "wrong", and a two-tone bevel would have // it say "raised" at the same time. assert!(css.contains("inset 0 0 0 1px var(--danger)")); } #[test] fn button_and_card_come_out_identical_by_construction() { // The duplication phase A deletes. They are the same composition, so // the only honest way to emit both is from one call. let opts = Emit::default(); let css = surface_rules(&opts); assert_eq!( depth_declarations(Depth::Raised), depth_declarations(Depth::Raised) ); assert!(css.contains(".button {")); assert!(css.contains(".card {")); assert_eq!( interactive_rules("button").replace("button", "card"), interactive_rules("card") ); } #[test] fn a_prefix_reaches_the_component_classes_too() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = stylesheet(&opts); for name in [ "mo-button", "mo-card", "mo-field", "mo-badge", "mo-chip", "mo-tab", "mo-row-primary", "mo-progress", "mo-progress-fill", ] { assert!(css.contains(&format!(".{name}")), "unprefixed: {name}"); } // The bare names must be gone entirely, or a prefixed build still // collides with the app's own stylesheet. assert!(!css.contains(".button {")); assert!(!css.contains(".card {")); assert!(!css.contains(".badge {")); } #[test] fn the_whole_sheet_still_names_every_colour() { // The crate's founding property, asserted over the component layer and // not only the primitives. let css = stylesheet(&Emit::default()); assert!(!css.contains('#')); assert!(!css.contains("rgb")); for line in css.lines() { // Declarations only: a selector or an at-rule can carry a colon of // its own (`:root`, `:hover`) and declares nothing. let declaration = line.strip_prefix(" ").map(str::trim); let Some(Some((_, value))) = declaration.map(|d| d.split_once(": ")) else { continue; }; if value.contains("var(--") { continue; } // Everything left has to be a keyword or a caller-supplied length, // never a colour. assert!( value.contains("hidden") || value.contains("visible") || value.contains("inset"), "unrecognised literal value: {line}" ); } } #[test] fn flat_emits_nothing_at_all() { assert_eq!(depth_class(Depth::Flat, &Emit::default()), None); assert!(!depth_rules(&Emit::default()).contains("flat")); } #[test] fn a_prefix_namespaces_every_class() { let opts = Emit { class_prefix: "mo-", ..Emit::default() }; let css = depth_rules(&opts); assert!(css.contains(".mo-raised {")); assert!(css.contains(".mo-well {")); assert!(!css.contains(".raised {")); } #[test] fn the_border_width_is_the_callers() { let opts = Emit { border_width: "2px", ..Emit::default() }; assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0")); } #[test] fn edges_agree_with_the_description() { // Not a tautology: it is the guard that a CSS-shaped convenience never // quietly reverses which side is lit. let (tl, br) = Bevel::Raised.edges(); assert_eq!(tl.token(), Edge::Light.token()); assert_eq!(br.token(), Edge::Dark.token()); } }