Skip to main content

max / makeover-webview

0.62.0: scope the table model, unmute the figure, and draw a field's note Three changes, each a claim moving to where its evidence is. `table_rules` scopes `display: table-cell` under `.table`. `state_rules` is unchanged, so the caret and the sortable cursor stay on `.table-heading` where they belong. goingson's task headings are `.table-heading` inside a CSS grid: they want the affordance and not the table model, and until now taking one meant taking the other, inert solely because a grid blockifies `table-cell` to `block`. Costs no markup change anywhere, since quasi-webview always nests the heading inside the table. `figure_rules` writes `content` literally and `token_rules` writes `content-muted` for `.badge` literally. Both read `Tone::Neutral.token()` before, which answered `content-muted`, so a figure's headline number came out at the colour of its own caption. What makes a badge quiet is `Token::Badge` answering no click, which this crate holds and `Tone` does not know, so the claim belongs here rather than on the status axis. `form::note_rules` and the markup for makeover-layout 0.36.0's `Field::note`: a third message slot between the hint and the error, carrying its own tone on `data-tone`. Warning and Danger take `role="alert"` and the rest `role="status"`, which is quasi-webview's reading of assertiveness honoured here rather than restated differently. `aria-describedby` names all three in the order they are drawn.
Author: Max Johnson <me@maxj.phd> · 2026-08-27 23:27 UTC
Signed with PGP, not checked
Commit: b92f759dea6e9e5260976bcfd627eb8c37e22e2e
Parent: af3ac5d
3 files changed, +188 insertions, -13 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.61.0"
3 + version = "0.62.0"
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
@@ -17,7 +17,7 @@
17 17 # patch satisfy the requirement and still fail to compile. That happened once
18 18 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
19 19 # it surfaced, one release later.
20 - makeover-layout = "0.35.0"
20 + makeover-layout = "0.36.0"
21 21 # The capability axis. `makeover-touch` decides whether a hover rule should be
22 22 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
23 23 # answers are owned elsewhere and neither is re-derived here.
@@ -27,7 +27,7 @@
27 27 # satisfies "0.8" and keeps a second makeover-geometry in the graph next to the
28 28 # 0.7 this crate asks for. `Density` is nominally distinct across the two and
29 29 # the build fails on a type that reads as identical.
30 - makeover-touch = "0.25.0"
30 + makeover-touch = "0.26.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/form.rs +111 -2
@@ -41,7 +41,7 @@
41 41 //! write it back, at which point it is a form model.
42 42
43 43 use crate::{Emit, class, push_class};
44 - use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
44 + use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, Tone};
45 45 use std::fmt::Write as _;
46 46
47 47 /// Every class this module can put in markup.
@@ -70,6 +70,7 @@
70 70 "form-hint",
71 71 "form-interval",
72 72 "form-label",
73 + "form-note",
73 74 "form-option-reason",
74 75 "form-radio-group",
75 76 "form-radio-label",
@@ -448,7 +449,7 @@
448 449 /// on a control, and one reading of "what describes this field" is the point.
449 450 fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
450 451 let unit = unit_of(field).is_some();
451 - if field.hint.is_none() && field.error.is_none() && !unit {
452 + if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit {
452 453 return;
453 454 }
454 455 let mut written = false;
@@ -467,6 +468,16 @@
467 468 let _ = write!(out, "{id}-unit");
468 469 written = true;
469 470 }
471 + // The note after the unit and before the error, matching the order the
472 + // three are drawn in and the order they are useful in: what the answer
473 + // costs is context, and what is wrong with it now still comes last.
474 + if field.note.is_some() {
475 + if written {
476 + out.push(' ');
477 + }
478 + let _ = write!(out, "{id}-note");
479 + written = true;
480 + }
470 481 if field.error.is_some() {
471 482 if written {
472 483 out.push(' ');
@@ -1001,6 +1012,31 @@
1001 1012 /// control is the app's layout, exactly as `.form-hint`'s is, and a margin
1002 1013 /// asserted here would be this crate deciding a magnitude that belongs to
1003 1014 /// `makeover-geometry`.
1015 + /// The rules a field's note needs.
1016 + ///
1017 + /// [`unit_rules`]' precedent and its argument: `.form-hint` and `.form-error`
1018 + /// are the apps' own names and stay unruled here, and this one has no app
1019 + /// counterpart to keep because nothing emitted it before [`Field::note`]
1020 + /// existed.
1021 + ///
1022 + /// Colour only, and the tones are the four a badge carries. The bare class is
1023 + /// `content` rather than `content-muted`: a note is a consequence the user is
1024 + /// meant to read before answering, so muting it by default would be this crate
1025 + /// deciding it does not matter.
1026 + pub(crate) fn note_rules(opts: &Emit) -> String {
1027 + let note = class("form-note", opts);
1028 + let mut css = String::new();
1029 + let _ = writeln!(css, ".{note} {{\n color: var(--content);\n}}");
1030 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1031 + let _ = writeln!(
1032 + css,
1033 + ".{note}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
1034 + tone.token()
1035 + );
1036 + }
1037 + css
1038 + }
1039 +
1004 1040 pub(crate) fn unit_rules(opts: &Emit) -> String {
1005 1041 let unit = class("form-unit", opts);
1006 1042 let mut css = String::new();
@@ -1177,6 +1213,29 @@
1177 1213 escape_into(hint, out);
1178 1214 out.push_str("</div>");
1179 1215 }
1216 + // A consequence of the answer, between the standing help and the failure.
1217 + // The tone rides on `data-tone` -- the same attribute every other toned
1218 + // thing in this crate takes -- and it also picks the live region: Warning
1219 + // and Danger are assertive, which is quasi-webview's own reading at
1220 + // `node.rs:1403` and is honoured here rather than restated differently.
1221 + if let Some((tone, note)) = field.note {
1222 + out.push_str("<div class=\"");
1223 + push_class(out, "form-note", opts);
1224 + let assertive = matches!(tone, Tone::Warning | Tone::Danger);
1225 + let _ = write!(
1226 + out,
1227 + "\" id=\"{id}-note\" role=\"{}\"",
1228 + if assertive { "alert" } else { "status" }
1229 + );
1230 + // Neutral is the bare class rather than a variant, matching every
1231 + // other toned component here: it is the absence of a status.
1232 + if tone != Tone::Neutral {
1233 + let _ = write!(out, " data-tone=\"{}\"", tone.token());
1234 + }
1235 + out.push('>');
1236 + escape_into(note, out);
1237 + out.push_str("</div>");
1238 + }
1180 1239 if let Some(Markup(markup)) = filling.trailing {
1181 1240 out.push_str(markup);
1182 1241 }
@@ -1387,6 +1446,56 @@
1387 1446 assert!(!html.contains("has-error"), "{html}");
1388 1447 }
1389 1448
1449 + #[test]
1450 + fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
1451 + let mut f = field(FieldKind::Text);
1452 + f.hint = Some("Keep it short");
1453 + f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
1454 + f.error = Some("Required");
1455 + let html = field_html(&f, &Filling::default(), &Emit::default());
1456 +
1457 + // All three associated, in the order they are drawn.
1458 + assert!(
1459 + html.contains(r#"aria-describedby="title-hint title-note title-error""#),
1460 + "{html}"
1461 + );
1462 + assert!(
1463 + html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
1464 + "{html}"
1465 + );
1466 + // And in that order in the document, so the reading order matches.
1467 + let hint = html.find("title-hint").unwrap();
1468 + let note = html.rfind("title-note").unwrap();
1469 + let err = html.rfind("title-error").unwrap();
1470 + assert!(hint < note && note < err, "{html}");
1471 + }
1472 +
1473 + #[test]
1474 + fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
1475 + // Neutral is the bare class, matching every other toned component
1476 + // here, and only Warning and Danger interrupt.
1477 + let mut f = field(FieldKind::Text);
1478 + f.note = Some((Tone::Info, "This is what that setting implies"));
1479 + let html = field_html(&f, &Filling::default(), &Emit::default());
1480 + assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
1481 +
1482 + f.note = Some((Tone::Neutral, "An ordinary fact"));
1483 + let html = field_html(&f, &Filling::default(), &Emit::default());
1484 + assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
1485 + assert!(!html.contains("data-tone"), "{html}");
1486 + }
1487 +
1488 + #[test]
1489 + fn a_note_does_not_mark_the_group_invalid() {
1490 + // `Field::invalid` stays `error.is_some()`, and the renderer's
1491 + // `has-error` follows it rather than any message being present.
1492 + let mut f = field(FieldKind::Text);
1493 + f.note = Some((Tone::Danger, "This cannot be undone"));
1494 + let html = field_html(&f, &Filling::default(), &Emit::default());
1495 + assert!(!html.contains("has-error"), "{html}");
1496 + assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
1497 + }
1498 +
1390 1499 #[test]
1391 1500 fn the_hint_survives_an_error_arriving() {
1392 1501 let mut f = field(FieldKind::Text);
M src/lib.rs +74 -8
@@ -941,11 +941,13 @@
941 941 // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat,
942 942 // and a label with an edge says it can be pressed.
943 943 let badge = class("badge", opts);
944 - let _ = writeln!(
945 - css,
946 - ".{badge} {{\n color: var(--{});\n}}",
947 - Tone::Neutral.token()
948 - );
944 + // `content-muted` literally, not `Tone::Neutral.token()`. What makes a
945 + // badge quiet is `Token::Badge` answering no click, which this crate holds
946 + // and `Tone` genuinely does not know. Routing it through Neutral put the
947 + // claim where the evidence was not, and the bill arrived on the figure
948 + // value: it took the same muting from the same call and read as its own
949 + // caption. Neutral answers `content` from makeover-layout 0.36.0.
950 + let _ = writeln!(css, ".{badge} {{\n color: var(--content-muted);\n}}");
949 951 for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
950 952 let _ = writeln!(
951 953 css,
@@ -1409,10 +1411,15 @@
1409 1411 let change = class("figure-change", opts);
1410 1412 let mut css = String::new();
1411 1413
1414 + // `content` literally. A figure's value is the thing itself, at full
1415 + // weight -- wiki `three-tone-convention` classes it "active, emphasised",
1416 + // and both other renderers already draw it that way (makeover-tui
1417 + // `piece.rs:391` bold, makeover-immediate `widget.rs:244`). It reached
1418 + // here through `Tone::Neutral.token()` and came out `content-muted`, so
1419 + // the headline number sat at the colour of its own caption.
1412 1420 let _ = writeln!(
1413 1421 css,
1414 - ".{figure} > .{value} {{\n color: var(--{});\n}}",
1415 - Tone::Neutral.token()
1422 + ".{figure} > .{value} {{\n color: var(--content);\n}}"
1416 1423 );
1417 1424 let _ = writeln!(
1418 1425 css,
@@ -1781,7 +1788,19 @@
1781 1788 ".{table} {{\n display: table;\n width: 100%;\n}}"
1782 1789 );
1783 1790 let _ = writeln!(css, ".{head},\n.{row} {{\n display: table-row;\n}}");
1784 - let _ = writeln!(css, ".{heading},\n.{cell} {{\n display: table-cell;\n}}");
1791 + // Scoped under `.{table}` rather than keyed on the classes alone. The
1792 + // table model is what a cell takes *by being in a table*, and only there:
1793 + // goingson's task headings are `.table-heading` inside a CSS grid, so the
1794 + // unscoped rule reached them, and the sort caret and the sortable cursor
1795 + // -- which `state_rules` keys on `.table-heading` and rightly still does
1796 + // -- came with a `display` the grid had to blockify away.
1797 + //
1798 + // It costs no markup anywhere: quasi-webview always nests the heading
1799 + // inside the table (`quasi-webview/src/node.rs:1591`, `:1605`, `:1615`).
1800 + let _ = writeln!(
1801 + css,
1802 + ".{table} .{heading},\n.{table} .{cell} {{\n display: table-cell;\n}}"
1803 + );
1785 1804
1786 1805 // A content column shrinks to what is in it. `width: 1%` is how a CSS table
1787 1806 // is told that: auto layout hands the slack to the columns that asked for
@@ -1889,6 +1908,7 @@
1889 1908 css.push_str(&form::editor_rules(opts));
1890 1909 css.push_str(&form::suggestion_rules(opts));
1891 1910 css.push_str(&form::unit_rules(opts));
1911 + css.push_str(&form::note_rules(opts));
1892 1912 css
1893 1913 }
1894 1914
@@ -2698,6 +2718,52 @@
2698 2718 assert!(!css.contains(".button[data-tone=\"danger\"] {\n background"));
2699 2719 }
2700 2720
2721 + #[test]
2722 + fn the_table_model_is_scoped_to_the_table_but_the_caret_is_not() {
2723 + // goingson's task headings are `.table-heading` inside a CSS grid.
2724 + // They want the sort caret and the sortable cursor; they do not want
2725 + // `display: table-cell`, which a grid item blockifies away anyway.
2726 + // Scoping the one and not the other is what separates them.
2727 + let css = component_rules(&Emit::default());
2728 +
2729 + assert!(
2730 + css.contains(".table .table-heading,\n.table .cell {\n display: table-cell;"),
2731 + "{css}"
2732 + );
2733 + assert!(
2734 + !css.contains(".table-heading,\n.cell {\n display: table-cell;"),
2735 + "the table model is still unscoped: {css}"
2736 + );
2737 +
2738 + let states = state_rules(&Emit::default());
2739 + assert!(
2740 + states.contains(".table-heading[aria-sort"),
2741 + "the caret got scoped along with the model: {states}"
2742 + );
2743 + }
2744 +
2745 + #[test]
2746 + fn a_figure_value_is_the_thing_itself_and_a_badge_is_quiet() {
2747 + // Both used to read `Tone::Neutral.token()`, which answered
2748 + // `content-muted`, so the headline number sat at the colour of its own
2749 + // caption. Neutral answers `content` now; each site states its own
2750 + // claim rather than borrowing one from the status axis.
2751 + let css = component_rules(&Emit::default());
2752 +
2753 + assert!(
2754 + css.contains(".figure > .figure-value {\n color: var(--content);"),
2755 + "{css}"
2756 + );
2757 + assert!(
2758 + css.contains(".figure > .figure-caption {\n color: var(--content-muted);"),
2759 + "{css}"
2760 + );
2761 + assert!(
2762 + css.contains(".badge {\n color: var(--content-muted);"),
2763 + "{css}"
2764 + );
2765 + }
2766 +
2701 2767 #[test]
2702 2768 fn a_described_list_is_not_a_bulleted_list() {
2703 2769 let css = component_rules(&Emit::default());