Skip to main content

max / makeover-webview

Draw a chart, and put the division in the stylesheet `--most` once on the container and `--value` once per bar, both printed with `{}` and reaching the markup as themselves; `chart_rules` is where they become a height. Not taste: `quasi-declare` derives a compiled template by rendering with stand-in values, and a number the renderer works out from two others prints as its arithmetic, leaves no stand-in to find, and bakes one request's chart into the template. It reads better too. The DOM now carries the two real numbers rather than a percentage with nothing behind it. `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and dividing by it invalidates the declaration at computed-value time, which drops the height to `auto` -- in a flex column, a bar of full height. Sizes are deferred as custom properties with defaults, on `progress_rules`' rule, and the readout's hover state is gated like every other. `role="img"` is emitted only with a name for it: an unnamed one announces nothing, which is worse than the labelled readouts the markup already is.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 18:12 UTC
Signed with PGP, not checked
Commit: 4568ef1976db71df89e95e522b865ff831bdb6ea
Parent: a260211
5 files changed, +384 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.74.1"
3 + version = "0.74.2"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
M src/lib.rs +2
@@ -298,6 +298,7 @@
298 298
299 299 #![forbid(unsafe_code)]
300 300
301 + pub mod chart;
301 302 pub mod facet;
302 303 pub mod figure;
303 304 pub mod form;
@@ -1980,6 +1981,7 @@
1980 1981 css.push_str(&row_rules(opts));
1981 1982 css.push_str(&run_rules(opts));
1982 1983 css.push_str(&progress_rules(opts));
1984 + css.push_str(&chart::rules(opts));
1983 1985 css.push_str(&awaiting_rules(opts));
1984 1986 css.push_str(&figure_rules(opts));
1985 1987 css.push_str(&picture_rules(opts));
M src/tests.rs +21 -1
@@ -1213,6 +1213,27 @@
1213 1213 // rather than dividing the leftovers in proportion
1214 1214 // to their contents. Three ratios and no length.
1215 1215 | "1 1 0"
1216 + // A chart's four, 0.75.0. Every one is structure or
1217 + // alignment, and the reading is `table`'s and
1218 + // `flex`'s: which way the bars are laid out and how
1219 + // they line up, never how much of anything. The
1220 + // chart's own lengths -- its height, its gap, a
1221 + // bar's floor, the readout's padding -- are all
1222 + // custom properties with defaults, so they leave by
1223 + // the `var(--` arm above.
1224 + //
1225 + // `attr(data-tooltip)` is `content: ""`'s case with
1226 + // something in it: the pseudo-element echoes an
1227 + // attribute the markup already carries, which names
1228 + // no colour and no size.
1229 + // The readout's box asks the browser what its own
1230 + // text comes to, which is `min-content`'s reading
1231 + // exactly: a derived width and not one anybody chose.
1232 + | "max-content"
1233 + | "flex-end"
1234 + | "column"
1235 + | "ellipsis"
1236 + | "attr(data-tooltip)"
1216 1237 // A menu run's overflow control, 0.64.0.
1217 1238 // `column` and `stretch` are the same reading
1218 1239 // `flex` and `center` get one line up: which way
@@ -1223,7 +1244,6 @@
1223 1244 // panel's `inset-block-start`, which is "the whole
1224 1245 // of the control it hangs from" rather than a
1225 1246 // distance anybody picked.
1226 - | "column"
1227 1247 | "stretch"
1228 1248 // A picture's three, 0.36.0. `block` is structure
1229 1249 // for the reason `table` is: an inline image sits
@@ -34,6 +34,7 @@
34 34 //! from, and a class added to an emitter enters the vocabulary in the same
35 35 //! commit that adds it.
36 36
37 + use crate::chart::CHART_CLASSES;
37 38 use crate::facet::FACET_CLASSES;
38 39 use crate::figure::FIGURE_CLASSES;
39 40 use crate::form::{FIELD_CLASSES, FIELD_STATE_CLASSES};
@@ -94,6 +95,7 @@
94 95 .chain(FIELD_CLASSES)
95 96 .chain(FIGURE_CLASSES)
96 97 .chain(METER_CLASSES)
98 + .chain(CHART_CLASSES)
97 99 .chain(PLACEHOLDER_CLASSES)
98 100 .map(|name| crate::class(name, opts)),
99 101 );
A src/chart.rs +358
@@ -1,0 +1,358 @@
1 + //! A run of magnitudes against one axis, rendered as bars.
2 + //!
3 + //! [`meter`](crate::meter)'s neighbour and its opposite in one respect: a meter
4 + //! draws one proportion and computes the width from the pair it is handed, and
5 + //! this draws a series and computes nothing. Both numbers reach the markup as
6 + //! they were given, and the division happens in CSS.
7 + //!
8 + //! # Why the arithmetic is in the stylesheet
9 + //!
10 + //! Not taste, and not an optimisation. `quasi-declare` derives a compiled
11 + //! template by rendering a screen with stand-in values and keeping the bytes
12 + //! that no request reaches; a number the description HANDS a renderer is found
13 + //! in that render and becomes a hole, and a number the renderer WORKS OUT from
14 + //! two of them is printed as its arithmetic, leaves no stand-in to find, and is
15 + //! baked into the template as a constant. `quasi_router::stage::number_at` says
16 + //! so in as many words.
17 + //!
18 + //! So a chart drawn from a width this crate computed could be described and
19 + //! could not be compiled, which for MNW's revenue chart is the difference
20 + //! between a screen on the seam and the one screen left off it.
21 + //! `--value` and `--most` are printed with `{}` and reach the markup as
22 + //! themselves, and `chart_rules` divides them where a browser can.
23 + //!
24 + //! It costs nothing and reads better: the DOM carries the two real numbers
25 + //! rather than a percentage with nothing behind it, which is
26 + //! [`makeover_layout::Chart`]'s own argument for carrying the pair.
27 +
28 + use crate::form::escape_into;
29 + use crate::{Depth, Emit, class, depth_rule, gated, hover_condition, push_class};
30 + use makeover_layout::{Bar, Chart, Intent, Tone};
31 + use std::fmt::Write as _;
32 +
33 + /// Every class this module can put in markup.
34 + ///
35 + /// [`crate::facet::FACET_CLASSES`]' obligation, and the list is what keeps the
36 + /// scraped vocabulary true if a rule goes away.
37 + pub const CHART_CLASSES: &[&str] = &[
38 + "chart",
39 + "chart-bars",
40 + "chart-bar-col",
41 + "chart-bar",
42 + "chart-bar-label",
43 + ];
44 +
45 + /// What a bar says when a pointer rests on it, or nothing.
46 + ///
47 + /// The reading and the note, in that order, joined the way the description did
48 + /// not: [`Bar::reading`] and [`Bar::note`] arrive worded separately so a
49 + /// terminal at one line and a tooltip can want different sentences, which is
50 + /// [`crate::meter::meter_text`]'s split exactly.
51 + ///
52 + /// The place on the axis is deliberately not in here. It is drawn under the bar
53 + /// as its own label, so repeating it in the tooltip is the readout arguing with
54 + /// itself.
55 + #[must_use]
56 + pub fn bar_text(bar: &Bar<'_>) -> Option<String> {
57 + match (bar.reading, bar.note) {
58 + (Some(reading), Some(note)) => Some(format!("{reading} / {note}")),
59 + (Some(only), None) | (None, Some(only)) => Some(only.to_string()),
60 + (None, None) => None,
61 + }
62 + }
63 +
64 + /// A chart as a run of bars, written into a buffer the caller already has.
65 + ///
66 + /// The bars arrive as an iterator rather than a slice so a caller holding owned
67 + /// bars can map them through without building a second `Vec`, which is how
68 + /// `quasi-webview` holds a `Vec<screen::Bar>` and this wants
69 + /// [`makeover_layout::Bar`].
70 + ///
71 + /// An empty axis draws its container and no bars. A chart over nothing is
72 + /// sayable on purpose -- see [`Chart::is_empty`] -- and drawing the frame says
73 + /// so on screen, where dividing by the axis would put `NaN` in a length.
74 + pub fn chart_html_into<'a>(
75 + chart: &Chart<'_>,
76 + bars: impl IntoIterator<Item = Bar<'a>>,
77 + opts: &Emit,
78 + out: &mut String,
79 + ) {
80 + out.push_str("<div class=\"");
81 + push_class(out, "chart", opts);
82 + out.push('"');
83 + // The axis, once, on the container the bars read it from. Stated here and
84 + // not per bar because it is one fact about the chart, and a fact repeated
85 + // per bar is one the copies can disagree about.
86 + let _ = write!(out, " style=\"--most: {}\"", chart.most);
87 + if chart.tone != Tone::Neutral {
88 + let _ = write!(out, " data-tone=\"{}\"", chart.tone.token());
89 + }
90 + // `role="img"` only where there is a name for it. The role tells a screen
91 + // reader to announce this as one thing instead of reading the bars, and an
92 + // unnamed one announces nothing at all -- worse than the group of labelled
93 + // readouts the markup already is. So the role and the name arrive together
94 + // or neither does, and a description that wants the chart announced says
95 + // what the magnitudes are.
96 + if let Some(label) = chart.label {
97 + out.push_str(" role=\"img\" aria-label=\"");
98 + escape_into(label, out);
99 + out.push('"');
100 + }
101 + out.push('>');
102 +
103 + out.push_str("<div class=\"");
104 + push_class(out, "chart-bars", opts);
105 + out.push_str("\">");
106 +
107 + for bar in bars {
108 + bar_html_into(&bar, opts, out);
109 + }
110 +
111 + out.push_str("</div></div>");
112 + }
113 +
114 + /// One bar and its label.
115 + ///
116 + /// Split out because the loop over bars is the loop a compiled template holds,
117 + /// so what one pass emits is worth being able to read on its own.
118 + fn bar_html_into(bar: &Bar<'_>, opts: &Emit, out: &mut String) {
119 + out.push_str("<div class=\"");
120 + push_class(out, "chart-bar-col", opts);
121 + out.push('"');
122 + if let Some(text) = bar_text(bar) {
123 + out.push_str(" data-tooltip=\"");
124 + escape_into(&text, out);
125 + out.push('"');
126 + }
127 + out.push('>');
128 +
129 + out.push_str("<div class=\"");
130 + push_class(out, "chart-bar", opts);
131 + // The magnitude as it was handed over. See the module header for why this
132 + // is not a width.
133 + let _ = write!(out, "\" style=\"--value: {}\"></div>", bar.value);
134 +
135 + out.push_str("<div class=\"");
136 + push_class(out, "chart-bar-label", opts);
137 + out.push_str("\">");
138 + escape_into(bar.at, out);
139 + out.push_str("</div></div>");
140 + }
141 +
142 + /// A chart as a returned string.
143 + #[must_use]
144 + pub fn chart_html<'a>(
145 + chart: &Chart<'_>,
146 + bars: impl IntoIterator<Item = Bar<'a>>,
147 + opts: &Emit,
148 + ) -> String {
149 + let mut html = String::new();
150 + chart_html_into(chart, bars, opts, &mut html);
151 + html
152 + }
153 +
154 + /// What a chart looks like.
155 + ///
156 + /// # What is emitted and what is deferred
157 + ///
158 + /// `progress_rules`' rule, applied: the tones are emitted and the sizes are
159 + /// not. This crate names no magnitude -- that is `makeover-geometry`'s -- so
160 + /// every length here is a custom property with a default an adopter overrides
161 + /// once, exactly as `--awaiting-bar` is. How tall a chart stands is the app's:
162 + /// MNW's revenue chart is 200px and a sparkline beside a figure is 24px.
163 + ///
164 + /// The height of a BAR is the one length that has to be here, and it is not a
165 + /// magnitude: it is the two numbers the markup carries, divided. That division
166 + /// is the half of the contract the markup cannot state on its own.
167 + ///
168 + /// `max(var(--most), 1)` rather than a guard: an axis of zero is sayable, and
169 + /// dividing by it makes the whole declaration invalid at computed-value time,
170 + /// which drops the height to `auto` -- in a flex column, a bar of full height.
171 + /// Clamping the divisor draws every bar at nothing, which is what an empty axis
172 + /// means.
173 + fn chart_rules(opts: &Emit) -> String {
174 + let chart = class("chart", opts);
175 + let bars = class("chart-bars", opts);
176 + let col = class("chart-bar-col", opts);
177 + let bar = class("chart-bar", opts);
178 + let label = class("chart-bar-label", opts);
179 +
180 + let mut css = depth_rule(&chart, Depth::Well);
181 +
182 + let _ = writeln!(
183 + css,
184 + ".{bars} {{\n display: flex;\n align-items: flex-end;\n \
185 + gap: var(--chart-gap, 2px);\n height: var(--chart-height, 200px);\n}}"
186 + );
187 + let _ = writeln!(
188 + css,
189 + ".{col} {{\n flex: 1;\n display: flex;\n flex-direction: column;\n \
190 + align-items: center;\n min-width: 0;\n position: relative;\n}}"
191 + );
192 + let _ = writeln!(
193 + css,
194 + ".{bar} {{\n width: 100%;\n background: var(--action);\n \
195 + min-height: var(--chart-bar-least, 2px);\n \
196 + height: calc(var(--value, 0) * 100% / max(var(--most, 1), 1));\n}}"
197 + );
198 + // A chart can be saying something, the same way a bar can. `progress_rules`
199 + // emits the tones for that reason and this follows it.
200 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
201 + let _ = writeln!(
202 + css,
203 + ".{chart}[data-tone=\"{0}\"] .{bar} {{\n background: var(--{0});\n}}",
204 + tone.token()
205 + );
206 + }
207 + let _ = writeln!(
208 + css,
209 + ".{label} {{\n color: var(--content-muted);\n max-width: 100%;\n \
210 + white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}}"
211 + );
212 +
213 + // The readout, revealed from the attribute the markup already carries.
214 + //
215 + // Gated, because it is a hover state and this crate asks
216 + // `makeover-touch` whether a hover state exists rather than assuming one.
217 + // Keyed on the attribute rather than on a class, so a bar with nothing to
218 + // say reveals no empty bubble.
219 + //
220 + // Centred with `inset-inline: 0` and an auto margin rather than with a
221 + // half-width translate: the translate is the idiom and it names a
222 + // magnitude, and this does the same job with three keywords.
223 + css.push_str(&gated(
224 + hover_condition(),
225 + &format!(
226 + ".{col}[data-tooltip]:hover::before {{\n content: attr(data-tooltip);\n \
227 + position: absolute;\n bottom: 100%;\n inset-inline: 0;\n \
228 + margin-inline: auto;\n width: max-content;\n \
229 + background: var(--surface-raised);\n color: var(--content);\n \
230 + border: var(--border);\n box-shadow: var(--elevation-overlay);\n \
231 + padding: var(--chart-readout-padding, 0.25em 0.5em);\n \
232 + white-space: nowrap;\n pointer-events: none;\n}}\n"
233 + ),
234 + ));
235 + css
236 + }
237 +
238 + /// The rules, for the stylesheet builder.
239 + #[must_use]
240 + pub fn rules(opts: &Emit) -> String {
241 + chart_rules(opts)
242 + }
243 +
244 + #[cfg(test)]
245 + mod tests {
246 + use super::*;
247 +
248 + fn axis() -> Chart<'static> {
249 + Chart::new(6740).label("revenue")
250 + }
251 +
252 + /// The two numbers reach the markup as themselves. This is the whole reason
253 + /// the member is shaped the way it is, so it is asserted rather than
254 + /// assumed: a width computed here would compile into a template as a
255 + /// constant and serve one request's chart to everybody.
256 + #[test]
257 + fn both_numbers_are_printed_and_neither_is_divided() {
258 + let html = chart_html(
259 + &axis(),
260 + [Bar::at("Mar 3").of(4210).reading("$42.10").note("3 sales")],
261 + &Emit::default(),
262 + );
263 + assert!(html.contains("--most: 6740"), "{html}");
264 + assert!(html.contains("--value: 4210"), "{html}");
265 + assert!(
266 + !html.contains('%'),
267 + "a percentage reached the markup: {html}"
268 + );
269 + }
270 +
271 + /// The role and the name arrive together or neither does. An unnamed
272 + /// `role="img"` announces nothing, which is worse than the labelled
273 + /// readouts the markup already is.
274 + #[test]
275 + fn an_unlabelled_chart_claims_no_role() {
276 + let named = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
277 + assert!(
278 + named.contains(r#"role="img" aria-label="revenue""#),
279 + "{named}"
280 + );
281 +
282 + let bare = chart_html(&Chart::new(10), [Bar::at("Mar 3").of(1)], &Emit::default());
283 + assert!(!bare.contains("role="), "{bare}");
284 + assert!(!bare.contains("aria-label"), "{bare}");
285 + }
286 +
287 + /// A bar says both facts or the one it has, and a bar with neither draws no
288 + /// tooltip rather than an empty one.
289 + #[test]
290 + fn a_readout_is_what_the_bar_was_given() {
291 + assert_eq!(
292 + bar_text(&Bar::at("a").of(1).reading("$1").note("2 sales")),
293 + Some("$1 / 2 sales".to_string())
294 + );
295 + assert_eq!(
296 + bar_text(&Bar::at("a").of(1).reading("$1")),
297 + Some("$1".to_string())
298 + );
299 + assert_eq!(
300 + bar_text(&Bar::at("a").of(1).note("2 sales")),
301 + Some("2 sales".to_string())
302 + );
303 + assert_eq!(bar_text(&Bar::at("a").of(1)), None);
304 +
305 + let bare = chart_html(&axis(), [Bar::at("Mar 3").of(1)], &Emit::default());
306 + assert!(!bare.contains("data-tooltip"), "{bare}");
307 + }
308 +
309 + /// Everything a request brings goes through the escaper, in the text and in
310 + /// the attribute. A label reaching a chart from a database is why.
311 + #[test]
312 + fn a_label_and_a_readout_are_escaped() {
313 + let html = chart_html(
314 + &Chart::new(10).label("a & b"),
315 + [Bar::at("<script>").of(5).reading("\"x\"")],
316 + &Emit::default(),
317 + );
318 + assert!(!html.contains("<script>"), "{html}");
319 + assert!(html.contains("&lt;script&gt;"), "{html}");
320 + assert!(html.contains("a &amp; b"), "{html}");
321 + assert!(html.contains("&quot;x&quot;"), "{html}");
322 + }
323 +
324 + /// An axis of zero draws its frame and its bars, and the stylesheet is what
325 + /// keeps them at nothing. Drawing no frame would be a screen that says
326 + /// nothing where it has nothing, which is the empty state's job and not
327 + /// this one's.
328 + #[test]
329 + fn an_empty_axis_draws_rather_than_dividing() {
330 + let html = chart_html(&Chart::new(0), [Bar::at("Mar 3").of(0)], &Emit::default());
331 + assert!(html.contains("--most: 0"), "{html}");
332 + assert!(html.contains("--value: 0"), "{html}");
333 + }
334 +
335 + /// The streamed form and the returned one are the same bytes, which is the
336 + /// obligation every other emitter here carries.
337 + #[test]
338 + fn the_streamed_form_is_the_returned_one() {
339 + let opts = Emit {
340 + class_prefix: "mk-",
341 + ..Emit::default()
342 + };
343 + let bars = [Bar::at("Mar 3").of(4210).reading("$42.10")];
344 + let mut streamed = String::new();
345 + chart_html_into(&axis(), bars, &opts, &mut streamed);
346 + assert_eq!(streamed, chart_html(&axis(), bars, &opts));
347 + }
348 +
349 + /// Every class the emitter can write carries a rule, which is what
350 + /// `CHART_CLASSES` exists to keep true.
351 + #[test]
352 + fn every_class_this_module_writes_has_a_rule() {
353 + let css = chart_rules(&Emit::default());
354 + for name in CHART_CLASSES {
355 + assert!(css.contains(&format!(".{name}")), "{name} has no rule");
356 + }
357 + }
358 + }