Skip to main content

max / quasi

Take makeover 0.11: a figure strip, a proportion in a row, field constraints
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:50 UTC
Signed with PGP, not checked
Commit: 896d5736e22cf30d8cf492bfbb79380a9f5977e8
Parent: 4b2ef3e
7 files changed, +331 insertions, -89 deletions
M Cargo.lock +20 -14
@@ -1781,6 +1781,12 @@
1781 1781 source = "registry+https://github.com/rust-lang/crates.io-index"
1782 1782 checksum = "586e191675d471f1b10a6d3620f8d9f796c74032bff3dd1efb0e578c290dc4ef"
1783 1783
1784 + [[package]]
1785 + name = "makeover-layout"
1786 + version = "0.11.0"
1787 + source = "registry+https://github.com/rust-lang/crates.io-index"
1788 + checksum = "67da5deacb4c162878a5ed87839c1a08a7b1f473901f3a8ba1ae03b6ea3952a4"
1789 +
1784 1790 [[package]]
1785 1791 name = "makeover-touch"
1786 1792 version = "0.6.0"
@@ -1788,17 +1794,17 @@
1788 1794 checksum = "54908e627c94ee16a6349f3aa6a98fe60365f1671be218ce4ca6701b71e44ae7"
1789 1795 dependencies = [
1790 1796 "makeover-geometry",
1791 - "makeover-layout",
1797 + "makeover-layout 0.10.0",
1792 1798 ]
1793 1799
1794 1800 [[package]]
1795 1801 name = "makeover-webview"
1796 - version = "0.19.0"
1802 + version = "0.20.0"
1797 1803 source = "registry+https://github.com/rust-lang/crates.io-index"
1798 - checksum = "e55462f42c8a1960ff7a58edb30717ffcea7b1df2f5a6f0bfb6d00230de7e234"
1804 + checksum = "7a7917e386d40de17c282f4a4354ca73172ae0a00953f0253d136270c6fb7763"
1799 1805 dependencies = [
1800 1806 "makeover-geometry",
1801 - "makeover-layout",
1807 + "makeover-layout 0.11.0",
1802 1808 "makeover-touch",
1803 1809 ]
1804 1810
@@ -2455,7 +2461,7 @@
2455 2461 name = "quasi-router"
2456 2462 version = "0.1.0"
2457 2463 dependencies = [
2458 - "makeover-layout",
2464 + "makeover-layout 0.11.0",
2459 2465 ]
2460 2466
2461 2467 [[package]]
@@ -2474,7 +2480,7 @@
2474 2480 version = "0.1.0"
2475 2481 dependencies = [
2476 2482 "docengine",
2477 - "makeover-layout",
2483 + "makeover-layout 0.11.0",
2478 2484 "makeover-webview",
2479 2485 "quasi-http",
2480 2486 "quasi-router",
@@ -4630,14 +4636,6 @@
4630 4636 source = "registry+https://github.com/rust-lang/crates.io-index"
4631 4637 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4632 4638
4633 - [[patch.unused]]
4634 - name = "synckit-client"
4635 - version = "0.8.0"
4636 -
4637 - [[patch.unused]]
4638 - name = "synckit-config"
4639 - version = "0.2.0"
4640 -
4641 4639 [[patch.unused]]
4642 4640 name = "kberg"
4643 4641 version = "0.1.0"
@@ -4649,3 +4647,11 @@
4649 4647 [[patch.unused]]
4650 4648 name = "tagtree"
4651 4649 version = "0.4.0"
4650 +
4651 + [[patch.unused]]
4652 + name = "synckit-client"
4653 + version = "0.8.0"
4654 +
4655 + [[patch.unused]]
4656 + name = "synckit-config"
4657 + version = "0.2.0"
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.10.0"
16 + makeover-layout = "0.11.0"
@@ -15,8 +15,8 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 - makeover-layout = "0.10.0"
19 - makeover-webview = "0.19.0"
18 + makeover-layout = "0.11.0"
19 + makeover-webview = "0.20.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Optional and default-on: a renderer that cannot render the member makes the
22 22 # member do nothing, and a consumer with no rich text should not pay for a
@@ -109,8 +109,8 @@
109 109 pub use crate::response::{Message, Outcome, Response};
110 110 pub use crate::router::{Handler, Router};
111 111 pub use crate::screen::{
112 - Act, Action, Cells, Choice, Column, Destination, Field, Node, RegionKind, Row, Screen, Slot,
113 - Tag,
112 + Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, RegionKind, Row,
113 + Screen, Slot, Tag,
114 114 };
115 115
116 116 #[cfg(test)]
@@ -290,6 +290,125 @@
290 290 }
291 291 }
292 292
293 + /// One figure with a caption, owned.
294 + ///
295 + /// The borrowed original is [`layout::Figure`], and everything it says applies:
296 + /// the value is text because only the app knows whether the number is a
297 + /// percentage, a duration or a ratio, and the tone is carried because no
298 + /// renderer can work out that a streak of zero is worth colouring.
299 + ///
300 + /// What it calls, if it calls anything, is not here. That is an address, which
301 + /// `makeover-layout` never names, and it rides beside the figure in
302 + /// [`Node::Stats`] the way [`Row::activate`] rides beside a row's parts.
303 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
304 + pub struct Figure {
305 + /// The number, formatted the way the app means it to read.
306 + pub value: String,
307 + /// What it counts. The caption under the value.
308 + pub caption: String,
309 + /// What the figure means. [`layout::Tone::Neutral`] is an ordinary fact.
310 + pub tone: layout::Tone,
311 + }
312 +
313 + impl Figure {
314 + /// A figure that is an ordinary fact.
315 + pub fn new(value: impl Into<String>, caption: impl Into<String>) -> Self {
316 + Self {
317 + value: value.into(),
318 + caption: caption.into(),
319 + tone: layout::Tone::Neutral,
320 + }
321 + }
322 +
323 + /// What the figure means.
324 + #[must_use]
325 + pub const fn tone(mut self, tone: layout::Tone) -> Self {
326 + self.tone = tone;
327 + self
328 + }
329 +
330 + /// Borrow as the description layer's own type.
331 + #[must_use]
332 + pub fn as_layout(&self) -> layout::Figure<'_> {
333 + layout::Figure {
334 + value: &self.value,
335 + caption: &self.caption,
336 + tone: self.tone,
337 + }
338 + }
339 + }
340 +
341 + /// How much of a set is done, owned.
342 + ///
343 + /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this.
344 + /// Before it, a screen with a progress bar concatenated the two numbers into its
345 + /// heading — "Subtasks 3/7" — which keeps both facts and loses the reading, the
346 + /// same way a toned status badge read as prose before [`Row::tokens`].
347 + ///
348 + /// Its own struct as of 0.11.0, having been the inline payload of
349 + /// [`Node::Meter`]. Extracted for the reason [`Tag`] was: a row can carry one
350 + /// now ([`Row::meter`], against `makeover-layout`'s `RowPart::Proportion`), and
351 + /// the alternative was defining the same four fields twice and watching them
352 + /// drift.
353 + ///
354 + /// Carries the pair rather than a percentage for the reason [`layout::Meter`]
355 + /// gives: a bar that is full because it landed exactly and one that is full
356 + /// because it ran over are the same width and not the same fact.
357 + ///
358 + /// This is a proportion of a set and not the progress of an operation. A running
359 + /// timer or a fetch is imperative and live, and a screen is described once per
360 + /// answer; [`layout::Readiness::Pending`] and a [`layout::Notice::Toast`] are
361 + /// what those get.
362 + #[derive(Debug, Clone, PartialEq, Eq, Hash)]
363 + pub struct Meter {
364 + /// How much is done. May exceed [`total`](Self::total).
365 + pub done: u32,
366 + /// How much there is to do.
367 + pub total: u32,
368 + /// What the proportion means. No renderer can derive this.
369 + pub tone: layout::Tone,
370 + /// What is being counted: "subtasks", "tasks". The noun, not the ratio.
371 + pub label: Option<String>,
372 + }
373 +
374 + impl Meter {
375 + /// A proportion, untoned and unlabelled.
376 + #[must_use]
377 + pub const fn new(done: u32, total: u32) -> Self {
378 + Self {
379 + done,
380 + total,
381 + tone: layout::Tone::Neutral,
382 + label: None,
383 + }
384 + }
385 +
386 + /// What the proportion means.
387 + #[must_use]
388 + pub const fn tone(mut self, tone: layout::Tone) -> Self {
389 + self.tone = tone;
390 + self
391 + }
392 +
393 + /// What is being counted. The noun, not the ratio.
394 + #[must_use]
395 + pub fn label(mut self, label: impl Into<String>) -> Self {
396 + self.label = Some(label.into());
397 + self
398 + }
399 +
400 + /// Borrow as the description layer's own type.
401 + #[must_use]
402 + pub fn as_layout(&self) -> layout::Meter<'_> {
403 + layout::Meter {
404 + done: self.done,
405 + total: self.total,
406 + tone: self.tone,
407 + label: self.label.as_deref(),
408 + }
409 + }
410 + }
411 +
293 412 /// One field of a form, owned.
294 413 ///
295 414 /// The borrowed original is [`layout::Field`], and everything it says about
@@ -337,6 +456,16 @@
337 456 pub options: Vec<Choice>,
338 457 /// Whether the form refuses to submit without it.
339 458 pub required: bool,
459 + /// The longest the value may be, in characters.
460 + ///
461 + /// The borrowed original's [`layout::Field::max_length`], and everything it
462 + /// says applies: the description carries the rule, the renderer emits its
463 + /// host's idiom, and deciding a value is wrong stays with whoever validated.
464 + pub max_length: Option<u32>,
465 + /// The lowest value accepted, written the way the host writes one.
466 + pub min: Option<String>,
467 + /// The highest value accepted. See [`min`](Self::min).
468 + pub max: Option<String>,
340 469 /// Whether the field lives behind a "more options" disclosure.
341 470 pub extended: bool,
342 471 /// What to put back in the box: what was submitted, when a submission was
@@ -378,6 +507,9 @@
378 507 placeholder: None,
379 508 options: Vec::new(),
380 509 required: false,
510 + max_length: None,
511 + min: None,
512 + max: None,
381 513 extended: false,
382 514 value: None,
383 515 changes: None,
@@ -483,6 +615,9 @@
483 615 placeholder: self.placeholder.as_deref(),
484 616 options: &options,
485 617 required: self.required,
618 + max_length: self.max_length,
619 + min: self.min.as_deref(),
620 + max: self.max.as_deref(),
486 621 extended: self.extended,
487 622 })
488 623 }
@@ -770,6 +905,20 @@
770 905 /// [`current`](Self::current). goingson's contacts and tasks screens both
771 906 /// drive bulk actions from it.
772 907 pub selected: Option<bool>,
908 + /// How much of a set this row's thing has done.
909 + ///
910 + /// `da5666ae`, and [`RowPart::Proportion`](layout::RowPart::Proportion) is
911 + /// the part it renders into. [`layout::Meter`] arrived at 0.10.0 and closed
912 + /// two of the seven sites that wanted it; the other five sit in rows, and a
913 + /// row holds no nodes by the 2026-08-08 ruling — the door through which a
914 + /// description becomes a templating language. So this carries the
915 + /// *description of a bar* rather than a node, exactly as
916 + /// [`tokens`](Self::tokens) carries tags rather than nodes.
917 + ///
918 + /// Owned rather than borrowed for the reason everything here is. Without it
919 + /// a row flattens the proportion into [`meta`](Self::meta) as "3/7
920 + /// subtasks", keeping both numbers and losing the reading.
921 + pub meter: Option<Meter>,
773 922 /// What ticking this row calls, if ticking it is the write.
774 923 ///
775 924 /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row
@@ -798,6 +947,13 @@
798 947 }
799 948 }
800 949
950 + /// How much of this row's set is done.
951 + #[must_use]
952 + pub fn meter(mut self, meter: Meter) -> Self {
953 + self.meter = Some(meter);
954 + self
955 + }
956 +
801 957 /// A tick that is the write, in the state it is currently in.
802 958 ///
803 959 /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together,
@@ -970,7 +1126,12 @@
970 1126 ///
971 1127 /// Almost always carries a [`Field::changes`], because a control with no
972 1128 /// form around it and no route on it collects a value nothing reads.
973 - Field(Field),
1129 + ///
1130 + /// Boxed because it is the only member holding a whole struct by value, and
1131 + /// [`Field`] is the largest one here — every other member holds a `Vec`, a
1132 + /// `String` or a small enum. Unboxed it decides the size of every [`Node`]
1133 + /// in every list, and of the [`Response`](crate::Response) that carries one.
1134 + Field(Box<Field>),
974 1135 /// Fields, and the route that submits them.
975 1136 Form {
976 1137 /// Where the answers go. Almost always a [`Method::Post`].
@@ -1006,32 +1167,35 @@
1006 1167 action: Option<Action>,
1007 1168 },
1008 1169 /// How much of a set is done.
1170 + Meter(Meter),
1171 + /// A value with a caption, several of them as one strip.
1009 1172 ///
1010 - /// Against `makeover-layout`'s [`layout::Meter`], which arrived at 0.10.0
1011 - /// for this. Before it, a screen with a progress bar concatenated the two
1012 - /// numbers into its heading — "Subtasks 3/7" — which keeps both facts and
1013 - /// loses the reading, the same way a toned status badge read as prose before
1014 - /// [`Row::tokens`].
1173 + /// `93c6a174`. Against `makeover-layout`'s [`layout::Figure`], which arrived
1174 + /// at 0.11.0 for this. The dashboard shape: a large value over a small
1175 + /// caption, several in a row. goingson had five of them across five screens
1176 + /// with five class vocabularies for the one shape, and the port had been
1177 + /// making each out of a [`Row`] with the caption as `primary` and the figure
1178 + /// as `meta`, which reads backwards — a row's primary slot means the thing
1179 + /// itself, and here the thing is the number.
1015 1180 ///
1016 - /// Owned rather than borrowed for the reason every node here is, and
1017 - /// carrying the pair rather than a percentage for the reason
1018 - /// [`layout::Meter`] gives: a bar that is full because it landed exactly and
1019 - /// one that is full because it ran over are the same width and not the same
1020 - /// fact.
1181 + /// # Why the set is the node and not each figure
1021 1182 ///
1022 - /// This is a proportion of a set and not the progress of an operation. A
1023 - /// running timer or a fetch is imperative and live, and a screen is
1024 - /// described once per answer; [`layout::Readiness::Pending`] and a
1025 - /// [`layout::Notice::Toast`] are what those get.
1026 - Meter {
1027 - /// How much is done. May exceed [`total`](Self::Meter::total).
1028 - done: u32,
1029 - /// How much there is to do.
1030 - total: u32,
1031 - /// What the proportion means. No renderer can derive this.
1032 - tone: layout::Tone,
1033 - /// What is being counted: "subtasks", "tasks". The noun, not the ratio.
1034 - label: Option<String>,
1183 + /// Four tiles in a strip and four tiles down a column are different things,
1184 + /// and a renderer handed one at a time cannot tell it is looking at a set.
1185 + /// The objection to that is real and is answered by what is already here: a
1186 + /// node whose value is its grouping sounds like a layout instruction, and
1187 + /// [`List`](Self::List) and [`Table`](Self::Table) have been exactly that
1188 + /// since the beginning without anyone calling them one.
1189 + ///
1190 + /// # Why the action is here and not on the figure
1191 + ///
1192 + /// One of goingson's five is a control — sync's "Not Applied: 3" opens the
1193 + /// list. `makeover-layout` cannot name an action at all, so the figure it
1194 + /// describes carries none, and this pairs the description with the address
1195 + /// the same way [`Row`] pairs its parts with [`Row::activate`].
1196 + Stats {
1197 + /// The figures, in order, and what each one calls if it calls anything.
1198 + figures: Vec<(Figure, Option<Action>)>,
1035 1199 },
1036 1200 /// A region inside a region.
1037 1201 Region(Slot),
@@ -1107,37 +1271,21 @@
1107 1271 }
1108 1272
1109 1273 /// A proportion of a set, untoned and unlabelled.
1274 + #[must_use]
1110 1275 pub const fn meter(done: u32, total: u32) -> Self {
1111 - Self::Meter {
1112 - done,
1113 - total,
1114 - tone: layout::Tone::Neutral,
1115 - label: None,
1116 - }
1276 + Self::Meter(Meter::new(done, total))
1117 1277 }
1118 1278
1119 - /// Read a [`Self::Meter`] as the description layer's own type.
1120 - ///
1121 - /// A callback rather than a return, for the reason [`Field::with_layout`]
1122 - /// is one: [`layout::Meter`] borrows its label and ours owns it. `None` for
1123 - /// every other member, so a renderer matching on the node can ask without
1124 - /// first proving what it has.
1125 - pub fn with_meter<R>(&self, f: impl FnOnce(layout::Meter<'_>) -> R) -> Option<R> {
1126 - let Self::Meter {
1127 - done,
1128 - total,
1129 - tone,
1130 - label,
1131 - } = self
1132 - else {
1133 - return None;
1134 - };
1135 - Some(f(layout::Meter {
1136 - done: *done,
1137 - total: *total,
1138 - tone: *tone,
1139 - label: label.as_deref(),
1140 - }))
1279 + /// One control on its own, outside any form.
1280 + pub fn field(field: Field) -> Self {
1281 + Self::Field(Box::new(field))
1282 + }
1283 +
1284 + /// A strip of figures, none of which answers a click.
1285 + pub fn stats(figures: impl IntoIterator<Item = Figure>) -> Self {
1286 + Self::Stats {
1287 + figures: figures.into_iter().map(|figure| (figure, None)).collect(),
1288 + }
1141 1289 }
1142 1290 }
1143 1291
@@ -23,6 +23,7 @@
23 23
24 24 use makeover_layout as layout;
25 25 use makeover_webview::Emit;
26 + use makeover_webview::figure::figure_html;
26 27 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
27 28 use makeover_webview::list::{Cell, cells_html};
28 29 use makeover_webview::meter::meter_html;
@@ -391,6 +392,18 @@
391 392 out.push_str("</span>");
392 393 }
393 394
395 + // The bar sits with the plain trailing facts and before the tokens, which
396 + // is where a proportion belongs in the emphasis order: it is a fact about
397 + // the row rather than a thing with a standing of its own. The trough and
398 + // its tones are makeover-webview's, unchanged, same as everywhere else.
399 + if let Some(meter) = &row.meter {
400 + out.push_str("<span");
401 + class_attr(&["row-proportion"], opts, out);
402 + out.push('>');
403 + out.push_str(&meter_html(&meter.as_layout(), opts));
404 + out.push_str("</span>");
405 + }
406 +
394 407 // Tokens after meta and before actions, which is emphasis order: the row's
395 408 // own name, then what supports it, then the plain trailing fact, then the
396 409 // things with a standing of their own, then the controls. `RowPart`'s
@@ -611,10 +624,31 @@
611 624 // same reason the field markup is: a second emitter here would be the
612 625 // same anatomy with a different escaping story, and the CSS it has to
613 626 // match is emitted there too.
614 - node @ Node::Meter { .. } => {
615 - if let Some(html) = node.with_meter(|m| meter_html(&m, opts)) {
616 - out.push_str(&html);
627 + Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
628 +
629 + // The strip and its tones are makeover-webview's too, and the actions
630 + // are this crate's: a figure that answers a click becomes a control
631 + // wrapped round the emitted markup rather than a second figure emitter
632 + // that knows about routes.
633 + Node::Stats { figures } => {
634 + out.push_str("<div");
635 + class_attr(&["figures"], opts, out);
636 + out.push('>');
637 + for (figure, action) in figures {
638 + match action {
639 + Some(action) => {
640 + let (open, close) = control_tag(action);
641 + out.push_str(open);
642 + class_attr(&["figure-act"], opts, out);
643 + action_attrs(action, Fires::Click, morphs, out);
644 + out.push('>');
645 + out.push_str(&figure_html(&figure.as_layout(), opts));
646 + out.push_str(close);
647 + }
648 + None => out.push_str(&figure_html(&figure.as_layout(), opts)),
649 + }
617 650 }
651 + out.push_str("</div>");
618 652 }
619 653
620 654 Node::Region(slot) => slot_html(slot, morphs, opts, out),
@@ -9,7 +9,7 @@
9 9
10 10 use makeover_layout as layout;
11 11 use quasi_http::Render;
12 - use quasi_router::screen::{Act, Cells, Choice, Column, Field, Row, Tag};
12 + use quasi_router::screen::{Act, Cells, Choice, Column, Field, Figure, Meter, Row, Tag};
13 13 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
14 14
15 15 use crate::{Emit, Shell, Webview};
@@ -266,12 +266,11 @@
266 266 // The rule this crate is held to: the markup and the CSS that has to match
267 267 // it are both makeover-webview's. If this ever diverges, the trough and its
268 268 // fill stop agreeing about which classes exist.
269 - let html = fragment(&Node::Meter {
270 - done: 3,
271 - total: 7,
272 - tone: layout::Tone::Success,
273 - label: Some("subtasks".into()),
274 - });
269 + let html = fragment(&Node::Meter(
270 + Meter::new(3, 7)
271 + .tone(layout::Tone::Success)
272 + .label("subtasks"),
273 + ));
275 274 assert_eq!(
276 275 html,
277 276 makeover_webview::meter::meter_html(
@@ -284,16 +283,71 @@
284 283 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
285 284 }
286 285
286 + #[test]
287 + fn a_proportion_in_a_row_is_a_bar_and_not_flattened_text() {
288 + // `da5666ae`. `Meter` closed two of its seven sites at 0.10.0 and could not
289 + // reach the five that sit in rows, because a row holds no nodes. The row
290 + // carries the description of a bar instead, the way it carries tags.
291 + let html = fragment(&Node::list([
292 + Row::new("Ship it").meter(Meter::new(3, 7).label("subtasks"))
293 + ]));
294 +
295 + assert!(html.contains("class=\"row-proportion\""));
296 + assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
297 + // The markup is makeover-webview's, so the trough matches the CSS emitted
298 + // for it. A second emitter here is what this crate does not do.
299 + assert!(html.contains(&makeover_webview::meter::meter_html(
300 + &layout::Meter::new(3, 7).label("subtasks"),
301 + &Emit::default(),
302 + )));
303 + }
304 +
305 + #[test]
306 + fn a_strip_of_figures_is_one_node_because_a_renderer_cannot_infer_a_set() {
307 + // `93c6a174`. The port had been making each of these out of a `Row` with
308 + // the caption as `primary` and the figure as `meta`, which reads backwards.
309 + let html = fragment(&Node::stats([
310 + Figure::new("17", "Current Streak"),
311 + Figure::new("84%", "Completion Rate").tone(layout::Tone::Success),
312 + ]));
313 +
314 + assert!(html.starts_with("<div class=\"figures\">"));
315 + assert!(html.contains(r#"aria-label="Current Streak: 17""#));
316 + assert!(html.contains(r#"data-tone="success""#));
317 + // Nothing answers a click here, so nothing is a control.
318 + assert!(!html.contains("<button"));
319 + }
320 +
321 + #[test]
322 + fn a_figure_whose_value_is_a_control_becomes_one_without_a_second_emitter() {
323 + // The consumer no candidate shape accounted for: sync's "Not Applied: 3"
324 + // opens the list. makeover-layout cannot name an action, so the address
325 + // rides beside the figure here rather than inside it.
326 + let html = fragment(&Node::Stats {
327 + figures: vec![(
328 + Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
329 + Some(Action::get("/settings/held")),
330 + )],
331 + });
332 +
333 + assert!(html.contains("<button type=\"button\" class=\"figure-act\""));
334 + assert!(html.contains("hx-get=\"/settings/held\""));
335 + // And the figure inside it is the same markup an inert one would be.
336 + assert!(html.contains(&makeover_webview::figure::figure_html(
337 + &layout::Figure::new("3", "Not Applied").tone(layout::Tone::Warning),
338 + &Emit::default(),
339 + )));
340 + }
341 +
287 342 #[test]
288 343 fn a_meter_that_ran_over_says_so_after_the_width_is_clamped() {
289 344 // The finding this member closed. "45m tracked / 30m est" was heading text
290 345 // because nothing named a bar; a bar alone would report it as exactly full.
291 - let html = fragment(&Node::Meter {
292 - done: 45,
293 - total: 30,
294 - tone: layout::Tone::Danger,
295 - label: Some("minutes".into()),
296 - });
346 + let html = fragment(&Node::Meter(
347 + Meter::new(45, 30)
348 + .tone(layout::Tone::Danger)
349 + .label("minutes"),
350 + ));
297 351 assert!(html.contains("width: 100%"));
298 352 assert!(html.contains(r#"data-over="true""#));
299 353 assert!(html.contains(r#"aria-label="45 of 30 minutes""#));
@@ -459,7 +513,7 @@
459 513 fn a_control_that_writes_on_change_says_so_without_a_form_around_it() {
460 514 // `14612ed8`. goingson reached 13 of these through `dispatch.js`, which
461 515 // exists because the description could not say this.
462 - let html = fragment(&Node::Field(
516 + let html = fragment(&Node::field(
463 517 Field::select(
464 518 "theme",
465 519 "Theme",
@@ -481,7 +535,7 @@
481 535
482 536 #[test]
483 537 fn a_field_with_no_route_is_the_plain_group_it_always_was() {
484 - let html = fragment(&Node::Field(Field::new(
538 + let html = fragment(&Node::field(Field::new(
485 539 layout::FieldKind::Text,
486 540 "title",
487 541 "Title",