makeover-webview 0.1.0

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser.
Documentation
//! The webview renderer for [`makeover_layout`].
//!
//! <!-- wiki: makeover-webview -->
//!
//! # 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 output is byte-identical to the bevel composition both apps already
//! hand-write, which is asserted below, so adoption removes duplicated lines
//! rather than changing a pixel.
//!
//! # 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};
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<String> {
    let name = match depth {
        Depth::Flat => return None,
        Depth::Raised => "raised",
        Depth::Well => "well",
    };
    Some(format!("{}{name}", opts.class_prefix))
}

/// One rule per depth: its fill and its edge, together.
///
/// `Depth::Flat` emits nothing. A class that sets no properties is a class
/// that means "I thought about this", which is what comments are for.
///
/// 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;
        };
        let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else {
            continue;
        };
        let _ = writeln!(
            css,
            ".{class} {{\n    background: {};\n    box-shadow: var({});\n}}",
            fill_var(fill),
            bevel_var(bevel)
        );
    }
    if let (Some(raised), Some(pressed)) = (
        depth_class(Depth::Raised, opts),
        Depth::Raised.pressed().bevel(),
    ) {
        let _ = writeln!(
            css,
            ".{raised}:active {{\n    box-shadow: var({});\n}}",
            bevel_var(pressed)
        );
    }
    css
}

/// The whole phase-A stylesheet: properties and rules, 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{}",
        bevel_properties(opts),
        depth_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 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());
    }
}