Skip to main content

max / makeover-webview

0.10.0: emit the states this crate left to its consumers interactive_rules emitted hover and pressed and stopped, because makeover-layout modelled no interaction state. Focus and disabled were unsayable, so each app supplied them by out-specifying a rule it does not own, and the three focus rings do not match. That also blocked the cascade-layer work outright. An app declaring @layer puts its own rules in a named layer, and unlayered declarations outrank every named layer regardless of specificity, so all of those overrides lose in the commit that adopts layers. They cannot be deleted either, since they are the only thing supplying the missing states. Emitting the states here is what turns that adoption into a deletion. Four states now, and emission order is the mechanism: all four selectors are specificity (0,2,0), so disabled beats hover by coming last. Nothing reaches for :not(:disabled), which would raise a selector this crate will shortly be wrapping in a layer of its own. The focus ring uses outline rather than a composed box-shadow, which is the one place it is built differently from the invalid-field ring. A box-shadow ring has to restate the bevel beside it, since box-shadow is not additive and a lone ring drops the well out from under the element. That restatement is a second copy of the depth living in another function, which is the duplication Depth exists to prevent. outline has its own property, so the bevel survives untouched. Both render as a flush ring one border-width wide. Hover now sits inside a capability query. makeover-touch answers whether a fingertip has hover and makeover-geometry spells the condition; this crate asks and does not decide. goingson's section 60 exists solely to take that hover state back on touch, which is a fight it should never have been handed. The row reveal splits for the same reason: the hover half is gated and focus-within stays outside it, or a touchscreen with a keyboard loses its only route to the row actions. A field gains focus and disabled and no hover or pressed. A text field does not light up under the pointer and does not invert when clicked.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 15:35 UTC
Signed with PGP, not checked
Commit: 32bdcdabe575b324404d59ef710ac85a0e254d71
Parent: 2dc3970
2 files changed, +326 insertions, -23 deletions
M Cargo.toml +7 -2
@@ -1,13 +1,18 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.9.1"
3 + version = "0.10.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"
7 7 repository = "https://makenot.work/git/max/makeover-webview"
8 8
9 9 [dependencies]
10 - makeover-layout = "0.6.0"
10 + makeover-layout = "0.7"
11 + # The capability axis. `makeover-touch` decides whether a hover rule should be
12 + # gated at all; `makeover-geometry` spells the gate as a media condition. Both
13 + # answers are owned elsewhere and neither is re-derived here.
14 + makeover-touch = "0.1"
15 + makeover-geometry = "0.5"
11 16
12 17 [lints.rust]
13 18 unused = "warn"
M src/lib.rs +319 -21
@@ -72,6 +72,33 @@
72 72 //! decides which wins. That is the one real risk in adopting this, and it is
73 73 //! why the migration lands per component rather than in one commit.
74 74 //!
75 + //! # 0.10.0: the states this crate used to leave to its consumers
76 + //!
77 + //! [`interactive_rules`] emitted hover and pressed and stopped, because
78 + //! `makeover-layout` modelled no interaction state. Focus and disabled were
79 + //! therefore unsayable, and every app completed the primitive from outside the
80 + //! only way that works: by out-specifying a rule it does not own. goingson
81 + //! carries 19 such rules and the MNW server 21, and the three focus rings do
82 + //! not match each other.
83 + //!
84 + //! That also blocked the cascade-layer work outright. An app that declares
85 + //! `@layer` puts its own rules in a named layer, and unlayered declarations
86 + //! outrank every named layer regardless of specificity, so all of those
87 + //! overrides lose in the commit that adopts layers. They cannot simply be
88 + //! deleted, because they are the only thing supplying the missing states.
89 + //! Emitting the states here is what turns that adoption into a deletion.
90 + //!
91 + //! Four states now, in emission order, and the order is load-bearing: they are
92 + //! all specificity (0,2,0), so disabled beats hover by coming last and by
93 + //! nothing else. Nothing here reaches for `:not(:disabled)`, which would raise
94 + //! a selector this crate will shortly be wrapping in its own layer.
95 + //!
96 + //! Hover additionally sits inside a capability query now. `makeover-touch`
97 + //! answers whether a fingertip has hover and `makeover-geometry` spells the
98 + //! condition; this crate asks and does not decide. goingson's section 60 exists
99 + //! solely to take the hover state back on touch, which is a fight it should
100 + //! never have been handed.
101 + //!
75 102 //! # Substitution, three ways
76 103 //!
77 104 //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
@@ -90,7 +117,9 @@
90 117 pub mod form;
91 118 pub mod list;
92 119
93 - use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
120 + use makeover_geometry::{Density, SizeClass};
121 + use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, State, Token, Tone};
122 + use makeover_touch::Affordance;
94 123 use std::fmt::Write as _;
95 124
96 125 /// How the emitted CSS is shaped.
@@ -229,7 +258,111 @@
229 258 format!(".{selector} {{\n{body}}}\n")
230 259 }
231 260
232 - /// Hover and pressed, for a selector that answers a click.
261 + /// The media condition a hover rule has to sit inside, or `None` if hover is
262 + /// unconditional.
263 + ///
264 + /// Two crates answer this and neither answer is made here. `makeover-touch`
265 + /// owns *whether* hover exists at a density, and `makeover-geometry` owns how
266 + /// that capability is spelled as a media condition. Asking both is what stops
267 + /// this renderer minting a third opinion, which is what all three apps did:
268 + /// goingson sniffed the user agent, Balanced Breakfast used `(hover: none)`
269 + /// alone, and the MNW server had no gate at all.
270 + ///
271 + /// [`SizeClass`] is required by [`Affordance::available`] and ignored by this
272 + /// member, which reports as much through `reads_size`. Passing Compact is not
273 + /// a claim about width; the test below pins that every class agrees.
274 + fn hover_condition() -> Option<&'static str> {
275 + if Affordance::Hover.available(Density::Touch, SizeClass::Compact) {
276 + // A fingertip grew a hover state. Nothing to gate, and this renderer
277 + // should not invent a reason to gate anyway.
278 + None
279 + } else {
280 + Some(Density::Pointer.media_condition())
281 + }
282 + }
283 +
284 + /// Put a rule inside a media query, or leave it alone.
285 + fn gated(condition: Option<&str>, rule: &str) -> String {
286 + let Some(condition) = condition else {
287 + return rule.to_string();
288 + };
289 + let mut css = format!("@media {condition} {{\n");
290 + for line in rule.lines() {
291 + // Blank lines stay blank. Indenting one leaves trailing whitespace,
292 + // which is the sort of thing a formatter later reverts and calls a diff.
293 + if line.is_empty() {
294 + css.push('\n');
295 + } else {
296 + let _ = writeln!(css, " {line}");
297 + }
298 + }
299 + css.push_str("}\n");
300 + css
301 + }
302 +
303 + /// The keyboard focus ring, placed by the depth it lands on.
304 + ///
305 + /// One ring for the whole system, because a focus ring's job is to be
306 + /// recognised and three apps having three of them is the failure. What varies
307 + /// is where it sits, and that comes off [`Depth`] rather than off a per-
308 + /// component choice: a well takes the ring inside its own edge, and anything
309 + /// standing proud of the page takes it outside.
310 + ///
311 + /// `outline` rather than the composed `box-shadow` the invalid-field ring at
312 + /// [`field_rules`] uses, and deliberately the one place the two rings are built
313 + /// differently. A `box-shadow` ring has to restate the bevel beside it, because
314 + /// `box-shadow` is not additive and a lone ring silently drops the well out
315 + /// from under the element. That restatement is a second copy of the depth,
316 + /// living in a different function from the first, and it is exactly the
317 + /// duplication `Depth` exists to prevent. `outline` occupies its own property,
318 + /// so the bevel survives untouched and there is nothing to keep in agreement.
319 + /// They render the same: both are a flush ring one border-width wide.
320 + #[must_use]
321 + pub fn focus_rule(selector: &str, depth: Depth, opts: &Emit) -> String {
322 + let w = opts.border_width;
323 + let offset = match depth.bevel() {
324 + // Inside the well, clear of the inset edge rather than painted over
325 + // it. Two widths in: one to cross the edge, one to stand off it.
326 + Some(Bevel::Inset) => format!("calc(-2 * {w})"),
327 + // Raised, or no edge at all. Outside, standing off by its own width.
328 + _ => w.to_string(),
329 + };
330 + format!(
331 + ".{selector}:focus-visible {{\n outline: {w} solid var(--{});\n outline-offset: {offset};\n}}\n",
332 + State::Focus.token()
333 + )
334 + }
335 +
336 + /// Present, visible, and not answering.
337 + ///
338 + /// Matches the ARIA attribute as well as the pseudo-class, because `:disabled`
339 + /// only matches form elements and half the things this crate emits are not
340 + /// one: a `div` carrying `.chip` or `.tab` can never be `:disabled`. Keying on
341 + /// the accessible state is the pattern [`field_rules`] already establishes for
342 + /// `aria-invalid`, on the reasoning that one fact read by both the styling and
343 + /// the accessibility tree cannot drift from itself.
344 + ///
345 + /// The rest depth is re-asserted rather than assumed, because this rule has to
346 + /// beat the hover and pressed rules above it. It does that on source order at
347 + /// equal specificity, not by out-specifying them: every rule this function's
348 + /// caller emits is (0,2,0), and adding a `:not(:disabled)` anywhere would raise
349 + /// one of them and have to be unpicked when this output moves inside its own
350 + /// cascade layer.
351 + #[must_use]
352 + pub fn disabled_rule(selector: &str, depth: Depth) -> String {
353 + format!(
354 + ".{selector}:disabled,\n.{selector}[aria-disabled=\"true\"] {{\n{} color: var(--{});\n cursor: not-allowed;\n}}\n",
355 + depth_declarations(depth),
356 + State::Disabled.token()
357 + )
358 + }
359 +
360 + /// Every state a selector that answers a click implies: hover, pressed, focus
361 + /// and disabled, in that order.
362 + ///
363 + /// Order is the whole cascade mechanism here. All four selectors are
364 + /// specificity (0,2,0), so disabled wins over hover and pressed by coming last
365 + /// and by nothing else.
233 366 ///
234 367 /// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting
235 368 /// only the edge is what left goingson hand-writing `background:
@@ -241,13 +374,30 @@
241 374 /// Hover has no member in the description and is renderer policy: a terminal
242 375 /// and an immediate-mode painter have no hover to express. It resolves against
243 376 /// `--hover-surface`, which `makeover` already derives and which nothing
244 - /// consumed until now.
377 + /// consumed until now. What it *is* gated on is capability, via
378 + /// [`hover_condition`]. Before that gate existed the apps each wrote their own:
379 + /// goingson's section 60 exists solely to take back the hover state this
380 + /// function had just handed it, by out-specifying a rule it does not own.
381 + ///
382 + /// `depth` is the selector's **rest** depth, used to place the focus ring and
383 + /// to restore the surface under a disabled control. The pressed rule keeps
384 + /// inverting from [`Depth::Raised`] regardless, which is what every caller got
385 + /// before this parameter existed: a tab's unchosen depth is
386 + /// [`Depth::Sunken`], and `Sunken.pressed()` is `Sunken`, so deriving the press
387 + /// from the rest depth would leave a tab with no press at all.
245 388 #[must_use]
246 - pub fn interactive_rules(selector: &str) -> String {
247 - format!(
248 - ".{selector}:hover {{\n background: var(--hover-surface);\n}}\n{}",
249 - depth_rule(&format!("{selector}:active"), Depth::Raised.pressed())
250 - )
389 + pub fn interactive_rules(selector: &str, depth: Depth, opts: &Emit) -> String {
390 + let mut css = gated(
391 + hover_condition(),
392 + &format!(".{selector}:hover {{\n background: var(--hover-surface);\n}}\n"),
393 + );
394 + css.push_str(&depth_rule(
395 + &format!("{selector}:active"),
396 + Depth::Raised.pressed(),
397 + ));
398 + css.push_str(&focus_rule(selector, depth, opts));
399 + css.push_str(&disabled_rule(selector, depth));
400 + css
251 401 }
252 402
253 403 /// One rule per depth: its fill and its edge, together.
@@ -265,7 +415,7 @@
265 415 css.push_str(&depth_rule(&class, depth));
266 416 }
267 417 if let Some(raised) = depth_class(Depth::Raised, opts) {
268 - css.push_str(&interactive_rules(&raised));
418 + css.push_str(&interactive_rules(&raised, Depth::Raised, opts));
269 419 }
270 420 css
271 421 }
@@ -283,12 +433,20 @@
283 433 for name in ["button", "card"] {
284 434 let c = class(name, opts);
285 435 css.push_str(&depth_rule(&c, Depth::Raised));
286 - css.push_str(&interactive_rules(&c));
436 + css.push_str(&interactive_rules(&c, Depth::Raised, opts));
287 437 }
288 438
289 439 let field = class("field", opts);
290 440 css.push_str(&depth_rule(&field, Depth::Well));
291 441
442 + // A field takes focus and refuses input like everything else here, and got
443 + // neither until now, which is why all three apps hand-write a focus ring
444 + // for it and no two of them match. No hover or pressed: a text field does
445 + // not light up under the pointer and does not invert when clicked, so the
446 + // two states `interactive_rules` would add are the two it does not have.
447 + css.push_str(&focus_rule(&field, Depth::Well, opts));
448 + css.push_str(&disabled_rule(&field, Depth::Well));
449 +
292 450 // Keyed on the ARIA attribute rather than on a class, so the visual state
293 451 // and the accessible state cannot drift apart: there is one fact and both
294 452 // read it. goingson already drove its invalid styling this way and was
@@ -344,7 +502,7 @@
344 502 let chip = class("chip", opts);
345 503 let unlatched = Token::Chip { removable: false };
346 504 css.push_str(&depth_rule(&chip, unlatched.depth(false)));
347 - css.push_str(&interactive_rules(&chip));
505 + css.push_str(&interactive_rules(&chip, unlatched.depth(false), opts));
348 506 css.push_str(&depth_rule(
349 507 &format!("{chip}.latched"),
350 508 unlatched.depth(true),
@@ -375,7 +533,7 @@
375 533 ] {
376 534 let c = class(name, opts);
377 535 css.push_str(&depth_rule(&c, selector.unchosen()));
378 - css.push_str(&interactive_rules(&c));
536 + css.push_str(&interactive_rules(&c, selector.unchosen(), opts));
379 537 css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen()));
380 538 }
381 539 css
@@ -426,10 +584,23 @@
426 584 css,
427 585 ".{c} {{\n opacity: 0;\n pointer-events: none;\n}}"
428 586 );
429 - let _ = writeln!(
430 - css,
431 - ".{row}:hover .{c},\n.{row}:focus-within .{c} {{\n opacity: 1;\n pointer-events: auto;\n}}"
432 - );
587 +
588 + // The two halves split here, where they used to be one selector
589 + // list. Hover-to-reveal is the literal case `Affordance::Hover`
590 + // was written from, and on a touchscreen it does not fail
591 + // gracefully: the actions are simply unreachable, because there
592 + // is no pointer to bring them back. So the hover half is gated
593 + // and the app owes those rows another way in.
594 + //
595 + // `focus-within` stays outside the query. A touchscreen device
596 + // with a keyboard attached is a real thing, and it is the one
597 + // path to these actions that survives the gate.
598 + let revealed = " opacity: 1;\n pointer-events: auto;\n";
599 + css.push_str(&gated(
600 + hover_condition(),
601 + &format!(".{row}:hover .{c} {{\n{revealed}}}\n"),
602 + ));
603 + let _ = write!(css, ".{row}:focus-within .{c} {{\n{revealed}}}\n");
433 604 }
434 605 }
435 606 css
@@ -575,7 +746,7 @@
575 746 // bevel flip alone is what left goingson hand-writing `background:
576 747 // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
577 748 // of the three could be deleted.
578 - let pressed = interactive_rules("button");
749 + let pressed = interactive_rules("button", Depth::Raised, &Emit::default());
579 750 assert!(pressed.contains(".button:active {"));
580 751 assert!(
581 752 pressed.contains("background: var(--surface-well, var(--surface-page))"),
@@ -615,9 +786,126 @@
615 786 );
616 787 }
617 788
789 + #[test]
790 + fn a_primitive_owns_every_state_it_implies() {
791 + // The whole point of 0.10.0. Anything emitting a hover rule owes the
792 + // other three, or the consuming app supplies them by out-specifying a
793 + // rule it does not own: 19 such rules in goingson, 21 in the MNW
794 + // server, and three focus rings that do not match.
795 + let css = stylesheet(&Emit::default());
796 + for selector in ["button", "card", "chip", "tab", "segment", "toggle"] {
797 + assert!(css.contains(&format!(".{selector}:hover {{")), "{selector}");
798 + assert!(css.contains(&format!(".{selector}:active {{")), "{selector}");
799 + assert!(
800 + css.contains(&format!(".{selector}:focus-visible {{")),
801 + "{selector} has no focus ring"
802 + );
803 + assert!(
804 + css.contains(&format!(".{selector}:disabled,")),
805 + "{selector} has no disabled state"
806 + );
807 + }
808 + }
809 +
810 + #[test]
811 + fn a_field_takes_focus_and_refuses_input_without_taking_a_hover() {
812 + // A text field does not light up under the pointer, so it gets the two
813 + // states it has and not the two it does not.
814 + let css = stylesheet(&Emit::default());
815 + assert!(css.contains(".field:focus-visible {"));
816 + assert!(css.contains(".field:disabled,"));
817 + assert!(!css.contains(".field:hover {"));
818 + assert!(!css.contains(".field:active {"));
819 + }
820 +
821 + #[test]
822 + fn disabled_is_emitted_after_hover_so_source_order_settles_it() {
823 + // Every one of these selectors is specificity (0,2,0), so nothing but
824 + // order decides which wins. A disabled button taking the hover fill is
825 + // the exact bug goingson's `.button:disabled:hover` was written to fix,
826 + // and the reason it had to reach (0,3,0) to do it.
827 + let css = interactive_rules("button", Depth::Raised, &Emit::default());
828 + let hover = css.find(":hover").expect("hover");
829 + let active = css.find(":active").expect("active");
830 + let focus = css.find(":focus-visible").expect("focus");
831 + let disabled = css.find(":disabled").expect("disabled");
832 + assert!(hover < active && active < focus && focus < disabled);
833 +
834 + // And it restores the surface, or the hover fill survives underneath.
835 + let tail = &css[disabled..];
836 + assert!(tail.contains("background: var(--surface-raised)"));
837 + }
838 +
839 + #[test]
840 + fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
841 + // `:disabled` matches form elements only, and a chip is a div. Keying
842 + // on the ARIA attribute too is the pattern the invalid field already
843 + // set: one fact, read by the styling and the accessibility tree alike.
844 + let css = disabled_rule("chip", Depth::Raised);
845 + assert!(css.contains(".chip:disabled,"));
846 + assert!(css.contains(".chip[aria-disabled=\"true\"]"));
847 + assert!(css.contains("cursor: not-allowed"));
848 + }
849 +
850 + #[test]
851 + fn the_focus_ring_does_not_disturb_the_bevel_it_lands_on() {
852 + // `outline` has its own property, so unlike the invalid ring there is
853 + // no bevel to restate beside it and nothing to keep in agreement.
854 + let opts = Emit::default();
855 + let css = focus_rule("button", Depth::Raised, &opts);
856 + assert!(css.contains("outline: 1px solid var(--focus-ring)"));
857 + assert!(!css.contains("box-shadow"), "the ring restated the bevel");
858 + }
859 +
860 + #[test]
861 + fn a_well_takes_the_ring_inside_and_a_raised_surface_outside() {
862 + // One ring, placed by depth. The offset comes off `Depth::bevel` and
863 + // not off a per-component choice, which is what gave three apps three
864 + // different rings.
865 + let opts = Emit::default();
866 + assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-2 * 1px)"));
867 + assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 1px"));
868 + // Nothing to sit inside of, so it sits outside.
869 + assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 1px"));
870 + }
871 +
872 + #[test]
873 + fn hover_is_gated_on_capability_and_the_keyboard_path_is_not() {
874 + // goingson's section 60 exists only to take back the hover state this
875 + // crate handed it. Gating at the source is what deletes that section
876 + // in all three apps rather than having each fight for it.
877 + let css = stylesheet(&Emit::default());
878 + let condition = format!("@media {}", Density::Pointer.media_condition());
879 + assert!(css.contains(&condition));
880 +
881 + // The row reveal splits: hover inside the query, focus-within outside,
882 + // or a touchscreen with a keyboard loses its only way to the actions.
883 + let reveal = css.find(".row:hover .row-actions").expect("hover reveal");
884 + let keyboard = css
885 + .find(".row:focus-within .row-actions")
886 + .expect("keyboard reveal");
887 + let query_end = css[reveal..].find("\n}\n").expect("query closes") + reveal;
888 + assert!(reveal < query_end && query_end < keyboard);
889 + }
890 +
891 + #[test]
892 + fn the_capability_answer_is_asked_for_and_not_assumed() {
893 + // Both halves come from the crates that own them. If `makeover-touch`
894 + // ever says a fingertip has hover, this stops gating on its own.
895 + assert!(!Affordance::Hover.available(Density::Touch, SizeClass::Compact));
896 + assert!(Affordance::Hover.available(Density::Pointer, SizeClass::Compact));
897 + assert_eq!(hover_condition(), Some(Density::Pointer.media_condition()));
898 +
899 + // And the size class passed to that call is not a claim about width.
900 + assert!(Affordance::Hover.reads_density());
901 + for size in [SizeClass::Compact, SizeClass::Medium, SizeClass::Expanded] {
902 + assert!(!Affordance::Hover.available(Density::Touch, size));
903 + }
904 + }
905 +
618 906 #[test]
619 907 fn hover_resolves_against_the_token_makeover_already_derives() {
620 - let css = interactive_rules("card");
908 + let css = interactive_rules("card", Depth::Raised, &Emit::default());
621 909 assert!(css.contains(".card:hover {"));
622 910 assert!(css.contains("background: var(--hover-surface)"));
623 911 // Not the app's choice, which was --surface-overlay.
@@ -832,8 +1120,8 @@
832 1120 assert!(css.contains(".button {"));
833 1121 assert!(css.contains(".card {"));
834 1122 assert_eq!(
835 - interactive_rules("button").replace("button", "card"),
836 - interactive_rules("card")
1123 + interactive_rules("button", Depth::Raised, &Emit::default()).replace("button", "card"),
1124 + interactive_rules("card", Depth::Raised, &Emit::default())
837 1125 );
838 1126 }
839 1127
@@ -883,9 +1171,19 @@
883 1171 }
884 1172 // Everything left has to be a keyword, a number or a
885 1173 // caller-supplied length, never a colour.
1174 + //
1175 + // The length arm is what the comment above always claimed and the
1176 + // list never covered: `border_width` arrives from `Emit` and lands
1177 + // bare in the focus ring's offset, where the bevel had only ever
1178 + // used it inside an `inset` shadow.
1179 + let width = Emit::default().border_width;
886 1180 assert!(
887 1181 value.contains("inset")
888 - || matches!(value.trim_end_matches(';'), "0" | "1" | "none" | "auto"),
1182 + || value.contains(width)
1183 + || matches!(
1184 + value.trim_end_matches(';'),
1185 + "0" | "1" | "none" | "auto" | "not-allowed"
1186 + ),
889 1187 "unrecognised literal value: {line}"
890 1188 );
891 1189 }