Skip to main content

max / makeover-webview

0.67.0: name the element-chrome reset, and say flat out loud Three arms withdrew what their HTML element brought uninvited and none knew about the others: `button.link`, `.list`, and `.facet-values`, the last two byte-identical. `reset` is the one place that knows what an element brings and which parts a primitive gives back, composed from atoms because the bundles disagree at the edges: a link-as-button gives back its padding and font so it reads as text, a facet button keeps both so it stays worth aiming at. Emission order lives in the emitter, so two primitives withdrawing the same pair cannot spell it two ways. The CSS for the three existing arms is unchanged to the byte. The facets are what this is for. `depth_rule(_, Depth::Flat)` emitted an empty string, so where an app hands makeover the cascade with `revert-layer` the handoff rolled past an empty layer onto the app's own bare `button` rule and a described facet rendered raised, with `[aria-pressed="true"]` its only true state. Both facet controls now say flat out loud. Not a `Depth` axis: a stroke is None for every variant, so an added border axis would have covered one of seven properties and left `.link` alone.
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:25 UTC
Signed with PGP, not checked
Commit: c577776334e04cf7728efedd773d1aedfcae37a4
Parent: 6b5cae0
4 files changed, +292 insertions, -26 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.66.0"
3 + version = "0.67.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/facet.rs +33 -9
@@ -35,6 +35,7 @@
35 35 //! `makeover-geometry`'s.
36 36
37 37 use crate::form::escape_into;
38 + use crate::reset::Reset;
38 39 use crate::{Emit, class, push_class};
39 40 use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
40 41 use std::fmt::Write as _;
@@ -245,13 +246,10 @@
245 246 // muted: it was never going to answer a press.
246 247 let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
247 248
248 - // The list undoes the bullet a browser adds, for `row_rules`' reason: a
249 - // described set of tags is not a bulleted list and rendered as one because
250 - // nothing said otherwise.
251 - let _ = writeln!(
252 - css,
253 - ".{values} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}"
254 - );
249 + // The list gives back what a `<ul>` brought, the same ask `row_rules`
250 + // makes: a described set of tags is not a bulleted list and rendered as one
251 + // because nothing said otherwise.
252 + css.push_str(&Reset::BULLETS.rule(&format!(".{values}")));
255 253
256 254 // The indent is the level times one step. `--facet-depth` is written per
257 255 // value and `--facet-reach` per panel; the panel one reserves the gutter so
@@ -268,7 +266,14 @@
268 266
269 267 // The value's own control. Flat at rest, held in when it is in force, and
270 268 // that is the segmented control's sentence rather than a new one.
271 - css.push_str(&crate::depth_rule(&take, Depth::Flat));
269 + //
270 + // Flat states nothing, no fill and no bevel, so `depth_rule` emitted an
271 + // empty string and saying it was the whole of what this arm did. Where an
272 + // app hands makeover the cascade with `revert-layer`, an empty layer rolls
273 + // the handoff past makeover to the app's own bare `button` rule and the
274 + // value renders raised, with `[aria-pressed="true"]` its only true state.
275 + // Flat here has to be said out loud, which is what the reset is for.
276 + css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{take}")));
272 277 css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
273 278 css.push_str(&crate::depth_rule(
274 279 &format!("{take}[aria-pressed=\"true\"]"),
@@ -289,7 +294,8 @@
289 294 // whoever wants it rather than by a colour claiming something.
290 295 let _ = writeln!(css, ".{count} {{\n color: var(--content-muted);\n}}");
291 296
292 - css.push_str(&crate::depth_rule(&prune, Depth::Flat));
297 + // Same withdrawal as the take, for the same reason.
298 + css.push_str(&Reset::FLAT_BUTTON.rule(&format!(".{prune}")));
293 299 css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
294 300 css.push_str(&crate::depth_rule(
295 301 &format!("{prune}[aria-pressed=\"true\"]"),
@@ -444,6 +450,24 @@
444 450 assert!(!html.contains("<ag>"));
445 451 }
446 452
453 + #[test]
454 + fn a_value_reads_flat_before_it_is_touched() {
455 + // The reason the arm exists: `Depth::Flat` declares nothing, so an app
456 + // handing makeover the cascade with `revert-layer` rolled the handoff
457 + // past an empty layer onto its own bare `button` rule and the value
458 + // came out raised. Both controls say flat out loud now, and the states
459 + // below it are what a press is allowed to change.
460 + let css = facet_rules(&Emit::default());
461 + for name in ["facet-take", "facet-prune"] {
462 + assert!(
463 + css.contains(&format!(
464 + ".{name} {{\n background: none;\n border: none;\n box-shadow: none;\n}}"
465 + )),
466 + "{name} is not withdrawn: {css}"
467 + );
468 + }
469 + }
470 +
447 471 #[test]
448 472 fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
449 473 // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
M src/lib.rs +12 -16
@@ -32,6 +32,11 @@
32 32 //! The bevel properties are byte-identical to what both apps already
33 33 //! hand-write, which is asserted below.
34 34 //!
35 + //! Some of what phase A emits is not a look but the withdrawal of one. A
36 + //! renderer that picks its element from the description inherits that element's
37 + //! user-agent chrome, and [`reset`] is where a primitive says which parts of it
38 + //! were never asked for.
39 + //!
35 40 //! # Phase B: the markup, one description at a time
36 41 //!
37 42 //! [`form`] renders [`makeover_layout::Field`], which is the half of phase B
@@ -399,6 +404,7 @@
399 404 pub mod list;
400 405 pub mod meter;
401 406 pub mod placeholder;
407 + pub mod reset;
402 408 pub mod vocabulary;
403 409
404 410 /// A render of every emitter, scraped for the classes it wrote.
@@ -409,6 +415,7 @@
409 415 mod corpus;
410 416
411 417 use crate::list::{cell_part_class, part_class};
418 + use crate::reset::Reset;
412 419 use makeover_geometry::{Density, SizeClass};
413 420 // Re-exported rather than redefined. An app assembling its own stylesheet out
414 421 // of this crate's pieces needs the same layer name, and most such apps depend
@@ -941,12 +948,8 @@
941 948 );
942 949 // A link is often a `<button>` rather than an `<a>`: a renderer picks the
943 950 // element from the method, so a link that writes is a button that has to
944 - // stop looking like one.
945 - let _ = writeln!(
946 - css,
947 - "button.{link} {{\n background: none;\n border: none;\n \
948 - padding: 0;\n font: inherit;\n cursor: pointer;\n}}"
949 - );
951 + // stop looking like one. What that costs is named in [`reset`].
952 + css.push_str(&Reset::TEXT_BUTTON.rule(&format!("button.{link}")));
950 953 css
951 954 }
952 955
@@ -1038,16 +1041,9 @@
1038 1041 fn row_rules(opts: &Emit) -> String {
1039 1042 let mut css = String::new();
1040 1043
1041 - // The container the rows sit in. Three zeroes and a keyword, all of them
1042 - // undoing a browser default the description never asked for: a described
1043 - // list of SSH keys is not a bulleted list, and it rendered as one because
1044 - // nothing here said otherwise. Not a size: there is no magnitude in it,
1045 - // which is the line this crate holds.
1046 - let _ = writeln!(
1047 - css,
1048 - ".{} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}",
1049 - class("list", opts)
1050 - );
1044 + // The container the rows sit in, giving back what a `<ul>` brought. Not a
1045 + // size: there is no magnitude in it, which is the line this crate holds.
1046 + css.push_str(&Reset::BULLETS.rule(&format!(".{}", class("list", opts))));
1051 1047
1052 1048 for part in [
1053 1049 RowPart::Primary,
A src/reset.rs +246
@@ -1,0 +1,246 @@
1 + //! What an HTML element brings uninvited, and how a primitive gives it back.
2 + //!
3 + //! The renderer picks an element from the description (a link that writes is a
4 + //! `<button>`, a described set of values is a `<ul>`) and the element arrives
5 + //! carrying a user-agent look nobody asked for. Withdrawing that look is a
6 + //! recurring ask rather than an edge case, and it was written by hand three
7 + //! times before this module existed: twice byte-identically for a list, once
8 + //! for a link, with no arm aware of the others.
9 + //!
10 + //! # Why this is a withdrawal and not a depth
11 + //!
12 + //! [`makeover_layout::Depth`] was the obvious home and is the wrong one. A
13 + //! depth states what a region *is*, a fill and a bevel, and every variant
14 + //! answers `None` for a stroke, so an added border axis would have covered one
15 + //! of the seven properties in play and left `.link` untouched. What these arms
16 + //! share is not a shape. It is the absence of one the browser supplied.
17 + //!
18 + //! # Renderer-local by construction
19 + //!
20 + //! A terminal has no element chrome to withdraw and an immediate-mode painter
21 + //! draws from nothing, so this concept cannot rise into the description layer.
22 + //! Nothing in `makeover-layout` knows the word, and there is no cascade.
23 +
24 + use std::fmt::Write as _;
25 +
26 + /// One thing an element brings that a description never asked for.
27 + ///
28 + /// Atoms rather than bundles, because the bundles disagree at the edges: a
29 + /// link-as-button gives back its padding and its font so it can read as text,
30 + /// and a facet button keeps both so it stays worth aiming at. The named sets
31 + /// below are the bundles, spelled once each.
32 + #[derive(Clone, Copy, PartialEq, Eq, Debug)]
33 + #[non_exhaustive]
34 + pub enum Chrome {
35 + /// The bullet on a list item. `list-style: none`.
36 + Bullet,
37 + /// The gutter around a list, which existed to make room for the bullet.
38 + /// `margin: 0`.
39 + Gutter,
40 + /// A control's surface. `background: none`.
41 + Fill,
42 + /// A control's stroke. `border: none`.
43 + Edge,
44 + /// A control's raised look, where an app's own `button` rule supplies one.
45 + /// `box-shadow: none`.
46 + Shadow,
47 + /// The room a control keeps around its label. `padding: 0`.
48 + Padding,
49 + /// The face a control is set in, which is not the face around it.
50 + /// `font: inherit`.
51 + Type,
52 + /// The one addition rather than a withdrawal: a `<button>` points with the
53 + /// default arrow where an `<a>` points with a hand. `cursor: pointer`.
54 + Pointing,
55 + }
56 +
57 + /// The order every reset emits in, outside the box and inward: how it sits in
58 + /// flow, then its surface, then what it does with its contents. Fixed here so
59 + /// that two primitives withdrawing the same pair can never spell it in two
60 + /// orders and read as two rules.
61 + const ORDER: [(Chrome, &str); 8] = [
62 + (Chrome::Bullet, "list-style: none"),
63 + (Chrome::Gutter, "margin: 0"),
64 + (Chrome::Fill, "background: none"),
65 + (Chrome::Edge, "border: none"),
66 + (Chrome::Shadow, "box-shadow: none"),
67 + (Chrome::Padding, "padding: 0"),
68 + (Chrome::Type, "font: inherit"),
69 + (Chrome::Pointing, "cursor: pointer"),
70 + ];
71 +
72 + const fn bit(chrome: Chrome) -> u8 {
73 + match chrome {
74 + Chrome::Bullet => 1 << 0,
75 + Chrome::Gutter => 1 << 1,
76 + Chrome::Fill => 1 << 2,
77 + Chrome::Edge => 1 << 3,
78 + Chrome::Shadow => 1 << 4,
79 + Chrome::Padding => 1 << 5,
80 + Chrome::Type => 1 << 6,
81 + Chrome::Pointing => 1 << 7,
82 + }
83 + }
84 +
85 + /// A set of [`Chrome`] a primitive opts into giving back.
86 + #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
87 + pub struct Reset(u8);
88 +
89 + impl Reset {
90 + /// Withdraw nothing. The starting point for [`Reset::and`], and what a
91 + /// primitive that is happy with its element gets by saying nothing.
92 + pub const NOTHING: Self = Self(0);
93 +
94 + /// The triple a `<ul>` or `<ol>` brings: the bullet, the gutter that made
95 + /// room for it, and the indent. A described list of SSH keys is not a
96 + /// bulleted list, and it rendered as one because nothing said otherwise.
97 + pub const BULLETS: Self = Self::NOTHING
98 + .and(Chrome::Bullet)
99 + .and(Chrome::Gutter)
100 + .and(Chrome::Padding);
101 +
102 + /// A `<button>`'s raised look and nothing else: fill, stroke, shadow. What
103 + /// stays is the hit area and the type, so the control is still worth
104 + /// aiming at and still reads as a control.
105 + ///
106 + /// This is the set that matters where an app hands makeover the cascade
107 + /// with `revert-layer`: with an empty layer the handoff rolls past
108 + /// makeover to a bare `button` rule, which supplies all three, and a
109 + /// described flat control renders raised.
110 + pub const FLAT_BUTTON: Self = Self::NOTHING
111 + .and(Chrome::Fill)
112 + .and(Chrome::Edge)
113 + .and(Chrome::Shadow);
114 +
115 + /// A `<button>` that has to stop looking like one, because the description
116 + /// said link and only the method said button. Everything a control brings,
117 + /// plus the pointing hand a link has and a button does not.
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.
124 + pub const TEXT_BUTTON: Self = Self::NOTHING
125 + .and(Chrome::Fill)
126 + .and(Chrome::Edge)
127 + .and(Chrome::Padding)
128 + .and(Chrome::Type)
129 + .and(Chrome::Pointing);
130 +
131 + /// Add one thing to the set. Const, so a named set above is a constant and
132 + /// not a function call at every emit.
133 + #[must_use]
134 + pub const fn and(self, chrome: Chrome) -> Self {
135 + Self(self.0 | bit(chrome))
136 + }
137 +
138 + /// Whether the set carries this one.
139 + #[must_use]
140 + pub const fn carries(self, chrome: Chrome) -> bool {
141 + self.0 & bit(chrome) != 0
142 + }
143 +
144 + /// Whether the set withdraws nothing, in which case a caller emits no rule
145 + /// at all rather than an empty one. Same contract as
146 + /// [`depth_declarations`](crate::depth_declarations) and
147 + /// [`depth_rule`](crate::depth_rule).
148 + #[must_use]
149 + pub const fn is_empty(self) -> bool {
150 + self.0 == 0
151 + }
152 +
153 + /// The declarations, indented and terminated, ready for a rule body.
154 + #[must_use]
155 + pub fn declarations(self) -> String {
156 + let mut css = String::new();
157 + for (chrome, declaration) in ORDER {
158 + if self.carries(chrome) {
159 + let _ = writeln!(css, " {declaration};");
160 + }
161 + }
162 + css
163 + }
164 +
165 + /// One rule, or nothing when the set withdraws nothing.
166 + ///
167 + /// The selector is written in full and taken verbatim, which is where this
168 + /// parts company with [`depth_rule`](crate::depth_rule): a reset exists
169 + /// because of the element underneath, so its selector is routinely
170 + /// element-qualified: `button.link` and not `.link`.
171 + #[must_use]
172 + pub fn rule(self, selector: &str) -> String {
173 + if self.is_empty() {
174 + return String::new();
175 + }
176 + format!("{selector} {{\n{}}}\n", self.declarations())
177 + }
178 + }
179 +
180 + #[cfg(test)]
181 + mod tests {
182 + use super::*;
183 +
184 + #[test]
185 + fn nothing_emits_nothing() {
186 + assert!(Reset::NOTHING.is_empty());
187 + assert_eq!(Reset::NOTHING.rule(".x"), "");
188 + }
189 +
190 + #[test]
191 + fn the_selector_is_verbatim() {
192 + assert!(
193 + Reset::TEXT_BUTTON
194 + .rule("button.link")
195 + .starts_with("button.link {")
196 + );
197 + }
198 +
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.
202 + #[test]
203 + fn the_named_sets_emit_what_they_replaced() {
204 + assert_eq!(
205 + Reset::BULLETS.rule(".list"),
206 + ".list {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n"
207 + );
208 + assert_eq!(
209 + Reset::TEXT_BUTTON.rule("button.link"),
210 + "button.link {\n background: none;\n border: none;\n \
211 + padding: 0;\n font: inherit;\n cursor: pointer;\n}\n"
212 + );
213 + assert_eq!(
214 + Reset::FLAT_BUTTON.rule(".facet-take"),
215 + ".facet-take {\n background: none;\n border: none;\n \
216 + box-shadow: none;\n}\n"
217 + );
218 + }
219 +
220 + /// Order is a property of the emitter and not of the order a caller asked
221 + /// in, which is what stops two primitives withdrawing the same pair from
222 + /// emitting two different rules.
223 + #[test]
224 + fn order_is_the_emitters() {
225 + let forwards = Reset::NOTHING.and(Chrome::Fill).and(Chrome::Bullet);
226 + let backwards = Reset::NOTHING.and(Chrome::Bullet).and(Chrome::Fill);
227 + assert_eq!(forwards, backwards);
228 + assert_eq!(
229 + forwards.declarations(),
230 + " list-style: none;\n background: none;\n"
231 + );
232 + }
233 +
234 + #[test]
235 + fn every_member_has_a_declaration() {
236 + for (chrome, _) in ORDER {
237 + assert!(Reset::NOTHING.and(chrome).carries(chrome), "{chrome:?}");
238 + }
239 + // One bit each, and no member left out of the order.
240 + let all = ORDER
241 + .iter()
242 + .fold(Reset::NOTHING, |set, (chrome, _)| set.and(*chrome));
243 + assert_eq!(all.0, u8::MAX);
244 + assert_eq!(all.declarations().lines().count(), ORDER.len());
245 + }
246 + }