makeover-webview 0.106.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 classes a list's or a table's columns, rows and cells carry.
//!
//! The row and cell markup is drawn by the quasi hosts (`quasi-webview` for
//! this renderer), which own the column and cell members of the vocabulary.
//! What stays here is the naming those hosts write into markup and the
//! generated stylesheet rules: a column's name as a class
//! ([`push_column_name`]), the kind, part and flow classes, the cell wrapper
//! ([`CELL_IN`]) and sizer ([`push_sizer`]), the `short-N` ladder
//! ([`SHORT_RUNGS`]), and the class lists the vocabulary is checked against.
//!
//! # Why positions are the bug
//!
//! goingson hid its mobile columns with `nth-child(n+5)` against a
//! seven-column table, plus a separate `nth-child(3)`, plus two class-based
//! rules, the same fact said three ways, two of them positional. Insert a
//! column anywhere left of the cut and the wrong one disappears, silently,
//! because nothing in the stylesheet knows what column five *is*.
//! [`Priority`](makeover_layout::Priority) is the fix: a renderer narrows by what a column is worth, as a
//! shrink factor on the column's own class (one of [`CELL_DROP_CLASSES`]), and
//! never by counting.

use crate::form::Markup;
use crate::{Emit, push_class};
use makeover_layout::{CellPart, ColumnKind, Flow, RowPart};

/// A column's name as the identifier half of its class.
///
/// # Why this is not escaping
///
/// The name is the one app-supplied string this crate puts in a class attribute
/// rather than in text or an `aria-label`, so it may never go in raw. A column
/// named `a" onclick="steal()` emitted
///
/// ```html
/// <div class="cell col-a" onclick="steal() cell-fill cell-keeps">
/// ```
///
/// which is a live event handler on every cell of that column. HTML escaping is
/// the reflex and it is the wrong tool here, because a class is read twice: once
/// by the HTML parser, which would decode `&quot;` back to a quote, and once by
/// the CSS selector an app writes against it. An escaped name is safe in the
/// attribute and unmatchable from the stylesheet, so the two halves would stop
/// meeting, silently, the way every other defect this module's comments
/// record did.
///
/// Reducing the name to identifier characters answers both. What comes out is a
/// valid CSS identifier, so the selector matches, and it holds none of the five
/// characters an attribute value can be ended with, so there is nothing to
/// escape.
///
/// # What it changes for a name that was already fine
///
/// Nothing. Alphanumerics, `_` and `-` pass through, and every column name in
/// the tree is made of those. A name that is *not* was already broken rather
/// than merely unsafe: `Due date` emitted `col-Due date`, which the HTML parser
/// reads as the two classes `col-Due` and `date`, and which a selector wrote as
/// a descendant selector that matched neither. Both now agree on
/// `col-Due-date`.
///
/// Alphanumeric in the Unicode sense, not the ASCII one. CSS identifiers admit
/// everything from U+00A0 up, so a column named `Größe` keeps its name; folding
/// it to `Gr--e` would collide with a neighbouring column for nothing.
pub fn push_column_name(out: &mut String, name: &str) {
    for ch in name.chars() {
        // Substituted rather than dropped. Two columns called `a b` and `ab`
        // are different columns, and dropping would give them one class and one
        // set of narrowing rules between them.
        if ch.is_alphanumeric() || ch == '_' || ch == '-' {
            out.push(ch);
        } else {
            out.push('-');
        }
    }
}

/// The class saying what a column holds, or `None` for prose.
///
/// wiki `table-model`: the kind carries the look, so the class goes on every
/// cell and on the heading, and the sheet rules each kind once. Text is the
/// absence of a kind, as `cell-keeps` would be if a keep needed saying, so it
/// writes nothing. A kind added upstream reads as text: plain, never missing.
#[must_use]
pub fn kind_class(kind: ColumnKind) -> Option<&'static str> {
    match kind {
        ColumnKind::Identifier => Some("kind-identifier"),
        ColumnKind::Date => Some("kind-date"),
        ColumnKind::Number => Some("kind-number"),
        ColumnKind::Code => Some("kind-code"),
        ColumnKind::Status => Some("kind-status"),
        ColumnKind::Actions => Some("kind-actions"),
        _ => None,
    }
}

/// Every class [`kind_class`] can return.
pub const KIND_CLASSES: &[&str] = &[
    "kind-identifier",
    "kind-date",
    "kind-number",
    "kind-code",
    "kind-status",
    "kind-actions",
];

/// The wrapper every cell and heading holds its contents in.
///
/// The column is a flex item that may close to nothing, and padding cannot
/// shrink, so the padding lives on this and the column clips it. It is also
/// what a droppable column hides when it is under its floor, and where the
/// type goes, so a heading and the cells under it are the same box whatever
/// each holds.
pub const CELL_IN: &str = "cell-in";

/// What a kept column is as wide as, when its floor cannot say.
///
/// A floor is a count of `ch`, and a column of controls is not drawn in `ch`.
/// Measured on MNW's tables at 1440 and 420: a label's letters run 6 to 8px
/// against the 9px rung while a button's padding and edge take 26px against the
/// 18 that `quasi_router::screen::ACT_ROOM` allows, so "Add to cart" held 150px
/// to draw 118 and "Remove" held 96 to draw 99. The first starved the name
/// beside it to 32px at 420; the second clipped at every width. No constant
/// fixes both, because the error is the face's and not the count's.
///
/// So the host that knows what a column holds writes a copy of it here, once
/// per distinct run of controls, drawn in the row's own type and taking no
/// height and no ink. A kept column holding one is as wide as its widest copy,
/// which the browser measures in the face it draws, and every cell and the
/// heading hold the same copies, so the column is one width down the table.
/// Hidden rather than absent: a hidden box still has a width.
///
/// Write it with [`push_sizer`] and never by hand, so the wrapper the sheet
/// rules and the one a host emits cannot be spelled apart.
pub const CELL_SIZER: &str = "cell-sizer";

/// A [`CELL_SIZER`] around `copies`, which go inside a cell's or a heading's
/// [`CELL_IN`] after what it holds.
///
/// `copies` is the host's: one block per distinct run, each drawn with the
/// classes and the element the real controls take, since an element rule an
/// app writes for `button` reaches a copy only if it is one. Nothing in it may
/// be a control anyone can reach: `aria-hidden` here and `visibility: hidden`
/// in the sheet take it out of the accessibility tree and the tab order, and a
/// copy carries no address. Empty writes nothing.
///
/// [`Markup`], because the copies are the host's markup and are written as-is.
pub fn push_sizer(out: &mut String, copies: Markup<'_>, opts: &Emit) {
    let Markup(copies) = copies;
    if copies.is_empty() {
        return;
    }
    out.push_str("<span class=\"");
    push_class(out, CELL_SIZER, opts);
    out.push_str("\" aria-hidden=\"true\">");
    out.push_str(copies);
    out.push_str("</span>");
}

/// Every class [`part_class`] can return, including the fallback.
///
/// Beside the match rather than derived from it, because a `match` over a
/// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
/// same obligation the match does and a test below holds the two together, so
/// a new arm added without a new entry fails rather than silently narrowing
/// what a checker believes this crate can emit.
pub const ROW_PART_CLASSES: &[&str] = &[
    "row-primary",
    "row-secondary",
    "row-meta",
    "row-actions",
    "row-tokens",
    "row-proportion",
    "row-part",
];

/// Every class [`flow_class`] can return.
///
/// `Flow::Tight` has no class: one line is what a run already does, so a rule
/// for it would restate the default on every part in every row. Only the tier
/// that departs from it is named, which is also why a renderer emitting nothing
/// for an unknown flow is correct rather than lossy.
pub const FLOW_CLASSES: &[&str] = &["row-relaxed"];

/// The classes a row inside a hierarchy carries.
///
/// Declared here because the generated sheet defines rules for them: the
/// vocabulary has to say so, or the check that every emitted name has a rule
/// cannot see them.
pub const NESTING_CLASSES: &[&str] = &["row-nested", "row-branch", "row-disclose"];

/// The class for a part's flow, if it needs one.
///
/// `None` for [`Flow::Tight`] and for any tier added upstream that this crate
/// has not been taught, which lands as one line: the same trade
/// [`part_class`]'s fallback makes, and the safe direction, since a part that
/// grows without bound breaks the rows around it while a part that stays on one
/// line only looks like the old rendering. Grep this when adopting a new
/// makeover-layout.
#[must_use]
pub fn flow_class(flow: Flow) -> Option<&'static str> {
    match flow {
        Flow::Relaxed => Some("row-relaxed"),
        _ => None,
    }
}

/// Every class saying how wide a cell of a column asks to be, including the
/// fill a column that makes no claim lands on.
///
/// See [`ROW_PART_CLASSES`] for why it is written out. Only two of the three
/// carry a rule, a fill is what a cell does when the sheet says nothing,
/// which is exactly why the list is here rather than being read off the
/// generated CSS: `cell-fill` reached every table in the tree and the
/// vocabulary named it nowhere.
pub const CELL_WIDTH_CLASSES: &[&str] = &["cell-content", "cell-fixed", "cell-fill"];

/// Every class saying when a cell of a column drops, including the tier that
/// never does.
///
/// The class is a shrink factor: a table short of room takes the shortfall
/// from the columns worth least, and nothing counts positions or names a
/// breakpoint.
///
/// [`CELL_WIDTH_CLASSES`]' argument, one column property over: `cell-keeps` is
/// the tier the narrowing never hides, so the sheet writes no rule for it and
/// a scraped set cannot see it.
pub const CELL_DROP_CLASSES: &[&str] = &["cell-drops-first", "cell-drops-next", "cell-keeps"];

/// The rungs of the `short-N` ladder, in `rem`: the narrowest and widest table
/// width it can say, and the step between two.
///
/// Two `rem` a step, so a table is marked short at most 32px before its
/// essentials actually stop fitting, which is well inside the one gap of slack
/// every floor already carries. A table needing more than the ceiling is wider
/// than any pane the apps draw, and gets no class.
pub const SHORT_RUNGS: (u16, u16, u16) = (8, 160, 2);

/// Every class [`cell_part_class`] can return, including the fallback.
///
/// See [`ROW_PART_CLASSES`] for why it is written out.
pub const CELL_PART_CLASSES: &[&str] = &[
    "cell-value",
    "cell-tokens",
    "cell-actions",
    "cell-link",
    "cell-part",
];

/// The class for a row part.
///
/// [`RowPart`] is `#[non_exhaustive]`, so a member added upstream does not stop
/// this compiling.
///
/// The fallback is what that costs. A member added upstream lands here as a
/// bare `row-part`, which the sheet lets shrink like its siblings and otherwise
/// leaves plain: a thing rendering plainly rather than a build that stops. Grep
/// this function when adopting a new makeover-layout.
///
/// Public, because a row's parts are emitted by whoever builds the row element
/// and that is not always the same host: a table's cells are one emitter, but a
/// list row carries the app's identity and hooks, so a screen renderer writes
/// it. A renderer that reimplements this list rather than calling it takes on
/// the obligation above without knowing it.
#[must_use]
pub fn part_class(part: RowPart) -> &'static str {
    match part {
        RowPart::Primary => "row-primary",
        RowPart::Secondary => "row-secondary",
        RowPart::Meta => "row-meta",
        RowPart::Actions => "row-actions",
        RowPart::Tokens => "row-tokens",
        RowPart::Proportion => "row-proportion",
        _ => "row-part",
    }
}

/// The class for a cell part.
///
/// [`part_class`]'s table half, for [`CellPart`]. The fallback is there for the
/// same reason and costs the same thing: a member added upstream lands as a
/// bare `cell-part` with no rule of its own, rather than as a build that stops.
/// Grep this function too when adopting a new makeover-layout. Public for the
/// reason [`part_class`] is.
#[must_use]
pub fn cell_part_class(part: CellPart) -> &'static str {
    match part {
        CellPart::Value => "cell-value",
        CellPart::Tokens => "cell-tokens",
        CellPart::Actions => "cell-actions",
        CellPart::Link => "cell-link",
        _ => "cell-part",
    }
}

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

    /// A sizer is one hidden wrapper around the host's copies, and no copies
    /// writes nothing.
    #[test]
    fn a_sizer_wraps_its_copies_and_an_empty_one_writes_nothing() {
        let opts = Emit::default();
        let mut out = String::new();
        push_sizer(&mut out, Markup(""), &opts);
        assert_eq!(out, "");

        push_sizer(&mut out, Markup("<button>Remove</button>"), &opts);
        assert_eq!(
            out,
            "<span class=\"cell-sizer\" aria-hidden=\"true\"><button>Remove</button></span>"
        );
    }

    #[test]
    fn a_column_name_cannot_break_out_of_the_class_attribute() {
        // A name written raw would emit
        // `class="cell col-a" onclick="steal() cell-fill ...">`, a live
        // handler on every cell of the column.
        let mut out = String::new();
        push_column_name(&mut out, "a\" onclick=\"steal()");
        assert_eq!(out, "a--onclick--steal--");
        assert!(!out.contains('"'), "{out}");
    }

    #[test]
    fn the_class_and_the_selector_that_names_it_agree_on_the_name() {
        // The reason the fix is a filter and not an escape: one class, not the
        // two `col-Due date` parses as, and no entity a selector cannot match.
        let mut out = String::new();
        push_column_name(&mut out, "Due date");
        assert_eq!(out, "Due-date");
    }

    #[test]
    fn a_name_already_made_of_identifier_characters_is_untouched() {
        for name in ["description", "due", "progress", "Name", "col_2", "a-b"] {
            let mut out = String::new();
            push_column_name(&mut out, name);
            assert_eq!(out, name);
        }
    }

    #[test]
    fn a_name_outside_ascii_keeps_itself() {
        // CSS identifiers admit everything from U+00A0 up, so folding these to
        // dashes would collide two columns for nothing.
        let mut out = String::new();
        push_column_name(&mut out, "Größe");
        assert_eq!(out, "Größe");
    }
}