makeover-webview 0.22.1

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
//! A figure with a caption, and a strip of them.
//!
//! The fourth phase-B emitter. `makeover_layout::Figure` arrived at 0.11.0 after
//! goingson turned out to have five of these across five screens, each with its
//! own class names for the one shape: `task-overview-stat`, `stat-box`,
//! `month-stat-item`, `contact-summary-stat`, `sync-stat`.
//!
//! # Why the strip has its own function
//!
//! Four tiles in a row and four tiles down a column are different things, and a
//! renderer handed one figure at a time cannot tell it is looking at a set. So
//! the set is what gets emitted, and a lone figure is a set of one.
//!
//! # The reading order is markup, not CSS
//!
//! Visually the value is set large over a small caption, which is what four of
//! the five sites drew. A screen reader meeting "17" before it knows what was
//! counted has to hold the number until the noun arrives, so the figure carries
//! its own accessible name — "Current Streak: 17" — and the two spans are hidden
//! from the reader that has already been told.
//!
//! Solving it that way rather than by inverting the markup and turning it back
//! with `column-reverse` is deliberate: the arrangement and the type scale are
//! the app's, and a renderer that emitted them would be naming sizes. Same line
//! `meter_html` holds when it emits the tones and never the width.

use crate::form::escape;
use crate::{Emit, class};
use makeover_layout::{Figure, Intent, Tone};
use std::fmt::Write as _;

/// The accessible name for a figure: the noun, then the number.
///
/// Built here rather than carried, for the reason [`meter_text`] is: a strip
/// wants "Current Streak: 17" and a terminal at one line wants something else,
/// and a description that shipped either would have chosen for both.
///
/// [`meter_text`]: crate::meter::meter_text
#[must_use]
pub fn figure_text(figure: &Figure<'_>) -> String {
    format!("{}: {}", figure.caption, figure.value)
}

/// One figure, as its own element.
///
/// ```
/// use makeover_layout::{Figure, Tone};
/// use makeover_webview::{Emit, figure::figure_html};
///
/// let figure = Figure::new("17", "Current Streak").tone(Tone::Success);
/// let html = figure_html(&figure, &Emit::default());
///
/// assert!(html.contains(r#"data-tone="success""#));
/// assert!(html.contains(r#"aria-label="Current Streak: 17""#));
/// ```
#[must_use]
pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
    let mut html = format!(
        "<div class=\"{}\" aria-label=\"{}\"",
        class("figure", opts),
        escape(&figure_text(figure))
    );
    // Neutral is the ordinary fact, and `figure_rules` styles the bare class
    // for it. `data-tone="content-muted"` would match a rule that is not there.
    if figure.tone != Tone::Neutral {
        let _ = write!(html, " data-tone=\"{}\"", figure.tone.token());
    }
    // `aria-hidden` on both, because the element above has already said the
    // whole thing. Without it a reader gets the number twice and the noun
    // twice, in the order the eye wants rather than the order the ear does.
    let _ = write!(
        html,
        "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
         <span class=\"{}\" aria-hidden=\"true\">{}</span></div>",
        class("figure-value", opts),
        escape(figure.value),
        class("figure-caption", opts),
        escape(figure.caption),
    );
    html
}

/// Several figures as one strip.
///
/// An empty set emits the container and nothing in it, for the reason a meter
/// over nothing and a select with no options both render: it is what an app with
/// an unloaded count actually has, and an empty strip says so on screen rather
/// than in a log.
#[must_use]
pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
    let mut html = format!("<div class=\"{}\">", class("figures", opts));
    for figure in figures {
        html.push_str(&figure_html(figure, opts));
    }
    html.push_str("</div>");
    html
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_noun_reaches_a_reader_before_the_number() {
        // The problem the accessible name solves. Visually the value comes
        // first; a reader that met "17" first would have to hold it until it
        // found out what was counted.
        let figure = Figure::new("17", "Current Streak");
        assert_eq!(figure_text(&figure), "Current Streak: 17");

        let html = figure_html(&figure, &Emit::default());
        assert!(html.contains(r#"aria-label="Current Streak: 17""#));
        // And the spans are not read a second time in the other order.
        assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
    }

    #[test]
    fn an_untoned_figure_emits_no_tone_attribute() {
        // Same reason as the meter: the bare class is the untoned rule, so an
        // attribute here would match nothing.
        let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
        assert!(!plain.contains("data-tone"));

        let toned = figure_html(
            &Figure::new("0", "Current Streak").tone(Tone::Warning),
            &Emit::default(),
        );
        assert!(toned.contains(r#"data-tone="warning""#));
    }

    #[test]
    fn a_value_and_a_caption_are_escaped_like_every_other_string() {
        // Both arrive from the app, the same as a field label does.
        let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
        assert!(html.contains("a &amp; b"));
        assert!(html.contains("&lt;b&gt;"));
        assert!(!html.contains("<b>"));
    }

    #[test]
    fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
        let html = figures_html(
            &[
                Figure::new("17", "Current Streak"),
                Figure::new("84%", "Completion Rate"),
            ],
            &Emit::default(),
        );
        assert!(html.starts_with(r#"<div class="figures">"#));
        assert_eq!(html.matches(r#"class="figure""#).count(), 2);
    }

    #[test]
    fn an_empty_strip_renders_as_an_empty_strip() {
        // Sayable, so it has to be emittable, and visibly empty rather than
        // absent.
        let html = figures_html(&[], &Emit::default());
        assert_eq!(html, r#"<div class="figures"></div>"#);
        assert!(!html.contains("figure-"));
    }

    #[test]
    fn the_prefix_reaches_every_class() {
        // A prefixed build claims its own names, and the two inner spans are
        // descendant selectors in the emitted CSS.
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        let html = figures_html(&[Figure::new("17", "Total")], &opts);
        assert!(html.contains(r#"class="mo-figures""#));
        assert!(html.contains(r#"class="mo-figure""#));
        assert!(html.contains(r#"class="mo-figure-value""#));
        assert!(html.contains(r#"class="mo-figure-caption""#));
    }
}