makeover-webview 0.98.7

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! A figure with a caption, and a strip of them.
//!
//! The fourth phase-B emitter, for `makeover_layout::Figure`. Left to an app,
//! one shape grows a class name per screen: `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_into;
use crate::{Emit, class, push_class};
use makeover_layout::{Figure, Intent, Tone};
use std::fmt::Write as _;

/// Every class this module can put in markup.
///
/// [`crate::facet::FACET_CLASSES`]' obligation. `figures` is the strip around
/// them, and the sheet lays it out as a wrapping row of tiles a section gap
/// apart.
pub const FIGURE_CLASSES: &[&str] = &[
    "figures",
    "figure",
    "figure-value",
    "figure-caption",
    "figure-change",
];

/// 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 {
    figure.change.map_or_else(
        || format!("{}: {}", figure.caption, figure.value),
        // The delta reaches a reader as part of the one name, because the spans
        // below are all `aria-hidden` and it would otherwise reach them not at
        // all. "Views: 1,204, +12.5%" rather than a bare number after a comma.
        |change| format!("{}: {}, {change}", 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 = String::new();
    figure_html_into(figure, opts, &mut html);
    html
}

/// One figure, written into a buffer the caller already has.
///
/// [`figure_html`]'s streaming form, byte-identical to it. The accessible name
/// is escaped a piece at a time rather than built and then escaped, which is
/// the same output for one allocation fewer: the separators [`figure_text`]
/// puts between the pieces contain nothing an escaper would encode.
pub fn figure_html_into(figure: &Figure<'_>, opts: &Emit, out: &mut String) {
    out.push_str("<div class=\"");
    push_class(out, "figure", opts);
    out.push_str("\" aria-label=\"");
    escape_into(figure.caption, out);
    out.push_str(": ");
    escape_into(figure.value, out);
    if let Some(change) = figure.change {
        out.push_str(", ");
        escape_into(change, out);
    }
    out.push('"');
    // 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!(out, " 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.
    out.push_str("><span class=\"");
    push_class(out, "figure-value", opts);
    out.push_str("\" aria-hidden=\"true\">");
    escape_into(figure.value, out);
    out.push_str("</span><span class=\"");
    push_class(out, "figure-caption", opts);
    out.push_str("\" aria-hidden=\"true\">");
    escape_into(figure.caption, out);
    out.push_str("</span>");
    // Its own element rather than more of the caption, so a stylesheet
    // can set it smaller and a renderer with one line can drop it first. The
    // tone is already on the wrapper and the rule keys off it from there, which
    // is why the delta carries no `data-tone` of its own: two elements claiming
    // one tone is how they end up disagreeing.
    if let Some(change) = figure.change {
        out.push_str("<span class=\"");
        push_class(out, "figure-change", opts);
        out.push_str("\" aria-hidden=\"true\">");
        escape_into(change, out);
        out.push_str("</span>");
    }
    out.push_str("</div>");
}

/// 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 = String::new();
    figures_html_into(figures, opts, &mut html);
    html
}

/// Several figures as one strip, written into a buffer the caller already has.
///
/// [`figures_html`]'s streaming form, byte-identical to it. A strip is where a
/// per-figure `String` would otherwise be paid for once per tile.
pub fn figures_html_into(figures: &[Figure<'_>], opts: &Emit, out: &mut String) {
    emit_figures(figures, opts, out, None);
}

/// Several figures as one strip, saying where each tile landed.
///
/// Byte-identical to [`figures_html_into`], and it appends one entry to
/// `placed` per figure, in order: the offsets in `out` between which that
/// figure's whole `<div>` was written. For a caller compiling this markup into
/// a template; see [`crate::list::cells_html_placed`].
pub fn figures_html_placed(
    figures: &[Figure<'_>],
    opts: &Emit,
    out: &mut String,
    placed: &mut Vec<core::ops::Range<usize>>,
) {
    emit_figures(figures, opts, out, Some(placed));
}

fn emit_figures(
    figures: &[Figure<'_>],
    opts: &Emit,
    out: &mut String,
    mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
    out.push_str("<div class=\"");
    push_class(out, "figures", opts);
    out.push_str("\">");
    for figure in figures {
        let at = out.len();
        figure_html_into(figure, opts, out);
        if let Some(placed) = placed.as_deref_mut() {
            placed.push(at..out.len());
        }
    }
    out.push_str("</div>");
}

pub(crate) fn figure_rules(opts: &Emit) -> String {
    let figures = class("figures", opts);
    let figure = class("figure", opts);
    let value = class("figure-value", opts);
    let caption = class("figure-caption", opts);
    let change = class("figure-change", opts);
    let mut css = String::new();

    // A strip of tiles that wraps rather than overflows, a section apart. It
    // was quasi-webview's arrangement sheet's to say, for a name this crate
    // emits.
    //
    // **The strip is one object, and its cells are even.** It was a flex row
    // with a gap and nothing else, so a set of figures read as its values and
    // captions scattered along a line: every figure started where the last one
    // ended, no column formed, and four numbers looked like eight loose words.
    // That is the defect [`facts_rules`] already records for a labelled row,
    // and the answer is the same one, alignment only the SET can give, which
    // is why `Node::Stats` carries the set rather than each figure.
    //
    // A ground with its own air, sized to what it holds. The ground is what
    // makes a set read as a set; the derived width is what stops it reading as
    // a banner with its contents in one corner.
    //
    // `max-content` under `max-width: 100%` rather than `fit-content`, which
    // says the same thing and is not a value this crate may name: the literal
    // guard takes `max-content` as "a derived width and not one anybody chose"
    // and refuses `fit-content`, and the pair comes to the same layout.
    //
    // Both halves were measured on MNW's waitlist at 1440, where the strip
    // holds four figures and the page runs 1376px. Stretching the cells to
    // share the line (`flex: 1 1 0`) put `4 Pending` and `15 Creators` at
    // opposite ends of the screen with a third of the window empty between
    // them, which trades scattered text for a scattered row. Letting the band
    // run full width left the four figures huddled in its left corner. Cells at
    // their natural width inside a band that hugs them is the one of the three
    // that reads as a group.
    //
    // `max-width: 100%` so the band still yields at a narrow window: the cells
    // wrap inside it rather than pushing the document sideways.
    //
    // The ground is a step of the theme's own neutral ramp, never a tone, and
    // the fallback chain is the one this crate's header states for a well, so a
    // theme naming no sunken surface gets a readable band rather than a missing
    // colour. Not a raised surface per figure: that makes four cards out of a
    // summary, and a summary is rarely the point of the page it sits on.
    let _ = writeln!(
        css,
        ".{figures} {{\n    display: flex;\n    flex-wrap: wrap;\n    gap: var(--gap-pane);\n    \
         padding: var(--gap-section);\n    width: max-content;\n    max-width: 100%;\n    \
         background: var(--surface-sunken, var(--surface-raised));\n}}"
    );

    // `content` literally. A figure's value is the thing itself, at full
    // weight, wiki `three-tone-convention` classes it "active, emphasised",
    // and both other renderers already draw it that way (makeover-tui
    // `piece.rs:391` bold, makeover-immediate `widget.rs:244`). It reached
    // here through `Tone::Neutral.token()` and came out `content-muted`, so
    // the headline number sat at the colour of its own caption.
    //
    // Bold for the same reason, and the half that was still missing: goingson's
    // project strip read "3 / Open tasks" with the number at the caption's size
    // and weight, so the set did not lead with its values, while the terminal
    // and the immediate host both drew them bold.
    let _ = writeln!(
        css,
        ".{figure} > .{value} {{\n    color: var(--content);\n    font-weight: bold;\n}}"
    );
    let _ = writeln!(
        css,
        ".{figure} > .{caption} {{\n    color: var(--content-muted);\n}}"
    );
    let _ = writeln!(
        css,
        ".{figure} > .{change} {{\n    color: var(--content-muted);\n}}"
    );

    // A toned figure tones one part and never the caption. The caption is the
    // noun and stays muted.
    //
    // Which part depends on whether there is a change, and that is the whole of
    // what 0.13.0 changed here. A figure with a delta is an ordinary number that
    // has moved in a direction worth reading, so the delta takes the colour and
    // the number stays plain; a figure without one has nowhere else to put it.
    // `:has` is what lets one attribute mean both, and the alternative was the
    // emitter deciding by writing the attribute onto a different element, which
    // leaves two elements able to disagree about a figure's one meaning.
    // The tone is an edge, and neither number is drawn in it. Both were until
    // 0.86.0, and both failed the same way `{tone}-surface`'s derivation
    // comment describes: measured on goingson, a toned value read 1.24:1, 1.53:1
    // and 2.27:1 against the ground it sits on. A figure's value is the
    // largest text on the screen and the one most worth reading, so it keeps
    // `content` whatever the tone is.
    //
    // An edge rather than the tinted fill a chip and a button take. A figure
    // has no box of its own, it is a bare pair of spans in a flex strip,
    // and giving the toned ones a fill would give a strip of figures two
    // different shapes depending on which of them had news. The rule sits on
    // the side so a neutral figure is untouched and the tint never has to exist.
    //
    // This also retires the `:has` split. It existed to choose which of the two
    // elements took the colour; with neither taking it, a figure with a delta
    // and a figure without one want the same rule, and the two elements can no
    // longer disagree about one tone because neither states it.
    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
        let _ = writeln!(
            css,
            ".{figure}[data-tone=\"{0}\"] {{\n    border-inline-start: {1} solid \
             var(--{0});\n    padding-inline-start: var(--step-snug);\n}}",
            tone.token(),
            opts.focus_width
        );
    }
    css
}

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

    #[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 a_change_is_its_own_span_and_reaches_a_reader_through_the_name() {
        // The spans are all `aria-hidden`, so a delta that is not in the
        // accessible name reaches a screen reader not at all.
        let figure = Figure::new("1,204", "Views").change("+12.5%");
        assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%");

        let html = figure_html(&figure, &Emit::default());
        assert!(html.contains("figure-change"), "{html}");
        assert!(html.contains(">+12.5%<"), "{html}");
        assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3);

        // A figure with nothing to compare against emits no empty span for it.
        let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
        assert!(!plain.contains("figure-change"), "{plain}");
    }

    #[test]
    fn a_change_carries_no_tone_of_its_own() {
        // One element claims the figure's meaning and the sheet reaches the
        // right span from there. Two would be two things able to disagree.
        let html = figure_html(
            &Figure::new("1,204", "Views")
                .change("-4%")
                .tone(Tone::Danger),
            &Emit::default(),
        );
        assert_eq!(html.matches("data-tone").count(), 1, "{html}");
    }

    #[test]
    fn a_change_is_text_and_cannot_become_markup() {
        let html = figure_html(
            &Figure::new("1", "Views").change("<img src=x onerror=alert(1)>"),
            &Emit::default(),
        );
        assert!(!html.contains("<img"), "{html}");
    }

    #[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-"));
    }

    /// The accessible name is assembled by [`figure_text`] in one form and
    /// escaped a piece at a time in the other, so this is the assertion holding
    /// those two readings of the same sentence together.
    #[test]
    fn a_streamed_figure_is_the_figure_the_other_form_returns() {
        let opts = Emit {
            class_prefix: "mo-",
            ..Emit::default()
        };
        for figure in [
            Figure::new("17", "Total"),
            Figure::new("<b>3</b>", "a & b").tone(Tone::Danger),
            Figure::new("1,204", "Views & co").change("+12.5% <up>"),
        ] {
            let mut streamed = String::new();
            figure_html_into(&figure, &opts, &mut streamed);
            assert_eq!(streamed, figure_html(&figure, &opts));
            assert!(
                streamed.contains(&format!("aria-label=\"{}\"", escape(&figure_text(&figure)))),
                "{streamed}"
            );

            let mut strip = String::new();
            figures_html_into(&[figure], &opts, &mut strip);
            assert_eq!(strip, figures_html(&[figure], &opts));
        }
    }

    #[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""#));
    }
}