makeover-webview 0.84.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
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
//! A run of magnitudes against one axis, rendered as bars.
//!
//! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
//! draws one proportion and computes the width from the pair it is handed, and
//! this draws a series and computes nothing. Both numbers reach the markup as
//! they were given, and the division happens in CSS.
//!
//! # Why the arithmetic is in the stylesheet
//!
//! Not taste, and not an optimisation. `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 a chart drawn from a width this crate computed could be described and
//! could not be compiled, which for MNW's revenue chart is the difference
//! between a screen on the seam and the one screen left off it.
//! `--value` and `--most` are printed with `{}` and reach the markup as
//! themselves, and `chart_rules` divides them where a browser can.
//!
//! It costs nothing and reads better: the DOM carries the two real numbers
//! rather than a percentage with nothing behind it, which is
//! [`makeover_layout::Chart`]'s own argument for carrying the pair.
//!
//! Both ride in [`VARS_ATTR`] rather than in `style`, so a page whose policy
//! refuses style attributes still draws its bars.

use crate::form::escape_into;
use crate::{Depth, Emit, VARS_ATTR, class, depth_rule, gated, hover_condition, push_class};
use makeover_layout::{Bar, Chart, Intent, Tone};
use std::fmt::Write as _;

/// Every class this module can put in markup.
///
/// [`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 bar says when a pointer rests on it, or nothing.
///
/// The reading and the note, in that order, joined the way the description did
/// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a
/// terminal at one line and a tooltip can want different sentences, which is
/// [`crate::meter::meter_text`]'s split exactly.
///
/// The place on the axis is deliberately not in here. It is drawn under the bar
/// as its own label, so repeating it in the tooltip is the readout arguing with
/// itself.
#[must_use]
pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
    match (bar.reading, bar.note) {
        (Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
        (Some(only), None) | (None, Some(only)) => Some(only.to_string()),
        (None, None) => None,
    }
}

/// A chart as a run of bars, written into a buffer the caller already has.
///
/// The bars arrive as an iterator rather than a slice so a caller holding owned
/// bars can map them through without building a second `Vec`, which is how
/// `quasi-webview` holds a `Vec<screen::Bar>` and this wants
/// [`makeover_layout::Bar`].
///
/// An empty axis draws its container and no bars. A chart over nothing is
/// sayable on purpose -- see [`Chart::is_empty`] -- and drawing the frame says
/// so on screen, where dividing by the axis would put `NaN` in a length.
pub fn chart_html_into<'a>(
    chart: &Chart<'_>,
    bars: impl IntoIterator<Item = Bar<'a>>,
    opts: &Emit,
    out: &mut String,
) {
    emit_chart(chart, bars, opts, out, None);
}

/// A chart, saying where each bar landed.
///
/// Byte-identical to [`chart_html_into`], and it appends one entry to `placed`
/// per bar, in order: the offsets in `out` between which that bar's whole
/// column was written. See [`crate::list::cells_html_placed`], which exists for
/// the same reason and says it at length: a caller compiling this markup into a
/// template has to know which bytes one bar produced, and the writer is the
/// only source for that which cannot be wrong.
pub fn chart_html_placed<'a>(
    chart: &Chart<'_>,
    bars: impl IntoIterator<Item = Bar<'a>>,
    opts: &Emit,
    out: &mut String,
    placed: &mut Vec<core::ops::Range<usize>>,
) {
    emit_chart(chart, bars, opts, out, Some(placed));
}

fn emit_chart<'a>(
    chart: &Chart<'_>,
    bars: impl IntoIterator<Item = Bar<'a>>,
    opts: &Emit,
    out: &mut String,
    mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
) {
    out.push_str("<div class=\"");
    push_class(out, "chart", opts);
    out.push('"');
    // The axis, once, on the container the bars read it from. Stated here and
    // not per bar because it is one fact about the chart, and a fact repeated
    // per bar is one the copies can disagree about.
    let _ = write!(out, " {VARS_ATTR}=\"--most: {}\"", chart.most);
    if chart.tone != Tone::Neutral {
        let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
    }
    // `role="img"` only where there is a name for it. The role tells a screen
    // reader to announce this as one thing instead of reading the bars, and an
    // unnamed one announces nothing at all -- worse than the group of labelled
    // readouts the markup already is. So the role and the name arrive together
    // or neither does, and a description that wants the chart announced says
    // what the magnitudes are.
    if let Some(label) = chart.label {
        out.push_str(" role=\"img\" aria-label=\"");
        escape_into(label, out);
        out.push('"');
    }
    out.push('>');

    out.push_str("<div class=\"");
    push_class(out, "chart-bars", opts);
    out.push_str("\">");

    for bar in bars {
        let at = out.len();
        bar_html_into(&bar, opts, out);
        if let Some(placed) = placed.as_deref_mut() {
            placed.push(at..out.len());
        }
    }

    out.push_str("</div></div>");
}

/// One bar and its label.
///
/// Split out because the loop over bars is the loop a compiled template holds,
/// so what one pass emits is worth being able to read on its own.
fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
    out.push_str("<div class=\"");
    push_class(out, "chart-bar-col", opts);
    out.push('"');
    if let Some(text) = bar_text(bar) {
        out.push_str(" data-tooltip=\"");
        escape_into(&text, out);
        out.push('"');
    }
    out.push('>');

    out.push_str("<div class=\"");
    push_class(out, "chart-bar", opts);
    // The magnitude as it was handed over. See the module header for why this
    // is not a width.
    let _ = write!(out, "\" {VARS_ATTR}=\"--value: {}\"></div>", bar.value);

    out.push_str("<div class=\"");
    push_class(out, "chart-bar-label", opts);
    out.push_str("\">");
    escape_into(bar.at, out);
    out.push_str("</div></div>");
}

/// A chart as a returned string.
#[must_use]
pub fn chart_html<'a>(
    chart: &Chart<'_>,
    bars: impl IntoIterator<Item = Bar<'a>>,
    opts: &Emit,
) -> String {
    let mut html = String::new();
    chart_html_into(chart, bars, opts, &mut html);
    html
}

/// 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.
///
/// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
/// dividing by it makes the whole declaration invalid at computed-value time,
/// which drops the height to `auto` -- in a flex column, a bar of full height.
/// Clamping the divisor draws every bar at nothing, which is what an empty axis
/// means.
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    \
         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: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\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 [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
        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}}"
    );

    // The readout, revealed from the attribute the markup already carries.
    //
    // Gated, because it is a hover state and this crate asks
    // `makeover-touch` 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: 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"
        ),
    ));
    css
}

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

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

    fn axis() -> Chart<'static> {
        Chart::new(6740).label("revenue")
    }

    /// The two numbers reach the markup as themselves. This is the whole reason
    /// the member is shaped the way it is, so it is asserted rather than
    /// assumed: a width computed here would compile into a template as a
    /// constant and serve one request's chart to everybody.
    #[test]
    fn both_numbers_are_printed_and_neither_is_divided() {
        let html = chart_html(
            &axis(),
            [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
            &Emit::default(),
        );
        assert!(html.contains("--most: 6740"), "{html}");
        assert!(html.contains("--value: 4210"), "{html}");
        assert!(
            !html.contains('%'),
            "a percentage reached the markup: {html}"
        );
    }

    /// The role and the name arrive together or neither does. An unnamed
    /// `role="img"` announces nothing, which is worse than the labelled
    /// readouts the markup already is.
    #[test]
    fn an_unlabelled_chart_claims_no_role() {
        let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
        assert!(
            named.contains(r#"role="img" aria-label="revenue""#),
            "{named}"
        );

        let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
        assert!(!bare.contains("role="), "{bare}");
        assert!(!bare.contains("aria-label"), "{bare}");
    }

    /// A bar says both facts or the one it has, and a bar with neither draws no
    /// tooltip rather than an empty one.
    #[test]
    fn a_readout_is_what_the_bar_was_given() {
        assert_eq!(
            bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
            Some("$1 / 2 sales".to_string())
        );
        assert_eq!(
            bar_text(&Bar::at("a").of(1).reading("$1")),
            Some("$1".to_string())
        );
        assert_eq!(
            bar_text(&Bar::at("a").of(1).note("2 sales")),
            Some("2 sales".to_string())
        );
        assert_eq!(bar_text(&Bar::at("a").of(1)), None);

        let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
        assert!(!bare.contains("data-tooltip"), "{bare}");
    }

    /// Everything a request brings goes through the escaper, in the text and in
    /// the attribute. A label reaching a chart from a database is why.
    #[test]
    fn a_label_and_a_readout_are_escaped() {
        let html = chart_html(
            &Chart::new(10).label("a & b"),
            [Bar::at("<script>").of(5).reading("\"x\"")],
            &Emit::default(),
        );
        assert!(!html.contains("<script>"), "{html}");
        assert!(html.contains("&lt;script&gt;"), "{html}");
        assert!(html.contains("a &amp; b"), "{html}");
        assert!(html.contains("&quot;x&quot;"), "{html}");
    }

    /// No style attribute, because a `style-src` without `'unsafe-inline'`
    /// refuses every one and a chart under it drew flat.
    #[test]
    fn the_numbers_ride_in_the_vars_attribute_and_not_in_style() {
        let html = chart_html(&axis(), [Bar::at("Mar 3").of(4210)], &Emit::default());
        assert!(!html.contains("style="), "{html}");
        assert!(html.contains(r#"data-vars="--most: 6740""#), "{html}");
        assert!(html.contains(r#"data-vars="--value: 4210""#), "{html}");
    }

    /// An axis of zero draws its frame and its bars, and the stylesheet is what
    /// keeps them at nothing. Drawing no frame would be a screen that says
    /// nothing where it has nothing, which is the empty state's job and not
    /// this one's.
    #[test]
    fn an_empty_axis_draws_rather_than_dividing() {
        let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
        assert!(html.contains("--most: 0"), "{html}");
        assert!(html.contains("--value: 0"), "{html}");
    }

    /// The streamed form and the returned one are the same bytes, which is the
    /// obligation every other emitter here carries.
    #[test]
    fn the_streamed_form_is_the_returned_one() {
        let opts = Emit {
            class_prefix: "mk-",
            ..Emit::default()
        };
        let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
        let mut streamed = String::new();
        chart_html_into(&axis(), bars, &opts, &mut streamed);
        assert_eq!(streamed, chart_html(&axis(), bars, &opts));
    }

    /// Every class the emitter can write carries a rule, which is what
    /// `CHART_CLASSES` exists to keep true.
    #[test]
    fn every_class_this_module_writes_has_a_rule() {
        let css = chart_rules(&Emit::default());
        for name in CHART_CLASSES {
            assert!(css.contains(&format!(".{name}")), "{name} has no rule");
        }
    }
}