Skip to main content

max / makeover-webview

0.68.0: state the rest look on both axes, and let a link give the shadow back Two follow-ups on the reset, both of them the same defect one axis over: a rule that has to beat the state above it cannot do it by staying silent. `disabled_rule` re-asserted the rest depth through `depth_declarations`, which states an axis only when the depth has something to say. That is right for a rest rule and wrong here: an axis left unstated is an axis the state above keeps. `Depth::Flat` states neither, so a disabled facet arm or suggestion entry won the source-order contest with nothing to say and kept the hover surface underneath a control that had stopped answering. `Sunken` and `Overlay` have no bevel either and kept the pressed rule's inset one. The withdrawal is spelled by `reset`, so a disabled control and a flat one say the same words. `Reset::TEXT_BUTTON` gains the shadow. Measured across the three webview consumers first: the MNW server is the only one with a bare `button` rule setting any chrome, and its own handoff already hands `box-shadow` back on `button.link`. With nothing in the layer to hand back, `revert-layer` was rolling past to the UA default and landing on `none` by luck. The computed value does not move; the layer stops depending on the accident.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-29 16:46 UTC
Signed with PGP, not checked
Commit: a38c05b54342701961cadec2fe83de4625a3e6ab
Parent: c577776
3 files changed, +95 insertions, -12 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.67.0"
3 + version = "0.68.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
M src/lib.rs +63 -2
@@ -415,7 +415,7 @@
415 415 mod corpus;
416 416
417 417 use crate::list::{cell_part_class, part_class};
418 - use crate::reset::Reset;
418 + use crate::reset::{Chrome, Reset};
419 419 use makeover_geometry::{Density, SizeClass};
420 420 // Re-exported rather than redefined. An app assembling its own stylesheet out
421 421 // of this crate's pieces needs the same layer name, and most such apps depend
@@ -757,6 +757,39 @@
757 757 )
758 758 }
759 759
760 + /// A rest depth said out loud on both axes, for a rule that has to beat the
761 + /// states above it.
762 + ///
763 + /// [`depth_declarations`] states an axis only when the depth has something to
764 + /// say about it, which is right for a rest rule: a [`Depth::Flat`] region
765 + /// inherits what it sits on, and asserting `background: none` there would be
766 + /// the difference between level-with and painted-transparent. It is wrong for
767 + /// a rule whose whole job is to take a state back. An axis left unstated is an
768 + /// axis the state above keeps, so `Flat` re-asserted nothing at all and a
769 + /// disabled control kept whatever hover had given it.
770 + ///
771 + /// So the axes the depth is silent on are withdrawn rather than skipped, and
772 + /// the withdrawal is spelled by [`reset`] rather than here, so a disabled
773 + /// control and a flat one say the same words. Reaches further than the fill:
774 + /// [`Depth::Sunken`] and [`Depth::Overlay`] have no bevel either, and the
775 + /// pressed rule above hands out an inset one.
776 + fn rest_declarations(depth: Depth) -> String {
777 + let mut css = String::new();
778 + match depth.fill() {
779 + Some(fill) => {
780 + let _ = writeln!(css, " background: {};", fill_var(fill));
781 + }
782 + None => css.push_str(&Reset::NOTHING.and(Chrome::Fill).declarations()),
783 + }
784 + match depth.bevel() {
785 + Some(bevel) => {
786 + let _ = writeln!(css, " box-shadow: var({});", bevel_var(bevel));
787 + }
788 + None => css.push_str(&Reset::NOTHING.and(Chrome::Shadow).declarations()),
789 + }
790 + css
791 + }
792 +
760 793 /// Present, visible, and not answering.
761 794 ///
762 795 /// Matches the ARIA attribute as well as the pseudo-class, because `:disabled`
@@ -772,11 +805,17 @@
772 805 /// caller emits is (0,2,0), and adding a `:not(:disabled)` anywhere would raise
773 806 /// one of them and have to be unpicked when this output moves inside its own
774 807 /// cascade layer.
808 + ///
809 + /// Re-asserted on **both** axes, through [`rest_declarations`], which is the
810 + /// half this got wrong until 0.68.0. `depth_declarations` alone is empty for
811 + /// [`Depth::Flat`], so the three flat controls this crate emits -- both facet
812 + /// arms and a suggestion entry -- won the contest with nothing to say and kept
813 + /// the hover surface underneath a control that had stopped answering.
775 814 #[must_use]
776 815 pub fn disabled_rule(selector: &str, depth: Depth) -> String {
777 816 format!(
778 817 ".{selector}:disabled,\n.{selector}[aria-disabled=\"true\"] {{\n{} color: var(--{});\n cursor: not-allowed;\n}}\n",
779 - depth_declarations(depth),
818 + rest_declarations(depth),
780 819 State::Disabled.token()
781 820 )
782 821 }
@@ -2354,6 +2393,28 @@
2354 2393 assert!(tail.contains("background: var(--surface-raised)"));
2355 2394 }
2356 2395
2396 + #[test]
2397 + fn a_flat_control_takes_its_hover_fill_back_when_it_stops_answering() {
2398 + // The same contest one depth over, and the half `depth_declarations`
2399 + // could not state. Flat declares neither axis, so before 0.68.0 the
2400 + // disabled rule won on source order with nothing to say and the hover
2401 + // surface stayed under a control that had stopped answering. Reaches
2402 + // both facet arms and a suggestion entry.
2403 + for depth in [Depth::Flat, Depth::Sunken, Depth::Overlay] {
2404 + let css = disabled_rule("x", depth);
2405 + assert!(css.contains("box-shadow"), "{depth:?}: {css}");
2406 + }
2407 + let flat = disabled_rule("x", Depth::Flat);
2408 + assert!(flat.contains("background: none;"), "{flat}");
2409 + assert!(flat.contains("box-shadow: none;"), "{flat}");
2410 +
2411 + // Every rule the caller emits states both axes now, so whichever wins
2412 + // the source-order contest leaves nothing of the one below it.
2413 + let css = interactive_rules("x", Depth::Flat, &Emit::default());
2414 + let disabled = css.find(":disabled").expect("disabled");
2415 + assert!(css[disabled..].contains("background: none;"), "{css}");
2416 + }
2417 +
2357 2418 #[test]
2358 2419 fn a_disabled_state_reaches_things_that_cannot_be_disabled() {
2359 2420 // `:disabled` matches form elements only, and a chip is a div. Keying
M src/reset.rs +31 -9
@@ -116,14 +116,23 @@
116 116 /// said link and only the method said button. Everything a control brings,
117 117 /// plus the pointing hand a link has and a button does not.
118 118 ///
119 - /// The shadow is deliberately absent, and it is the one asymmetry here:
120 - /// this set is what `.link` has emitted since before the reset was named,
121 - /// and widening it is a visible change rather than a refactor. A link
122 - /// sitting inside an app whose bare `button` rule raises its buttons keeps
123 - /// that shadow today.
119 + /// [`Reset::FLAT_BUTTON`] plus the type and metrics, which is the split
120 + /// worth reading off the two: a facet keeps its hit area because it is
121 + /// still a control, and a link gives it back because it is a word in a
122 + /// sentence.
123 + ///
124 + /// The shadow was absent until 0.68.0, and the hole was invisible for the
125 + /// worst reason: the MNW server's own handoff hands `box-shadow` back on
126 + /// `button.link`, and with nothing in the layer to hand back
127 + /// `revert-layer` rolled past to the UA default, which happens to be
128 + /// `none`. So the app was compensating for a gap here and the right answer
129 + /// arrived by luck. Measured across the three webview consumers before
130 + /// closing it: the MNW server is the only one with a bare `button` rule
131 + /// setting any chrome at all, and its computed value does not move.
124 132 pub const TEXT_BUTTON: Self = Self::NOTHING
125 133 .and(Chrome::Fill)
126 134 .and(Chrome::Edge)
135 + .and(Chrome::Shadow)
127 136 .and(Chrome::Padding)
128 137 .and(Chrome::Type)
129 138 .and(Chrome::Pointing);
@@ -196,9 +205,10 @@
196 205 );
197 206 }
198 207
199 - /// The three sets, spelled out. These are the bytes three hand-written arms
200 - /// emitted before the reset was named, and the point of pinning them is
201 - /// that the refactor was not allowed to change one.
208 + /// The three sets, spelled out. Two of them are the bytes hand-written arms
209 + /// emitted before the reset was named; the third, `TEXT_BUTTON`, is those
210 + /// bytes plus the `box-shadow` 0.68.0 added, which is the one place the
211 + /// reset deliberately says more than what it replaced.
202 212 #[test]
203 213 fn the_named_sets_emit_what_they_replaced() {
204 214 assert_eq!(
@@ -208,7 +218,8 @@
208 218 assert_eq!(
209 219 Reset::TEXT_BUTTON.rule("button.link"),
210 220 "button.link {\n background: none;\n border: none;\n \
211 - padding: 0;\n font: inherit;\n cursor: pointer;\n}\n"
221 + box-shadow: none;\n padding: 0;\n font: inherit;\n \
222 + cursor: pointer;\n}\n"
212 223 );
213 224 assert_eq!(
214 225 Reset::FLAT_BUTTON.rule(".facet-take"),
@@ -220,6 +231,17 @@
220 231 /// Order is a property of the emitter and not of the order a caller asked
221 232 /// in, which is what stops two primitives withdrawing the same pair from
222 233 /// emitting two different rules.
234 + /// The relationship the two button sets are meant to have, so a later edit
235 + /// to one cannot quietly make a link keep chrome a facet gives back.
236 + #[test]
237 + fn a_text_button_withdraws_everything_a_flat_one_does() {
238 + for (chrome, _) in ORDER {
239 + if Reset::FLAT_BUTTON.carries(chrome) {
240 + assert!(Reset::TEXT_BUTTON.carries(chrome), "{chrome:?}");
241 + }
242 + }
243 + }
244 +
223 245 #[test]
224 246 fn order_is_the_emitters() {
225 247 let forwards = Reset::NOTHING.and(Chrome::Fill).and(Chrome::Bullet);