Skip to main content

max / makeover-webview

0.21.0: a region's stand-in, and a sortable table heading
Author: Max Johnson <me@maxj.phd> · 2026-08-09 16:23 UTC
Signed with PGP, not checked
Commit: 27c052d61379c9e3c10b4e8b263abbead79f6d12
Parent: 83824e1
4 files changed, +251 insertions, -6 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.20.0"
3 + version = "0.21.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.11.0"
14 + makeover-layout = "0.12.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/lib.rs +55 -1
@@ -197,6 +197,7 @@
197 197 pub mod form;
198 198 pub mod list;
199 199 pub mod meter;
200 + pub mod placeholder;
200 201
201 202 use crate::list::part_class;
202 203 use makeover_geometry::{Density, SizeClass};
@@ -822,6 +823,52 @@
822 823 css
823 824 }
824 825
826 + /// A region's stand-in, and the header of a table that can be reordered.
827 + ///
828 + /// Both are 0.12.0 members and both are colour and affordance only, which is
829 + /// where `figure_rules` landed after trying to emit a type scale. How much room
830 + /// a stand-in gets is a size — goingson has the same one at three, as
831 + /// `--compact`, `--dashboard` and `--padded` — and a size is
832 + /// `makeover-geometry`'s question.
833 + ///
834 + /// The caret is the one thing here that is neither colour nor affordance, and it
835 + /// is a renderer's own expression rather than a value the description named:
836 + /// `aria-sort` is what the table actually says, and this turns it into something
837 + /// visible for everyone not using a screen reader. A terminal draws its own; an
838 + /// immediate-mode painter draws its own.
839 + fn state_rules(opts: &Emit) -> String {
840 + let placeholder = class("placeholder", opts);
841 + let text = class("placeholder-text", opts);
842 + let heading = class("table-heading", opts);
843 + let mut css = String::new();
844 +
845 + let _ = writeln!(
846 + css,
847 + ".{placeholder} > .{text} {{\n color: var(--content-muted);\n}}"
848 + );
849 + // Only the failure is toned. An empty list is the normal state of a new
850 + // install, and `Readiness::tone` is what says so.
851 + let _ = writeln!(
852 + css,
853 + ".{placeholder}[data-tone=\"{0}\"] > .{text} {{\n color: var(--{0});\n}}",
854 + Tone::Danger.token()
855 + );
856 +
857 + // A header that reorders the table is a control, and the pointer is the
858 + // only part of saying so that is not the app's own type and spacing.
859 + let _ = writeln!(
860 + css,
861 + ".{heading}[data-sortable] {{\n cursor: pointer;\n}}"
862 + );
863 + for (direction, caret) in [("ascending", "\\2191"), ("descending", "\\2193")] {
864 + let _ = writeln!(
865 + css,
866 + ".{heading}[aria-sort=\"{direction}\"]::after {{\n content: \"{caret}\";\n}}"
867 + );
868 + }
869 + css
870 + }
871 +
825 872 /// The component layer: every named thing phase A emits.
826 873 ///
827 874 /// No scrollbar track. It was on the phase A list and came off: eight lines of
@@ -836,6 +883,7 @@
836 883 css.push_str(&row_rules(opts));
837 884 css.push_str(&progress_rules(opts));
838 885 css.push_str(&figure_rules(opts));
886 + css.push_str(&state_rules(opts));
839 887 css
840 888 }
841 889
@@ -1500,9 +1548,15 @@
1500 1548 value.contains("inset")
1501 1549 || value.contains(opts.border_width)
1502 1550 || value.contains(opts.focus_width)
1551 + // 0.12.0's two: a sortable header is a control and says so
1552 + // with the pointer, and the caret is this renderer's own
1553 + // expression of `aria-sort`. Neither is a colour, which is
1554 + // what this test is actually about, and neither is a size,
1555 + // which is the other thing this crate must not name.
1556 + || value.starts_with("\"\\2")
1503 1557 || matches!(
1504 1558 value.trim_end_matches(';'),
1505 - "0" | "1" | "none" | "auto" | "not-allowed"
1559 + "0" | "1" | "none" | "auto" | "not-allowed" | "pointer"
1506 1560 ),
1507 1561 "unrecognised literal value: {line}"
1508 1562 );
M src/list.rs +3 -3
@@ -261,19 +261,19 @@
261 261 fn columns() -> Vec<Column<'static>> {
262 262 vec![
263 263 Column {
264 - name: "description",
265 264 width: Width::Fill,
266 265 priority: Priority::Essential,
266 + ..Column::new("description")
267 267 },
268 268 Column {
269 - name: "due",
270 269 width: Width::Fixed,
271 270 priority: Priority::Secondary,
271 + ..Column::new("due")
272 272 },
273 273 Column {
274 - name: "progress",
275 274 width: Width::Fixed,
276 275 priority: Priority::Optional,
276 + ..Column::new("progress")
277 277 },
278 278 ]
279 279 }
@@ -1,0 +1,191 @@
1 + //! What a region shows when it is not showing its content.
2 + //!
3 + //! The fifth phase-B emitter. `makeover_layout::Readiness` grew from two states
4 + //! to four at 0.12.0, and this is where the two new ones become markup: goingson
5 + //! drew an empty state at 27 sites across 12 files and Balanced Breakfast at 9,
6 + //! each app with its own class family, and the families had already drifted
7 + //! into `empty-state--error` against `error-state` for the same fact.
8 + //!
9 + //! # Why one function for three states
10 + //!
11 + //! `Pending`, `Empty` and `Failed` are the same anatomy — a region-sized box
12 + //! with a line of text in it — differing in what the text means and what colour
13 + //! it takes. Three emitters would be three copies of a `<div>` and a `<p>`, and
14 + //! the interesting thing about them is precisely the state, which the
15 + //! description carries. `Ready` renders nothing here by construction: it is the
16 + //! state that shows content, so there is no stand-in to draw.
17 + //!
18 + //! # The action, and why it arrives as markup
19 + //!
20 + //! Two of goingson's 27 empty states offer a way out — "No projects yet" with an
21 + //! "Add your first project" button under it. A button is an address, and no
22 + //! crate in this family names one. So it arrives through [`Markup`], the
23 + //! existing named hole in the escaping, the same way a field's trailing block
24 + //! does. The caller states that what it is passing is trusted; nothing here can
25 + //! check that for them.
26 +
27 + use crate::form::{Markup, escape};
28 + use crate::{Emit, class};
29 + use makeover_layout::{Intent, Readiness, Tone};
30 + use std::fmt::Write as _;
31 +
32 + /// A region's stand-in, or nothing at all when the region has its content.
33 + ///
34 + /// ```
35 + /// use makeover_layout::Readiness;
36 + /// use makeover_webview::{Emit, placeholder::placeholder_html};
37 + ///
38 + /// let html = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
39 + /// assert!(html.contains(r#"data-state="empty""#));
40 + /// assert!(html.contains("No projects yet"));
41 + ///
42 + /// // The one state that draws its own content draws no stand-in.
43 + /// assert!(placeholder_html(Readiness::Ready, "unused", None, &Emit::default()).is_empty());
44 + /// ```
45 + ///
46 + /// `role="status"` rather than `alert` for everything but a failure, on the same
47 + /// reasoning `Node::Notice` uses: an empty list is not an interruption. A
48 + /// failure is, because the user is looking at a region that should have had
49 + /// something in it and nothing else on the page will say so.
50 + #[must_use]
51 + pub fn placeholder_html(
52 + state: Readiness,
53 + message: &str,
54 + action: Option<Markup<'_>>,
55 + opts: &Emit,
56 + ) -> String {
57 + if state.shows_content() {
58 + return String::new();
59 + }
60 +
61 + let name = state_name(state);
62 + let mut html = format!(
63 + "<div class=\"{}\" data-state=\"{name}\"",
64 + class("placeholder", opts)
65 + );
66 +
67 + // Derived, not carried. "Nothing here yet" and "this broke" mean the same
68 + // thing in every app that will ever have them, which is what separates this
69 + // from a meter's tone.
70 + if state.tone() != Tone::Neutral {
71 + let _ = write!(html, " data-tone=\"{}\"", state.tone().token());
72 + }
73 + if state.tone() == Tone::Danger {
74 + html.push_str(" role=\"alert\"");
75 + } else {
76 + html.push_str(" role=\"status\" aria-live=\"polite\"");
77 + }
78 +
79 + let _ = write!(
80 + html,
81 + "><p class=\"{}\">{}</p>",
82 + class("placeholder-text", opts),
83 + escape(message)
84 + );
85 + if let Some(Markup(markup)) = action {
86 + let _ = write!(
87 + html,
88 + "<div class=\"{}\">{markup}</div>",
89 + class("placeholder-action", opts)
90 + );
91 + }
92 + html.push_str("</div>");
93 + html
94 + }
95 +
96 + /// The `data-state` value for a state.
97 + ///
98 + /// A wildcard rather than a total match, because `Readiness` is
99 + /// `#[non_exhaustive]` as of 0.12.0. A state added upstream draws the plain
100 + /// stand-in with no state of its own, which is a box rendering without its
101 + /// colour rather than a build that stops.
102 + fn state_name(state: Readiness) -> &'static str {
103 + match state {
104 + Readiness::Ready => "ready",
105 + Readiness::Pending => "pending",
106 + Readiness::Empty => "empty",
107 + Readiness::Failed => "failed",
108 + _ => "unknown",
109 + }
110 + }
111 +
112 + #[cfg(test)]
113 + mod tests {
114 + use super::*;
115 +
116 + #[test]
117 + fn the_state_that_shows_content_draws_no_stand_in() {
118 + // Not an empty box: nothing at all, or every ready region gains an
119 + // element that pushes its content down.
120 + assert!(placeholder_html(Readiness::Ready, "x", None, &Emit::default()).is_empty());
121 + }
122 +
123 + #[test]
124 + fn an_empty_region_is_not_announced_as_a_fault() {
125 + // An empty list is the normal state of a new install. `role="alert"`
126 + // interrupts a screen reader mid-sentence, which is the wrong thing to
127 + // do about "no projects yet".
128 + let empty = placeholder_html(Readiness::Empty, "No projects yet", None, &Emit::default());
129 + assert!(empty.contains(r#"role="status""#));
130 + assert!(!empty.contains("data-tone"));
131 +
132 + let failed = placeholder_html(
133 + Readiness::Failed,
134 + "Failed to load events",
135 + None,
136 + &Emit::default(),
137 + );
138 + assert!(failed.contains(r#"role="alert""#));
139 + assert!(failed.contains(r#"data-tone="danger""#));
140 + }
141 +
142 + #[test]
143 + fn the_message_is_escaped_and_the_action_is_not() {
144 + // The asymmetry is the whole point of `Markup`, and it is the same one
145 + // a field's trailing block has: text from the app is escaped, and a
146 + // block the caller has stated is markup is passed through.
147 + let html = placeholder_html(
148 + Readiness::Empty,
149 + "No <b>projects</b> yet",
150 + Some(Markup("<button>Add one</button>")),
151 + &Emit::default(),
152 + );
153 + assert!(html.contains("&lt;b&gt;"));
154 + assert!(!html.contains("<b>"));
155 + assert!(html.contains("<button>Add one</button>"));
156 + }
157 +
158 + #[test]
159 + fn a_state_with_no_action_emits_no_action_container() {
160 + // 25 of goingson's 27 empty states have no way out. An empty container
161 + // at each of them is a box the stylesheet has to know to collapse.
162 + let html = placeholder_html(Readiness::Empty, "Nothing here", None, &Emit::default());
163 + assert!(!html.contains("placeholder-action"));
164 + }
165 +
166 + #[test]
167 + fn pending_draws_the_same_anatomy_as_the_other_two() {
168 + // Three states, one box. What differs is what the text means, which is
169 + // what the description carries.
170 + let html = placeholder_html(Readiness::Pending, "Loading", None, &Emit::default());
171 + assert!(html.contains(r#"data-state="pending""#));
172 + assert!(html.contains("Loading"));
173 + }
174 +
175 + #[test]
176 + fn the_prefix_reaches_every_class() {
177 + let opts = Emit {
178 + class_prefix: "mo-",
179 + ..Emit::default()
180 + };
181 + let html = placeholder_html(
182 + Readiness::Empty,
183 + "None",
184 + Some(Markup("<button>Go</button>")),
185 + &opts,
186 + );
187 + assert!(html.contains(r#"class="mo-placeholder""#));
188 + assert!(html.contains(r#"class="mo-placeholder-text""#));
189 + assert!(html.contains(r#"class="mo-placeholder-action""#));
190 + }
191 + }