Skip to main content

max / makeover-webview

0.20.0: a figure emitter, a row proportion, field constraints, a file input
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:48 UTC
Signed with PGP, not checked
Commit: 83824e1b4bbd56ba54183ac6d9bdc54b451a3f87
Parent: 935eabf
5 files changed, +303 insertions, -4 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.19.0"
3 + version = "0.20.0"
4 4 edition = "2024"
5 5 description = "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."
6 6 license = "MIT"
@@ -11,7 +11,7 @@
11 11 # in 0.8.2. Declared as "0.8" from 0.16.1, where the radio landed, so a consumer
12 12 # whose lock already held 0.8.0 got a resolve that satisfied the pin and failed
13 13 # to compile. makeover-build is where that surfaced, one release later.
14 - makeover-layout = "0.10.0"
14 + makeover-layout = "0.11.0"
15 15 # The capability axis. `makeover-touch` decides whether a hover rule should be
16 16 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
17 17 # answers are owned elsewhere and neither is re-derived here.
M src/form.rs +74
@@ -157,6 +157,7 @@
157 157 FieldKind::Secret => "password",
158 158 FieldKind::Number => "number",
159 159 FieldKind::Checkbox => "checkbox",
160 + FieldKind::File => "file",
160 161 FieldKind::Hidden => "hidden",
161 162 // Not decoration. Each of these changes the keyboard a touch device
162 163 // offers and turns on the platform's own validation, which is why the
@@ -194,6 +195,19 @@
194 195 if field.required {
195 196 attrs.push_str(" required");
196 197 }
198 + // makeover-layout 0.11.0's constraints. The description carries the rule and
199 + // this emits the browser's idiom for it, which is the model `required` has
200 + // been using since before the crate wrote down that it carried none.
201 + // Enforcement is still whoever validated's, and arrives back as `error`.
202 + if let Some(limit) = field.max_length {
203 + let _ = write!(attrs, " maxlength=\"{limit}\"");
204 + }
205 + if let Some(min) = field.min {
206 + let _ = write!(attrs, " min=\"{}\"", escape(min));
207 + }
208 + if let Some(max) = field.max {
209 + let _ = write!(attrs, " max=\"{}\"", escape(max));
210 + }
197 211 if field.invalid() {
198 212 attrs.push_str(" aria-invalid=\"true\"");
199 213 }
@@ -357,6 +371,13 @@
357 371 FieldKind::Secret => {
358 372 format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>")
359 373 }
374 + // A file input carries no value, and this is the browser's rule rather
375 + // than a preference: setting one from markup is refused, because a page
376 + // that could preselect a path could read a file the user never offered.
377 + // Nothing upstream needs to know, which is why the exception is here.
378 + FieldKind::File => {
379 + format!("<input type=\"file\" class=\"{field_class}\"{attrs}>")
380 + }
360 381 kind => format!(
361 382 "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
362 383 input_type(kind),
@@ -813,6 +834,59 @@
813 834 /// These three exist so a touch keyboard and the platform's validation
814 835 /// arrive with the field. Emitting text for any of them is the regression
815 836 /// the variants were added to prevent, so the type is asserted directly.
837 + #[test]
838 + fn a_constraint_becomes_the_browsers_own_attribute() {
839 + // makeover-layout 0.11.0's model: the description carries the rule and
840 + // each renderer emits its host's idiom for it. Enforcement is still
841 + // whoever validated's, and arrives back as `error`.
842 + let html = field_html(
843 + &Field {
844 + max_length: Some(100),
845 + min: Some("1"),
846 + max: Some("240"),
847 + required: true,
848 + ..Field::new(FieldKind::Number, "minutes", "Minutes")
849 + },
850 + &Filling::default(),
851 + &Emit::default(),
852 + );
853 + assert!(html.contains(r#"maxlength="100""#));
854 + assert!(html.contains(r#"min="1""#));
855 + assert!(html.contains(r#"max="240""#));
856 + assert!(html.contains(" required"));
857 + }
858 +
859 + #[test]
860 + fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
861 + // The bound is text because it is only a number for some of the kinds
862 + // that take one; goingson's own sites are a duration and a datetime.
863 + let html = field_html(
864 + &Field {
865 + min: Some("2026-08-09T14:30"),
866 + ..Field::new(FieldKind::Text, "starts", "Starts")
867 + },
868 + &Filling::default(),
869 + &Emit::default(),
870 + );
871 + assert!(html.contains(r#"min="2026-08-09T14:30""#));
872 + }
873 +
874 + #[test]
875 + fn a_file_field_is_a_file_input() {
876 + // `844b5ae0`. It carries no `accept`, which is measured rather than
877 + // deferred: zero sites in either app.
878 + let html = field_html(
879 + &Field::new(FieldKind::File, "attachment", "Attachment"),
880 + &Filling::default(),
881 + &Emit::default(),
882 + );
883 + assert!(html.contains(r#"type="file""#));
884 + assert!(!html.contains("accept="));
885 + // And it never carries a value: a file input's value is not settable
886 + // from markup, and the browser refuses one that tries.
887 + assert!(!html.contains("value="));
888 + }
889 +
816 890 #[test]
817 891 fn the_typed_text_kinds_keep_their_input_type() {
818 892 for (kind, expected) in [
M src/lib.rs +50 -2
@@ -193,6 +193,7 @@
193 193
194 194 #![forbid(unsafe_code)]
195 195
196 + pub mod figure;
196 197 pub mod form;
197 198 pub mod list;
198 199 pub mod meter;
@@ -667,6 +668,7 @@
667 668 RowPart::Meta,
668 669 RowPart::Actions,
669 670 RowPart::Tokens,
671 + RowPart::Proportion,
670 672 ] {
671 673 let c = class(part_class(part), opts);
672 674
@@ -674,8 +676,12 @@
674 676 // so by returning the same intent inheriting already gives. Pinning it
675 677 // would be louder than saying nothing. Tokens answer alike, for their
676 678 // own reason: each token carries its own tone, and a colour on the
677 - // strip would fight the things sitting in it.
678 - if !matches!(part, RowPart::Actions | RowPart::Tokens) {
679 + // strip would fight the things sitting in it. A proportion is the same
680 + // case again: the meter inside carries the tone.
681 + if !matches!(
682 + part,
683 + RowPart::Actions | RowPart::Tokens | RowPart::Proportion
684 + ) {
679 685 let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent());
680 686 }
681 687
@@ -775,6 +781,47 @@
775 781 css
776 782 }
777 783
784 + /// A strip of figures, and the two spans inside each one.
785 + ///
786 + /// Colour only, which is the deferral rule applied to a component that badly
787 + /// wants to break it. A figure reads as a figure because the value is set large
788 + /// over a small caption, and that is a size: `makeover-geometry` answers how
789 + /// much space and this crate answers what the thing is. Emitting `font-size`
790 + /// here would be this crate naming a value, which is the one thing it is defined
791 + /// by not doing, and `progress_rules` is the precedent — it emits the tones and
792 + /// never the width, because the width is not its to know.
793 + ///
794 + /// So the arrangement and the type scale are the app's, and what is generated is
795 + /// the part an app cannot get right by itself: which of the two spans carries
796 + /// the tone.
797 + fn figure_rules(opts: &Emit) -> String {
798 + let figure = class("figure", opts);
799 + let value = class("figure-value", opts);
800 + let caption = class("figure-caption", opts);
801 + let mut css = String::new();
802 +
803 + let _ = writeln!(
804 + css,
805 + ".{figure} > .{value} {{\n color: var(--{});\n}}",
806 + Tone::Neutral.token()
807 + );
808 + let _ = writeln!(
809 + css,
810 + ".{figure} > .{caption} {{\n color: var(--content-muted);\n}}"
811 + );
812 +
813 + // A toned figure tones the value and never the caption. The caption is the
814 + // noun and stays muted; the number is the thing that is saying something.
815 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
816 + let _ = writeln!(
817 + css,
818 + ".{figure}[data-tone=\"{0}\"] > .{value} {{\n color: var(--{0});\n}}",
819 + tone.token()
820 + );
821 + }
822 + css
823 + }
824 +
778 825 /// The component layer: every named thing phase A emits.
779 826 ///
780 827 /// No scrollbar track. It was on the phase A list and came off: eight lines of
@@ -788,6 +835,7 @@
788 835 css.push_str(&selector_rules(opts));
789 836 css.push_str(&row_rules(opts));
790 837 css.push_str(&progress_rules(opts));
838 + css.push_str(&figure_rules(opts));
791 839 css
792 840 }
793 841
@@ -205,6 +205,7 @@
205 205 RowPart::Meta => "row-meta",
206 206 RowPart::Actions => "row-actions",
207 207 RowPart::Tokens => "row-tokens",
208 + RowPart::Proportion => "row-proportion",
208 209 _ => "row-part",
209 210 }
210 211 }
A src/figure.rs +176
@@ -1,0 +1,176 @@
1 + //! A figure with a caption, and a strip of them.
2 + //!
3 + //! The fourth phase-B emitter. `makeover_layout::Figure` arrived at 0.11.0 after
4 + //! goingson turned out to have five of these across five screens, each with its
5 + //! own class names for the one shape: `task-overview-stat`, `stat-box`,
6 + //! `month-stat-item`, `contact-summary-stat`, `sync-stat`.
7 + //!
8 + //! # Why the strip has its own function
9 + //!
10 + //! Four tiles in a row and four tiles down a column are different things, and a
11 + //! renderer handed one figure at a time cannot tell it is looking at a set. So
12 + //! the set is what gets emitted, and a lone figure is a set of one.
13 + //!
14 + //! # The reading order is markup, not CSS
15 + //!
16 + //! Visually the value is set large over a small caption, which is what four of
17 + //! the five sites drew. A screen reader meeting "17" before it knows what was
18 + //! counted has to hold the number until the noun arrives, so the figure carries
19 + //! its own accessible name — "Current Streak: 17" — and the two spans are hidden
20 + //! from the reader that has already been told.
21 + //!
22 + //! Solving it that way rather than by inverting the markup and turning it back
23 + //! with `column-reverse` is deliberate: the arrangement and the type scale are
24 + //! the app's, and a renderer that emitted them would be naming sizes. Same line
25 + //! `meter_html` holds when it emits the tones and never the width.
26 +
27 + use crate::form::escape;
28 + use crate::{Emit, class};
29 + use makeover_layout::{Figure, Intent, Tone};
30 + use std::fmt::Write as _;
31 +
32 + /// The accessible name for a figure: the noun, then the number.
33 + ///
34 + /// Built here rather than carried, for the reason [`meter_text`] is: a strip
35 + /// wants "Current Streak: 17" and a terminal at one line wants something else,
36 + /// and a description that shipped either would have chosen for both.
37 + ///
38 + /// [`meter_text`]: crate::meter::meter_text
39 + #[must_use]
40 + pub fn figure_text(figure: &Figure<'_>) -> String {
41 + format!("{}: {}", figure.caption, figure.value)
42 + }
43 +
44 + /// One figure, as its own element.
45 + ///
46 + /// ```
47 + /// use makeover_layout::{Figure, Tone};
48 + /// use makeover_webview::{Emit, figure::figure_html};
49 + ///
50 + /// let figure = Figure::new("17", "Current Streak").tone(Tone::Success);
51 + /// let html = figure_html(&figure, &Emit::default());
52 + ///
53 + /// assert!(html.contains(r#"data-tone="success""#));
54 + /// assert!(html.contains(r#"aria-label="Current Streak: 17""#));
55 + /// ```
56 + #[must_use]
57 + pub fn figure_html(figure: &Figure<'_>, opts: &Emit) -> String {
58 + let mut html = format!(
59 + "<div class=\"{}\" aria-label=\"{}\"",
60 + class("figure", opts),
61 + escape(&figure_text(figure))
62 + );
63 + // Neutral is the ordinary fact, and `figure_rules` styles the bare class
64 + // for it. `data-tone="content-muted"` would match a rule that is not there.
65 + if figure.tone != Tone::Neutral {
66 + let _ = write!(html, " data-tone=\"{}\"", figure.tone.token());
67 + }
68 + // `aria-hidden` on both, because the element above has already said the
69 + // whole thing. Without it a reader gets the number twice and the noun
70 + // twice, in the order the eye wants rather than the order the ear does.
71 + let _ = write!(
72 + html,
73 + "><span class=\"{}\" aria-hidden=\"true\">{}</span>\
74 + <span class=\"{}\" aria-hidden=\"true\">{}</span></div>",
75 + class("figure-value", opts),
76 + escape(figure.value),
77 + class("figure-caption", opts),
78 + escape(figure.caption),
79 + );
80 + html
81 + }
82 +
83 + /// Several figures as one strip.
84 + ///
85 + /// An empty set emits the container and nothing in it, for the reason a meter
86 + /// over nothing and a select with no options both render: it is what an app with
87 + /// an unloaded count actually has, and an empty strip says so on screen rather
88 + /// than in a log.
89 + #[must_use]
90 + pub fn figures_html(figures: &[Figure<'_>], opts: &Emit) -> String {
91 + let mut html = format!("<div class=\"{}\">", class("figures", opts));
92 + for figure in figures {
93 + html.push_str(&figure_html(figure, opts));
94 + }
95 + html.push_str("</div>");
96 + html
97 + }
98 +
99 + #[cfg(test)]
100 + mod tests {
101 + use super::*;
102 +
103 + #[test]
104 + fn the_noun_reaches_a_reader_before_the_number() {
105 + // The problem the accessible name solves. Visually the value comes
106 + // first; a reader that met "17" first would have to hold it until it
107 + // found out what was counted.
108 + let figure = Figure::new("17", "Current Streak");
109 + assert_eq!(figure_text(&figure), "Current Streak: 17");
110 +
111 + let html = figure_html(&figure, &Emit::default());
112 + assert!(html.contains(r#"aria-label="Current Streak: 17""#));
113 + // And the spans are not read a second time in the other order.
114 + assert_eq!(html.matches(r#"aria-hidden="true""#).count(), 2);
115 + }
116 +
117 + #[test]
118 + fn an_untoned_figure_emits_no_tone_attribute() {
119 + // Same reason as the meter: the bare class is the untoned rule, so an
120 + // attribute here would match nothing.
121 + let plain = figure_html(&Figure::new("17", "Total"), &Emit::default());
122 + assert!(!plain.contains("data-tone"));
123 +
124 + let toned = figure_html(
125 + &Figure::new("0", "Current Streak").tone(Tone::Warning),
126 + &Emit::default(),
127 + );
128 + assert!(toned.contains(r#"data-tone="warning""#));
129 + }
130 +
131 + #[test]
132 + fn a_value_and_a_caption_are_escaped_like_every_other_string() {
133 + // Both arrive from the app, the same as a field label does.
134 + let html = figure_html(&Figure::new("<b>3</b>", "a & b"), &Emit::default());
135 + assert!(html.contains("a &amp; b"));
136 + assert!(html.contains("&lt;b&gt;"));
137 + assert!(!html.contains("<b>"));
138 + }
139 +
140 + #[test]
141 + fn a_strip_is_the_unit_because_a_renderer_cannot_infer_a_set() {
142 + let html = figures_html(
143 + &[
144 + Figure::new("17", "Current Streak"),
145 + Figure::new("84%", "Completion Rate"),
146 + ],
147 + &Emit::default(),
148 + );
149 + assert!(html.starts_with(r#"<div class="figures">"#));
150 + assert_eq!(html.matches(r#"class="figure""#).count(), 2);
151 + }
152 +
153 + #[test]
154 + fn an_empty_strip_renders_as_an_empty_strip() {
155 + // Sayable, so it has to be emittable, and visibly empty rather than
156 + // absent.
157 + let html = figures_html(&[], &Emit::default());
158 + assert_eq!(html, r#"<div class="figures"></div>"#);
159 + assert!(!html.contains("figure-"));
160 + }
161 +
162 + #[test]
163 + fn the_prefix_reaches_every_class() {
164 + // A prefixed build claims its own names, and the two inner spans are
165 + // descendant selectors in the emitted CSS.
166 + let opts = Emit {
167 + class_prefix: "mo-",
168 + ..Emit::default()
169 + };
170 + let html = figures_html(&[Figure::new("17", "Total")], &opts);
171 + assert!(html.contains(r#"class="mo-figures""#));
172 + assert!(html.contains(r#"class="mo-figure""#));
173 + assert!(html.contains(r#"class="mo-figure-value""#));
174 + assert!(html.contains(r#"class="mo-figure-caption""#));
175 + }
176 + }