makeover-webview 0.116.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. With the build feature, writes a consumer's generated frontend files and checks the hand-written ones against them.
Documentation
//! The stylesheet half of a chart: a run of magnitudes against one axis, drawn
//! as bars.
//!
//! The markup is drawn by the quasi hosts (`quasi-webview` for this renderer),
//! which own the vocabulary member. This module owns the classes that markup
//! carries and the rules that draw them, so the generated stylesheet and the
//! emitted markup cannot disagree about a name.
//!
//! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
//! draws one proportion, and a chart draws a series whose markup computes
//! nothing. Both numbers reach the markup as they were given, and the division
//! happens in CSS.
//!
//! # Why the arithmetic is in the stylesheet
//!
//! `quasi-declare` derives a compiled template by rendering a screen with
//! stand-in values and keeping the bytes that no request reaches; a number the
//! description HANDS a renderer is found in that render and becomes a hole, and
//! a number the renderer WORKS OUT from two of them is printed as its
//! arithmetic, leaves no stand-in to find, and is baked into the template as a
//! constant. `quasi_router::stage::number_at` says so in as many words.
//!
//! So `--value` and `--most` reach the markup as themselves, and `chart_rules`
//! divides them where a browser can. Both ride in
//! [`VARS_ATTR`](crate::VARS_ATTR) rather than in `style`, so a page whose
//! policy refuses style attributes still draws its bars.

use crate::{Depth, Emit, STATUS_TONES, class, depth_rule, gated, hover_condition};
use makeover_layout::Intent as _;
use std::fmt::Write as _;

/// Every class a chart's markup can carry.
///
/// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
/// scraped vocabulary true if a rule goes away.
pub const CHART_CLASSES: &[&str] = &[
    "chart",
    "chart-bars",
    "chart-bar-col",
    "chart-bar",
    "chart-bar-label",
];

/// What a chart looks like.
///
/// # What is emitted and what is deferred
///
/// `progress_rules`' rule, applied: the tones are emitted and the sizes are
/// not. This crate names no magnitude, that is `makeover-geometry`'s, so
/// every length here is a custom property with a default an adopter overrides
/// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
/// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
///
/// The height of a BAR is the one length that has to be here, and it is not a
/// magnitude: it is the two numbers the markup carries, divided. That division
/// is the half of the contract the markup cannot state on its own.
///
/// The column stretches to the chart's height and stacks from the bottom. It
/// has to: a percentage height resolves only against a definite one, and a
/// column left at `align-items: flex-end`'s content height gave every bar
/// `auto`, which is the 2px floor and nothing more.
///
/// The value is clamped to the axis, `min(var(--value), var(--most))`, so an
/// axis of zero draws every bar at nothing, which is what an empty axis means,
/// and a value past the axis fills its column and stops rather than pushing its
/// label out of the chart. `max(var(--most), 1)` rather than a bare
/// `var(--most)` keeps the divisor nonzero, since dividing by it would make the
/// whole declaration invalid at computed-value time and drop the height to
/// `auto`; the vocabulary's own fraction and the other two renderers make the
/// same clamp.
///
/// The column holds the label too, one line by `nowrap`, so the bar's share is
/// of the column less `1lh`: measured against the whole column a full bar and
/// its label overflowed it, and flex shrank the tallest bars out of proportion
/// with the rest. The first `height` is the same without the label's line, for
/// an engine that does not know `lh` and drops the second.
fn chart_rules(opts: &Emit) -> String {
    let chart = class("chart", opts);
    let bars = class("chart-bars", opts);
    let col = class("chart-bar-col", opts);
    let bar = class("chart-bar", opts);
    let label = class("chart-bar-label", opts);

    let mut css = depth_rule(&chart, Depth::Well);

    let _ = writeln!(
        css,
        ".{bars} {{\n    display: flex;\n    align-items: flex-end;\n    \
         gap: var(--chart-gap, 2px);\n    height: var(--chart-height, 200px);\n}}"
    );
    let _ = writeln!(
        css,
        ".{col} {{\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    \
         justify-content: flex-end;\n    align-self: stretch;\n    \
         align-items: center;\n    min-width: 0;\n    position: relative;\n}}"
    );
    let _ = writeln!(
        css,
        ".{bar} {{\n    width: 100%;\n    background: var(--action);\n    \
         min-height: var(--chart-bar-least, 2px);\n    \
         height: min(calc(min(var(--value, 0), var(--most, 0)) * 100% / max(var(--most, 1), 1)), 100%);\n    \
         height: min(calc(min(var(--value, 0), var(--most, 0)) * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);\n}}"
    );
    // A chart can be saying something, the same way a bar can. `progress_rules`
    // emits the tones for that reason and this follows it.
    for tone in STATUS_TONES {
        let _ = writeln!(
            css,
            ".{chart}[data-tone=\"{0}\"] .{bar} {{\n    background: var(--{0});\n}}",
            tone.token()
        );
    }
    let _ = writeln!(
        css,
        ".{label} {{\n    color: var(--content-muted);\n    max-width: 100%;\n    \
         white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}}"
    );
    // A label every bar is a label no bar can hold. Thirty days drew thirty
    // `Sep …` stubs, each cut to its column, and not one date read. Past a
    // dozen bars only every seventh is labelled, a week apart on a daily
    // axis, and that label runs on over the six blank neighbours after it
    // rather than being cut to its own column. Past a hundred and twenty,
    // every thirtieth. Counted with `:has` and `:nth-child`, so the markup
    // says nothing it does not already say. A kept label near the end can run
    // past the last bar, so the bars clip across and only across: the readout
    // rises above them and must not be cut.
    let _ = writeln!(css, ".{bars} {{\n    overflow-x: clip;\n}}");
    for (from, every) in [(13, 7), (121, 30)] {
        let many = format!(".{bars}:has(> .{col}:nth-child({from}))");
        let _ = writeln!(
            css,
            "{many} > .{col} > .{label} {{\n    visibility: hidden;\n}}\n\
             {many} > .{col}:nth-child({every}n + 1) > .{label} {{\n    visibility: visible;\n    \
             max-width: none;\n    overflow: visible;\n    align-self: flex-start;\n}}"
        );
    }

    // The readout, revealed from the attribute the markup already carries.
    //
    // Gated, because it is a hover state and this crate asks
    // `Affordance` whether a hover state exists rather than assuming one.
    // Keyed on the attribute rather than on a class, so a bar with nothing to
    // say reveals no empty bubble.
    //
    // Centred with `inset-inline: 0` and an auto margin rather than with a
    // half-width translate: the translate is the idiom and it names a
    // magnitude, and this does the same job with three keywords.
    css.push_str(&gated(
        hover_condition(),
        &format!(
            ".{col}[data-tooltip]:hover::before {{\n    content: attr(data-tooltip);\n    \
             position: absolute;\n    bottom: 100%;\n    inset-inline: 0;\n    \
             margin-inline: auto;\n    width: max-content;\n    \
             background: var(--surface-raised);\n    color: var(--content);\n    \
             border: {bw} solid var(--border);\n    box-shadow: var(--elevation-overlay);\n    \
             padding: var(--chart-readout-padding, 0.25em 0.5em);\n    \
             white-space: nowrap;\n    pointer-events: none;\n}}\n",
            bw = opts.border_width
        ),
    ));
    css
}

/// The rules, for the stylesheet builder.
#[must_use]
pub fn rules(opts: &Emit) -> String {
    chart_rules(opts)
}

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

    /// Every class the markup can carry has a rule, which is what
    /// `CHART_CLASSES` exists to keep true.
    #[test]
    fn every_class_this_module_names_has_a_rule() {
        crate::test_support::assert_every_class_has_rule(
            &chart_rules(&Emit::default()),
            CHART_CLASSES,
        );
    }

    /// A zero axis draws every bar at nothing rather than at full height.
    ///
    /// The value is clamped to the axis before the division, so five on an
    /// axis of zero is `min(5, 0)`, not `min(5 * 100% / 1, 100%)`, which was
    /// the whole column. The divisor guard stays, so the declaration survives
    /// a zero axis instead of falling to `auto`.
    #[test]
    fn a_zero_axis_clamps_every_bar_to_nothing() {
        let css = chart_rules(&Emit::default());
        assert!(
            css.contains(
                "height: min(calc(min(var(--value, 0), var(--most, 0)) * 100% \
                 / max(var(--most, 1), 1)), 100%);"
            ),
            "{css}"
        );
        assert!(
            css.contains(
                "height: min(calc(min(var(--value, 0), var(--most, 0)) \
                 * (100% - 1lh) / max(var(--most, 1), 1)), 100% - 1lh);"
            ),
            "{css}"
        );
    }

    /// The readout's edge is drawn. `border` with a colour alone has no style,
    /// so the shorthand leaves the edge at `none` and draws nothing.
    #[test]
    fn the_readout_draws_its_edge() {
        let opts = Emit::default();
        let css = chart_rules(&opts);
        assert!(
            css.contains(&format!(
                "border: {} solid var(--border);",
                opts.border_width
            )),
            "{css}"
        );
        assert!(!css.contains("border: var(--border);"), "{css}");
    }
}