makeover_webview/figure.rs
1//! A figure with a caption, and a strip of them.
2//!
3//! The fourth phase-B emitter, for `makeover_layout::Figure`. Left to an app,
4//! one shape grows a class name per screen: `task-overview-stat`, `stat-box`,
5//! `month-stat-item`, `contact-summary-stat`, `sync-stat`.
6//!
7//! # Why the strip has its own function
8//!
9//! Four tiles in a row and four tiles down a column are different things, and a
10//! renderer handed one figure at a time cannot tell it is looking at a set. So
11//! the set is what gets emitted, and a lone figure is a set of one.
12//!
13//! # The reading order is markup, not CSS
14//!
15//! Visually the value is set large over a small caption, which is what four of
16//! the five sites drew. A screen reader meeting "17" before it knows what was
17//! counted has to hold the number until the noun arrives, so the figure carries
18//! its own accessible name ("Current Streak: 17") and the two spans are hidden
19//! from the reader that has already been told.
20//!
21//! Solving it that way rather than by inverting the markup and turning it back
22//! with `column-reverse` is deliberate: the arrangement and the type scale are
23//! the app's, and a renderer that emitted them would be naming sizes. Same line
24//! `meter_html` holds when it emits the tones and never the width.
25
26use crate::form::escape_into;
27use crate::{Emit, class, push_class};
28use makeover_layout::{Figure, Intent, Tone};
29use std::fmt::Write as _;
30
31/// Every class this module can put in markup.
32///
33/// [`crate::facet::FACET_CLASSES`]' obligation. `figures` is the strip around
34/// them, and the sheet lays it out as a wrapping row of tiles a section gap
35/// apart.
36pub const FIGURE_CLASSES: &[&str] = &[
37 "figures",
38 "figure",
39 "figure-value",
40 "figure-caption",
41 "figure-change",
42];
43
44/// The accessible name for a figure: the noun, then the number.
45///
46/// Built here rather than carried, for the reason [`meter_text`] is: a strip
47/// wants "Current Streak: 17" and a terminal at one line wants something else,
48/// and a description that shipped either would have chosen for both.
49///
50/// [`meter_text`]: crate::meter::meter_text
51#[must_use]
52pub fn figure_text(figure: &Figure<'_>) -> String {
53 figure.change.map_or_else(
54 || format!("{}: {}", figure.caption, figure.value),
55 // The delta reaches a reader as part of the one name, because the spans
56 // below are all `aria-hidden` and it would otherwise reach them not at
57 // all. "Views: 1,204, +12.5%" rather than a bare number after a comma.
58 |change| format!("{}: {}, {change}", figure.caption, figure.value),
59 )
60}
61
62/// One figure, as its own element.
63///
64/// ```
65/// use makeover_layout::{Figure, Tone};
66/// use makeover_webview::{Emit, figure::figure_html};
67///
68/// let figure = Figure::new("17", "Current Streak").tone(Tone::Success);
69/// let html = figure_html(&figure, &Emit::default());
70///
71/// assert!(html.contains(r#"data-tone="success""#));
72/// assert!(html.contains(r#"aria-label="Current Streak: 17""#));
73/// ```
74#[must_use]
75pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
76 let mut html = String::new();
77 figure_html_into(figure, opts, &mut html);
78 html
79}
80
81/// One figure, written into a buffer the caller already has.
82///
83/// [`figure_html`]'s streaming form, byte-identical to it. The accessible name
84/// is escaped a piece at a time rather than built and then escaped, which is
85/// the same output for one allocation fewer: the separators [`figure_text`]
86/// puts between the pieces contain nothing an escaper would encode.
87pub fn figure_html_into(figure: &Figure<'_>, opts: &Emit, out: &mut String) {
88 out.push_str("<div class=\"");
89 push_class(out, "figure", opts);
90 out.push_str("\" aria-label=\"");
91 escape_into(figure.caption, out);
92 out.push_str(": ");
93 escape_into(figure.value, out);
94 if let Some(change) = figure.change {
95 out.push_str(", ");
96 escape_into(change, out);
97 }
98 out.push('"');
99 // Neutral is the ordinary fact, and `figure_rules` styles the bare class
100 // for it. `data-tone="content-muted"` would match a rule that is not there.
101 if figure.tone != Tone::Neutral {
102 let _ = write!(out, " data-tone=\"{}\"", figure.tone.token());
103 }
104 // `aria-hidden` on both, because the element above has already said the
105 // whole thing. Without it a reader gets the number twice and the noun
106 // twice, in the order the eye wants rather than the order the ear does.
107 out.push_str("><span class=\"");
108 push_class(out, "figure-value", opts);
109 out.push_str("\" aria-hidden=\"true\">");
110 escape_into(figure.value, out);
111 out.push_str("</span><span class=\"");
112 push_class(out, "figure-caption", opts);
113 out.push_str("\" aria-hidden=\"true\">");
114 escape_into(figure.caption, out);
115 out.push_str("</span>");
116 // Its own element rather than more of the caption, so a stylesheet
117 // can set it smaller and a renderer with one line can drop it first. The
118 // tone is already on the wrapper and the rule keys off it from there, which
119 // is why the delta carries no `data-tone` of its own: two elements claiming
120 // one tone is how they end up disagreeing.
121 if let Some(change) = figure.change {
122 out.push_str("<span class=\"");
123 push_class(out, "figure-change", opts);
124 out.push_str("\" aria-hidden=\"true\">");
125 escape_into(change, out);
126 out.push_str("</span>");
127 }
128 out.push_str("</div>");
129}
130
131/// Several figures as one strip.
132///
133/// An empty set emits the container and nothing in it, for the reason a meter
134/// over nothing and a select with no options both render: it is what an app with
135/// an unloaded count actually has, and an empty strip says so on screen rather
136/// than in a log.
137#[must_use]
138pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
139 let mut html = String::new();
140 figures_html_into(figures, opts, &mut html);
141 html
142}
143
144/// Several figures as one strip, written into a buffer the caller already has.
145///
146/// [`figures_html`]'s streaming form, byte-identical to it. A strip is where a
147/// per-figure `String` would otherwise be paid for once per tile.
148pub fn figures_html_into(figures: &[Figure<'_>], opts: &Emit, out: &mut String) {
149 emit_figures(figures, opts, out, None);
150}
151
152/// Several figures as one strip, saying where each tile landed.
153///
154/// Byte-identical to [`figures_html_into`], and it appends one entry to
155/// `placed` per figure, in order: the offsets in `out` between which that
156/// figure's whole `<div>` was written. For a caller compiling this markup into
157/// a template; see [`crate::list::cells_html_placed`].
158pub fn figures_html_placed(
159 figures: &[Figure<'_>],
160 opts: &Emit,
161 out: &mut String,
162 placed: &mut Vec<core::ops::Range<usize>>,
163) {
164 emit_figures(figures, opts, out, Some(placed));
165}
166
167fn emit_figures(
168 figures: &[Figure<'_>],
169 opts: &Emit,
170 out: &mut String,
171 mut placed: Option<&mut Vec<core::ops::Range<usize>>>,
172) {
173 out.push_str("<div class=\"");
174 push_class(out, "figures", opts);
175 out.push_str("\">");
176 for figure in figures {
177 let at = out.len();
178 figure_html_into(figure, opts, out);
179 if let Some(placed) = placed.as_deref_mut() {
180 placed.push(at..out.len());
181 }
182 }
183 out.push_str("</div>");
184}
185
186pub(crate) fn figure_rules(opts: &Emit) -> String {
187 let figures = class("figures", opts);
188 let figure = class("figure", opts);
189 let value = class("figure-value", opts);
190 let caption = class("figure-caption", opts);
191 let change = class("figure-change", opts);
192 let mut css = String::new();
193
194 // A strip of tiles that wraps rather than overflows, a section apart. It
195 // was quasi-webview's arrangement sheet's to say, for a name this crate
196 // emits.
197 //
198 // **The strip is one object, and its cells are even.** It was a flex row
199 // with a gap and nothing else, so a set of figures read as its values and
200 // captions scattered along a line: every figure started where the last one
201 // ended, no column formed, and four numbers looked like eight loose words.
202 // That is the defect [`facts_rules`] already records for a labelled row,
203 // and the answer is the same one, alignment only the SET can give, which
204 // is why `Node::Stats` carries the set rather than each figure.
205 //
206 // A ground with its own air, sized to what it holds. The ground is what
207 // makes a set read as a set; the derived width is what stops it reading as
208 // a banner with its contents in one corner.
209 //
210 // `max-content` under `max-width: 100%` rather than `fit-content`, which
211 // says the same thing and is not a value this crate may name: the literal
212 // guard takes `max-content` as "a derived width and not one anybody chose"
213 // and refuses `fit-content`, and the pair comes to the same layout.
214 //
215 // Both halves were measured on MNW's waitlist at 1440, where the strip
216 // holds four figures and the page runs 1376px. Stretching the cells to
217 // share the line (`flex: 1 1 0`) put `4 Pending` and `15 Creators` at
218 // opposite ends of the screen with a third of the window empty between
219 // them, which trades scattered text for a scattered row. Letting the band
220 // run full width left the four figures huddled in its left corner. Cells at
221 // their natural width inside a band that hugs them is the one of the three
222 // that reads as a group.
223 //
224 // `max-width: 100%` so the band still yields at a narrow window: the cells
225 // wrap inside it rather than pushing the document sideways.
226 //
227 // The ground is a step of the theme's own neutral ramp, never a tone, and
228 // the fallback chain is the one this crate's header states for a well, so a
229 // theme naming no sunken surface gets a readable band rather than a missing
230 // colour. Not a raised surface per figure: that makes four cards out of a
231 // summary, and a summary is rarely the point of the page it sits on.
232 let _ = writeln!(
233 css,
234 ".{figures} {{\n display: flex;\n flex-wrap: wrap;\n gap: var(--gap-pane);\n \
235 padding: var(--gap-section);\n width: max-content;\n max-width: 100%;\n \
236 background: var(--surface-sunken, var(--surface-raised));\n}}"
237 );
238
239 // `content` literally. A figure's value is the thing itself, at full
240 // weight, wiki `three-tone-convention` classes it "active, emphasised",
241 // and both other renderers already draw it that way (makeover-tui
242 // `piece.rs:391` bold, makeover-immediate `widget.rs:244`). It reached
243 // here through `Tone::Neutral.token()` and came out `content-muted`, so
244 // the headline number sat at the colour of its own caption.
245 //
246 // Bold for the same reason, and the half that was still missing: goingson's
247 // project strip read "3 / Open tasks" with the number at the caption's size
248 // and weight, so the set did not lead with its values, while the terminal
249 // and the immediate host both drew them bold.
250 let _ = writeln!(
251 css,
252 ".{figure} > .{value} {{\n color: var(--content);\n font-weight: bold;\n}}"
253 );
254 let _ = writeln!(
255 css,
256 ".{figure} > .{caption} {{\n color: var(--content-muted);\n}}"
257 );
258 let _ = writeln!(
259 css,
260 ".{figure} > .{change} {{\n color: var(--content-muted);\n}}"
261 );
262
263 // A toned figure tones one part and never the caption. The caption is the
264 // noun and stays muted.
265 //
266 // Which part depends on whether there is a change, and that is the whole of
267 // what 0.13.0 changed here. A figure with a delta is an ordinary number that
268 // has moved in a direction worth reading, so the delta takes the colour and
269 // the number stays plain; a figure without one has nowhere else to put it.
270 // `:has` is what lets one attribute mean both, and the alternative was the
271 // emitter deciding by writing the attribute onto a different element, which
272 // leaves two elements able to disagree about a figure's one meaning.
273 // The tone is an edge, and neither number is drawn in it. Both were until
274 // 0.86.0, and both failed the same way `{tone}-surface`'s derivation
275 // comment describes: measured on goingson, a toned value read 1.24:1, 1.53:1
276 // and 2.27:1 against the ground it sits on. A figure's value is the
277 // largest text on the screen and the one most worth reading, so it keeps
278 // `content` whatever the tone is.
279 //
280 // An edge rather than the tinted fill a chip and a button take. A figure
281 // has no box of its own, it is a bare pair of spans in a flex strip,
282 // and giving the toned ones a fill would give a strip of figures two
283 // different shapes depending on which of them had news. The rule sits on
284 // the side so a neutral figure is untouched and the tint never has to exist.
285 //
286 // This also retires the `:has` split. It existed to choose which of the two
287 // elements took the colour; with neither taking it, a figure with a delta
288 // and a figure without one want the same rule, and the two elements can no
289 // longer disagree about one tone because neither states it.
290 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
291 let _ = writeln!(
292 css,
293 ".{figure}[data-tone=\"{0}\"] {{\n border-inline-start: {1} solid \
294 var(--{0});\n padding-inline-start: var(--step-snug);\n}}",
295 tone.token(),
296 opts.focus_width
297 );
298 }
299 css
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::form::escape;
306
307 #[test]
308 fn the_noun_reaches_a_reader_before_the_number() {
309 // The problem the accessible name solves. Visually the value comes
310 // first; a reader that met "17" first would have to hold it until it
311 // found out what was counted.
312 let figure = Figure::new("17", "Current Streak");
313 assert_eq!(figure_text(&figure), "Current Streak: 17");
314
315 let html = figure_html(&figure, &Emit::default());
316 assert!(html.contains(r#"aria-label="Current Streak: 17""#));
317 // And the spans are not read a second time in the other order.
318 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
319 }
320
321 #[test]
322 fn a_change_is_its_own_span_and_reaches_a_reader_through_the_name() {
323 // The spans are all `aria-hidden`, so a delta that is not in the
324 // accessible name reaches a screen reader not at all.
325 let figure = Figure::new("1,204", "Views").change("+12.5%");
326 assert_eq!(figure_text(&figure), "Views: 1,204, +12.5%");
327
328 let html = figure_html(&figure, &Emit::default());
329 assert!(html.contains("figure-change"), "{html}");
330 assert!(html.contains(">+12.5%<"), "{html}");
331 assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 3);
332
333 // A figure with nothing to compare against emits no empty span for it.
334 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
335 assert!(!plain.contains("figure-change"), "{plain}");
336 }
337
338 #[test]
339 fn a_change_carries_no_tone_of_its_own() {
340 // One element claims the figure's meaning and the sheet reaches the
341 // right span from there. Two would be two things able to disagree.
342 let html = figure_html(
343 &Figure::new("1,204", "Views")
344 .change("-4%")
345 .tone(Tone::Danger),
346 &Emit::default(),
347 );
348 assert_eq!(html.matches("data-tone").count(), 1, "{html}");
349 }
350
351 #[test]
352 fn a_change_is_text_and_cannot_become_markup() {
353 let html = figure_html(
354 &Figure::new("1", "Views").change("<img src=x onerror=alert(1)>"),
355 &Emit::default(),
356 );
357 assert!(!html.contains("<img"), "{html}");
358 }
359
360 #[test]
361 fn an_untoned_figure_emits_no_tone_attribute() {
362 // Same reason as the meter: the bare class is the untoned rule, so an
363 // attribute here would match nothing.
364 let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
365 assert!(!plain.contains("data-tone"));
366
367 let toned = figure_html(
368 &Figure::new("0", "Current Streak").tone(Tone::Warning),
369 &Emit::default(),
370 );
371 assert!(toned.contains(r#"data-tone="warning""#));
372 }
373
374 #[test]
375 fn a_value_and_a_caption_are_escaped_like_every_other_string() {
376 // Both arrive from the app, the same as a field label does.
377 let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
378 assert!(html.contains("a & b"));
379 assert!(html.contains("<b>"));
380 assert!(!html.contains("<b>"));
381 }
382
383 #[test]
384 fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
385 let html = figures_html(
386 &[
387 Figure::new("17", "Current Streak"),
388 Figure::new("84%", "Completion Rate"),
389 ],
390 &Emit::default(),
391 );
392 assert!(html.starts_with(r#"<div class="figures">"#));
393 assert_eq!(html.matches(r#"class="figure""#).count(), 2);
394 }
395
396 #[test]
397 fn an_empty_strip_renders_as_an_empty_strip() {
398 // Sayable, so it has to be emittable, and visibly empty rather than
399 // absent.
400 let html = figures_html(&[], &Emit::default());
401 assert_eq!(html, r#"<div class="figures"></div>"#);
402 assert!(!html.contains("figure-"));
403 }
404
405 /// The accessible name is assembled by [`figure_text`] in one form and
406 /// escaped a piece at a time in the other, so this is the assertion holding
407 /// those two readings of the same sentence together.
408 #[test]
409 fn a_streamed_figure_is_the_figure_the_other_form_returns() {
410 let opts = Emit {
411 class_prefix: "mo-",
412 ..Emit::default()
413 };
414 for figure in [
415 Figure::new("17", "Total"),
416 Figure::new("<b>3</b>", "a & b").tone(Tone::Danger),
417 Figure::new("1,204", "Views & co").change("+12.5% <up>"),
418 ] {
419 let mut streamed = String::new();
420 figure_html_into(&figure, &opts, &mut streamed);
421 assert_eq!(streamed, figure_html(&figure, &opts));
422 assert!(
423 streamed.contains(&format!("aria-label=\"{}\"", escape(&figure_text(&figure)))),
424 "{streamed}"
425 );
426
427 let mut strip = String::new();
428 figures_html_into(&[figure], &opts, &mut strip);
429 assert_eq!(strip, figures_html(&[figure], &opts));
430 }
431 }
432
433 #[test]
434 fn the_prefix_reaches_every_class() {
435 // A prefixed build claims its own names, and the two inner spans are
436 // descendant selectors in the emitted CSS.
437 let opts = Emit {
438 class_prefix: "mo-",
439 ..Emit::default()
440 };
441 let html = figures_html(&[Figure::new("17", "Total")], &opts);
442 assert!(html.contains(r#"class="mo-figures""#));
443 assert!(html.contains(r#"class="mo-figure""#));
444 assert!(html.contains(r#"class="mo-figure-value""#));
445 assert!(html.contains(r#"class="mo-figure-caption""#));
446 }
447}