Skip to main content

max / makeover-webview

0.12.0: the focus ring gets its own width, defaulting to 2px focus_rule reused Emit::border_width and emitted a 1px ring. That was an implementation convenience dressed as consistency with the invalid-field ring. A bevel and a focus indicator answer different questions, and only one of them has to be noticed from across a desk. Caught while adopting 0.11.0 into goingson, by exactly the check the adoption tasks ask for. Every consumer had already written its own ring and all three chose at least 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px, goingson 2px on three rules and 3px on the one covering twelve selectors. The design system was the only thing in the tree saying 1px, so deleting the app rules in favour of it would have thinned the focus indicator in all three apps rather than unifying it. The default is therefore the measured consensus and not a new opinion. The offset is the same magnitude with only its sign coming off the depth, which is also what the consumers wrote: 2px out everywhere, and 2px in for the one inset ring the MNW server had. Adding a field to Emit is breaking, and costs nothing in practice: all three apps construct it with Emit::default().
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 17:07 UTC
Signed with PGP, not checked
Commit: f4322133719d8320d2a5d324bc63ba4d7b5a1078
Parent: defd3c4
2 files changed, +52 insertions, -11 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.11.0"
3 + version = "0.12.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"
M src/lib.rs +51 -10
@@ -137,6 +137,24 @@
137 137 //! rules are as generated as the ones here, so they belong in the same layer and
138 138 //! this crate cannot put them there on the app's behalf.
139 139 //!
140 + //! # 0.12.0: the ring gets its own width
141 + //!
142 + //! [`focus_rule`] reused [`Emit::border_width`] and emitted a 1px ring. That was
143 + //! an implementation convenience dressed as consistency with the invalid-field
144 + //! ring: a bevel and a focus indicator answer different questions, and only one
145 + //! of them has to be noticed from across a desk.
146 + //!
147 + //! Caught while adopting 0.11.0 into goingson, by the check the adoption tasks
148 + //! ask for. Every consumer had already written its own ring and all three chose
149 + //! at least 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px,
150 + //! goingson 2px on three rules and 3px on the one covering twelve selectors. The
151 + //! design system was the only thing in the tree saying 1px, so deleting the app
152 + //! rules in favour of it would have thinned the focus indicator everywhere.
153 + //!
154 + //! [`Emit::focus_width`] now carries it, defaulting to `2px`, and the offset is
155 + //! the same magnitude with its sign off the depth. Both values are the measured
156 + //! consensus rather than a new opinion.
157 + //!
140 158 //! # Substitution, three ways
141 159 //!
142 160 //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
@@ -174,6 +192,20 @@
174 192 /// A value, so it arrives from the caller: border widths belong to
175 193 /// `makeover-geometry` and will come from there once it carries them.
176 194 pub border_width: &'static str,
195 + /// Focus ring thickness, as a CSS length.
196 + ///
197 + /// Separate from [`border_width`](Self::border_width), which it reused
198 + /// until 0.12.0. That reuse was an implementation convenience dressed as
199 + /// consistency, and it emitted a 1px ring: a bevel and a focus indicator
200 + /// are answering different questions, and only one of them has to be
201 + /// noticed from across a desk.
202 + ///
203 + /// The default is the measured consensus rather than a new opinion. Every
204 + /// consumer had already written its own ring and all three chose at least
205 + /// 2px: the MNW server 2px across 10 rules, Balanced Breakfast 2px,
206 + /// goingson 2px on three rules and 3px on the one covering twelve
207 + /// selectors. The design system was the only thing in the tree saying 1px.
208 + pub focus_width: &'static str,
177 209 /// Prefix for emitted class names, without the leading dot.
178 210 pub class_prefix: &'static str,
179 211 }
@@ -182,6 +214,7 @@
182 214 fn default() -> Self {
183 215 Self {
184 216 border_width: "1px",
217 + focus_width: "2px",
185 218 class_prefix: "",
186 219 }
187 220 }
@@ -363,11 +396,14 @@
363 396 /// They render the same: both are a flush ring one border-width wide.
364 397 #[must_use]
365 398 pub fn focus_rule(selector: &str, depth: Depth, opts: &Emit) -> String {
366 - let w = opts.border_width;
399 + let w = opts.focus_width;
400 + // Same magnitude either way, and only the sign comes off the depth. Both
401 + // values are what the consumers had already converged on independently:
402 + // 2px out is what all three wrote, and 2px in is the MNW server's own
403 + // answer for the one inset ring it had.
367 404 let offset = match depth.bevel() {
368 - // Inside the well, clear of the inset edge rather than painted over
369 - // it. Two widths in: one to cross the edge, one to stand off it.
370 - Some(Bevel::Inset) => format!("calc(-2 * {w})"),
405 + // Inside the well, clear of its edge rather than painted over it.
406 + Some(Bevel::Inset) => format!("calc(-1 * {w})"),
371 407 // Raised, or no edge at all. Outside, standing off by its own width.
372 408 _ => w.to_string(),
373 409 };
@@ -950,7 +986,7 @@
950 986 // no bevel to restate beside it and nothing to keep in agreement.
951 987 let opts = Emit::default();
952 988 let css = focus_rule("button", Depth::Raised, &opts);
953 - assert!(css.contains("outline: 1px solid var(--focus-ring)"));
989 + assert!(css.contains("outline: 2px solid var(--focus-ring)"));
954 990 assert!(!css.contains("box-shadow"), "the ring restated the bevel");
955 991 }
956 992
@@ -960,10 +996,14 @@
960 996 // not off a per-component choice, which is what gave three apps three
961 997 // different rings.
962 998 let opts = Emit::default();
963 - assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-2 * 1px)"));
964 - assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 1px"));
999 + assert!(focus_rule("field", Depth::Well, &opts).contains("outline-offset: calc(-1 * 2px)"));
1000 + assert!(focus_rule("button", Depth::Raised, &opts).contains("outline-offset: 2px"));
965 1001 // Nothing to sit inside of, so it sits outside.
966 - assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 1px"));
1002 + assert!(focus_rule("badge", Depth::Sunken, &opts).contains("outline-offset: 2px"));
1003 +
1004 + // And the ring is not the bevel. Reusing border_width emitted a 1px
1005 + // ring that every consumer had already overridden.
1006 + assert_ne!(opts.focus_width, opts.border_width);
967 1007 }
968 1008
969 1009 #[test]
@@ -1289,10 +1329,11 @@
1289 1329 // list never covered: `border_width` arrives from `Emit` and lands
1290 1330 // bare in the focus ring's offset, where the bevel had only ever
1291 1331 // used it inside an `inset` shadow.
1292 - let width = Emit::default().border_width;
1332 + let opts = Emit::default();
1293 1333 assert!(
1294 1334 value.contains("inset")
1295 - || value.contains(width)
1335 + || value.contains(opts.border_width)
1336 + || value.contains(opts.focus_width)
1296 1337 || matches!(
1297 1338 value.trim_end_matches(';'),
1298 1339 "0" | "1" | "none" | "auto" | "not-allowed"