Skip to main content

max / makeover-webview

Emit the phase A component stylesheet Bumps makeover-layout to 0.2.0, which is where the vocabulary phase A needs lives: Token, Selector, RowPart, Field, Region. Adds the component layer beside the depth primitives: button, card, field and its invalid ring, badge and chip, the three selectors, the four row parts, and a progress trough. No markup, which is phase B. Four decisions behind it, taken against goingson's styles.css rather than a component list. Two of them change how the app looks: - Pressed now carries its fill, not just the bevel flip. Emitting the edge alone is what left goingson hand-writing surface-sunken on .btn, .card and .tag/.badge alike, so none of the three could be deleted. The pressed fill comes from Depth::pressed, and hover resolves against --hover-surface, which makeover already derives and nothing consumed. Both states move for the app, and a dark theme will press lighter than it hovers because surface-well inverts by theme where surface-sunken does not. - Badges go flat, per Token::Badge. goingson ships .tag, .badge as one rule with the raised bevel, so adopting means splitting it and reading the call sites. A visible redesign, not a deletion. The other two: the progress trough is renderer-local on the same licence the skeletons hold, the scrollbar track came off the list, and no class prefix ships by default, so adoption deletes the app's rule in the same commit that adds the generated one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 19:54 UTC
Signed with PGP, not checked
Commit: c6a33a17d36b0abf9019e778e0aaa488f59f7a01
Parent: b4fe484
2 files changed, +432 insertions, -30 deletions
M Cargo.toml +1 -1
@@ -7,7 +7,7 @@
7 7 repository = "https://makenot.work/git/max/makeover-webview"
8 8
9 9 [dependencies]
10 - makeover-layout = "0.1.0"
10 + makeover-layout = "0.2.0"
11 11
12 12 [lints.rust]
13 13 unused = "warn"
M src/lib.rs +431 -29
@@ -22,9 +22,36 @@
22 22 //! markup is a migration, while adopting a generated stylesheet is a deletion.
23 23 //! The apps keep every line of their markup and gain the classes.
24 24 //!
25 - //! The output is byte-identical to the bevel composition both apps already
26 - //! hand-write, which is asserted below, so adoption removes duplicated lines
27 - //! rather than changing a pixel.
25 + //! The bevel properties are byte-identical to what both apps already
26 + //! hand-write, which is asserted below.
27 + //!
28 + //! # What phase A settled, and what it costs
29 + //!
30 + //! Decided 2026-07-29 against goingson's `styles.css` rather than against a
31 + //! component list. The useful finding there was that `.btn` (line 644),
32 + //! `.card` (768) and `.tag, .badge` (882) each hand-write the same
33 + //! composition, so three quarters of phase A is one rule with several names.
34 + //!
35 + //! Two of the four decisions change how goingson looks, and adoption should
36 + //! not be described as a pure deletion:
37 + //!
38 + //! - **Pressed carries its fill.** [`interactive_rules`] emits
39 + //! [`Depth::pressed`] whole. goingson presses to `--surface-sunken` today and
40 + //! will press to `--surface-well`, and hovers to `--surface-overlay` today
41 + //! and will hover to `--hover-surface`. Since `surface-well` inverts by theme
42 + //! where `surface-sunken` does not, a dark theme presses *lighter* than it
43 + //! hovers. That falls out of `makeover`'s own derivation, which says outright
44 + //! that `surface-sunken` cannot serve as a well, so if it reads wrong the
45 + //! answer is there and not here.
46 + //! - **Badges go flat.** See [`token_rules`].
47 + //!
48 + //! The other two: the progress trough is renderer-local and the scrollbar
49 + //! track was dropped ([`component_rules`]), and no class prefix ships by
50 + //! default, so adoption means deleting the app's hand-written rule in the same
51 + //! commit that adds the generated one. `.card`, `.badge` and the tab classes
52 + //! all already exist in goingson, and while both rules exist the cascade order
53 + //! decides which wins. That is the one real risk in adopting this, and it is
54 + //! why the migration lands per component rather than in one commit.
28 55 //!
29 56 //! # Substitution, three ways
30 57 //!
@@ -41,7 +68,7 @@
41 68
42 69 #![forbid(unsafe_code)]
43 70
44 - use makeover_layout::{Bevel, Depth, Fill, Intent};
71 + use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
45 72 use std::fmt::Write as _;
46 73
47 74 /// How the emitted CSS is shaped.
@@ -134,10 +161,62 @@
134 161 Some(format!("{}{name}", opts.class_prefix))
135 162 }
136 163
137 - /// One rule per depth: its fill and its edge, together.
164 + /// A prefixed class name.
165 + fn class(name: &str, opts: &Emit) -> String {
166 + format!("{}{name}", opts.class_prefix)
167 + }
168 +
169 + /// The fill and edge declarations for a depth, as a rule body.
138 170 ///
139 - /// `Depth::Flat` emits nothing. A class that sets no properties is a class
140 - /// that means "I thought about this", which is what comments are for.
171 + /// Empty for [`Depth::Flat`], which has neither and inherits what it sits on.
172 + /// Callers lean on the emptiness to skip the rule rather than emit a class that
173 + /// sets nothing: a class that sets no properties is a class that means "I
174 + /// thought about this", which is what comments are for.
175 + #[must_use]
176 + pub fn depth_declarations(depth: Depth) -> String {
177 + let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else {
178 + return String::new();
179 + };
180 + format!(
181 + " background: {};\n box-shadow: var({});\n",
182 + fill_var(fill),
183 + bevel_var(bevel)
184 + )
185 + }
186 +
187 + /// One rule giving a selector a depth, or nothing when the depth declares
188 + /// nothing.
189 + #[must_use]
190 + pub fn depth_rule(selector: &str, depth: Depth) -> String {
191 + let body = depth_declarations(depth);
192 + if body.is_empty() {
193 + return String::new();
194 + }
195 + format!(".{selector} {{\n{body}}}\n")
196 + }
197 +
198 + /// Hover and pressed, for a selector that answers a click.
199 + ///
200 + /// Pressed emits [`Depth::pressed`] in full, fill and edge together. Emitting
201 + /// only the edge is what left goingson hand-writing `background:
202 + /// var(--surface-sunken)` on three separate rules, and a fill that does not
203 + /// travel with its edge is precisely the disagreement `Depth` exists to make
204 + /// unrepresentable. So the pressed fill comes from the description
205 + /// (`--surface-well`) rather than from whatever each app reached for.
206 + ///
207 + /// Hover has no member in the description and is renderer policy: a terminal
208 + /// and an immediate-mode painter have no hover to express. It resolves against
209 + /// `--hover-surface`, which `makeover` already derives and which nothing
210 + /// consumed until now.
211 + #[must_use]
212 + pub fn interactive_rules(selector: &str) -> String {
213 + format!(
214 + ".{selector}:hover {{\n background: var(--hover-surface);\n}}\n{}",
215 + depth_rule(&format!("{selector}:active"), Depth::Raised.pressed())
216 + )
217 + }
218 +
219 + /// One rule per depth: its fill and its edge, together.
141 220 ///
142 221 /// A pressed rule rides along with the raised one, because the cascade can
143 222 /// carry a state that an immediate-mode renderer has to resolve per call site.
@@ -149,40 +228,203 @@
149 228 let Some(class) = depth_class(depth, opts) else {
150 229 continue;
151 230 };
152 - let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else {
153 - continue;
154 - };
155 - let _ = writeln!(
156 - css,
157 - ".{class} {{\n background: {};\n box-shadow: var({});\n}}",
158 - fill_var(fill),
159 - bevel_var(bevel)
160 - );
231 + css.push_str(&depth_rule(&class, depth));
161 232 }
162 - if let (Some(raised), Some(pressed)) = (
163 - depth_class(Depth::Raised, opts),
164 - Depth::Raised.pressed().bevel(),
165 - ) {
166 - let _ = writeln!(
167 - css,
168 - ".{raised}:active {{\n box-shadow: var({});\n}}",
169 - bevel_var(pressed)
170 - );
233 + if let Some(raised) = depth_class(Depth::Raised, opts) {
234 + css.push_str(&interactive_rules(&raised));
171 235 }
172 236 css
173 237 }
174 238
175 - /// The whole phase-A stylesheet: properties and rules, with a generated-file
176 - /// banner.
239 + /// The three surfaces that are a depth with a name.
240 + ///
241 + /// `button` and `card` are both [`Depth::Raised`], and `field` is a
242 + /// [`Depth::Well`] because that is the reading `Depth`'s own documentation
243 + /// gives a text field. Their bodies come out identical by construction rather
244 + /// than by hand: three hand-written copies in goingson's stylesheet is what
245 + /// phase A deletes, and generating them from one call is what stops them
246 + /// drifting apart again.
247 + fn surface_rules(opts: &Emit) -> String {
248 + let mut css = String::new();
249 + for name in ["button", "card"] {
250 + let c = class(name, opts);
251 + css.push_str(&depth_rule(&c, Depth::Raised));
252 + css.push_str(&interactive_rules(&c));
253 + }
254 +
255 + let field = class("field", opts);
256 + css.push_str(&depth_rule(&field, Depth::Well));
257 + // `Field::invalid` marks the whole group rather than only the message, so
258 + // the ring goes on the control. Flat, not a two-tone bevel: this edge is
259 + // saying "wrong", and lighting one side of it would have it say "raised"
260 + // at the same time.
261 + let _ = writeln!(
262 + css,
263 + ".{field}.invalid {{\n box-shadow: inset 0 0 0 {} var(--danger);\n}}",
264 + opts.border_width
265 + );
266 + css
267 + }
268 +
269 + /// Badges and chips.
270 + ///
271 + /// The one place phase A changes how goingson looks rather than only where its
272 + /// rules live. [`Token::Badge`] is [`Depth::Flat`], so a badge emits no fill
273 + /// and no edge at all, where goingson ships `.tag, .badge` as a single rule
274 + /// carrying the raised bevel. Splitting that means reading every call site to
275 + /// decide which of the two it always was.
276 + ///
277 + /// What a badge does carry is a [`Tone`], the intent family it shares with
278 + /// notices and nothing else. Neutral is the bare class rather than a variant,
279 + /// because it is the absence of a status and not a status called "none".
280 + fn token_rules(opts: &Emit) -> String {
281 + let mut css = String::new();
282 +
283 + // No `depth_rule` call here, deliberately: `Token::Badge.depth(_)` is Flat,
284 + // and a label with an edge says it can be pressed.
285 + let badge = class("badge", opts);
286 + let _ = writeln!(
287 + css,
288 + ".{badge} {{\n color: var(--{});\n}}",
289 + Tone::Neutral.token()
290 + );
291 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
292 + let _ = writeln!(
293 + css,
294 + ".{badge}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
295 + tone.token()
296 + );
297 + }
298 +
299 + // A chip holds itself down, which is `Depth::pressed` arrived at
300 + // independently by two apps. `removable` is a remove affordance, so it is
301 + // markup and waits for phase B.
302 + let chip = class("chip", opts);
303 + let unlatched = Token::Chip { removable: false };
304 + css.push_str(&depth_rule(&chip, unlatched.depth(false)));
305 + css.push_str(&interactive_rules(&chip));
306 + css.push_str(&depth_rule(
307 + &format!("{chip}.latched"),
308 + unlatched.depth(true),
309 + ));
310 + css
311 + }
312 +
313 + /// The three selectors, each named by what it picks.
314 + ///
315 + /// A tab comes *forward* to join the pane it opens, which is why
316 + /// [`Selector::Tabs`] chooses [`Depth::Raised`] where a segment and a toggle
317 + /// are held in. That is the folder semantic, and it is the whole reason the
318 + /// three are not one member with a flag.
319 + ///
320 + /// [`Selector::abutting`] is not emitted: whether the options touch is
321 + /// spacing, and spacing is `makeover-geometry`'s question to answer.
322 + fn selector_rules(opts: &Emit) -> String {
323 + let mut css = String::new();
324 + for (selector, name) in [
325 + (Selector::Tabs, "tab"),
326 + (Selector::Segmented, "segment"),
327 + (Selector::Toggle, "toggle"),
328 + ] {
329 + // The unchosen option is flat and emits no depth of its own. Giving it
330 + // an edge would make every option look picked.
331 + let c = class(name, opts);
332 + css.push_str(&interactive_rules(&c));
333 + css.push_str(&depth_rule(&format!("{c}.chosen"), selector.chosen()));
334 + }
335 + css
336 + }
337 +
338 + /// The four parts of a list row.
339 + fn row_rules(opts: &Emit) -> String {
340 + let mut css = String::new();
341 + let row = class("row", opts);
342 + for part in [
343 + RowPart::Primary,
344 + RowPart::Secondary,
345 + RowPart::Meta,
346 + RowPart::Actions,
347 + ] {
348 + let name = match part {
349 + RowPart::Primary => "row-primary",
350 + RowPart::Secondary => "row-secondary",
351 + RowPart::Meta => "row-meta",
352 + RowPart::Actions => "row-actions",
353 + };
354 + let c = class(name, opts);
355 +
356 + // Actions carry controls rather than text, and `RowPart::intent` says
357 + // so by returning the same intent inheriting already gives. Pinning it
358 + // would be louder than saying nothing.
359 + if !matches!(part, RowPart::Actions) {
360 + let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent());
361 + }
362 +
363 + if part.revealed_on_hover() {
364 + // Hidden rather than absent: the row must not change height when
365 + // the pointer arrives. `focus-within` carries the keyboard, which
366 + // hover on its own would lock out.
367 + let _ = writeln!(css, ".{c} {{\n visibility: hidden;\n}}");
368 + let _ = writeln!(
369 + css,
370 + ".{row}:hover .{c},\n.{row}:focus-within .{c} {{\n visibility: visible;\n}}"
371 + );
372 + }
373 + }
374 + css
375 + }
376 +
377 + /// The progress trough, which has nothing behind it in the description.
378 + ///
379 + /// Renderer-local chrome, on the same licence the skeletons hold as the
380 + /// webview's expression of `Readiness::Pending`: a determinate bar is a shape
381 + /// CSS draws readily and a terminal would rather not be told about. It earns
382 + /// the place empirically, goingson having grown four independent progress bars
383 + /// before anything named one.
384 + ///
385 + /// The trough is a [`Depth::Well`], the same reading a text field gets:
386 + /// something with its content down inside it.
387 + fn progress_rules(opts: &Emit) -> String {
388 + let progress = class("progress", opts);
389 + // `progress-fill` rather than a bare `fill`: an unprefixed build claims
390 + // these names in the app's own stylesheet, and `.fill` is grabby enough to
391 + // catch things that have nothing to do with progress. goingson already
392 + // calls it `.progress-fill`, so this is also the name that deletes.
393 + let fill = class("progress-fill", opts);
394 + format!(
395 + "{}.{progress} > .{fill} {{\n background: var(--action);\n}}\n",
396 + depth_rule(&progress, Depth::Well)
397 + )
398 + }
399 +
400 + /// The component layer: every named thing phase A emits.
401 + ///
402 + /// No scrollbar track. It was on the phase A list and came off: eight lines of
403 + /// `::-webkit-scrollbar` with no shape a terminal or an immediate-mode painter
404 + /// would want handed to it, so it stays with the apps.
405 + #[must_use]
406 + pub fn component_rules(opts: &Emit) -> String {
407 + let mut css = String::new();
408 + css.push_str(&surface_rules(opts));
409 + css.push_str(&token_rules(opts));
410 + css.push_str(&selector_rules(opts));
411 + css.push_str(&row_rules(opts));
412 + css.push_str(&progress_rules(opts));
413 + css
414 + }
415 +
416 + /// The whole phase-A stylesheet: properties, depth rules and components, with
417 + /// a generated-file banner.
177 418 #[must_use]
178 419 pub fn stylesheet(opts: &Emit) -> String {
179 420 format!(
180 421 "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \
181 422 Depth is a fill and an edge together; naming them apart is what let\n \
182 423 them disagree. See the crate's README and wiki note makeover-layout. */\n\
183 - :root {{\n{}}}\n\n{}",
424 + :root {{\n{}}}\n\n{}\n{}",
184 425 bevel_properties(opts),
185 - depth_rules(opts)
426 + depth_rules(opts),
427 + component_rules(opts)
186 428 )
187 429 }
188 430
@@ -247,6 +489,246 @@
247 489 assert!(css.contains(".raised:active {"));
248 490 }
249 491
492 + #[test]
493 + fn pressing_moves_the_fill_and_not_only_the_edge() {
494 + // The decision-1 guard, and the regression that mattered: emitting the
495 + // bevel flip alone is what left goingson hand-writing `background:
496 + // var(--surface-sunken)` on .btn, .card and .tag/.badge alike, so none
497 + // of the three could be deleted.
498 + let pressed = interactive_rules("button");
499 + assert!(pressed.contains(".button:active {"));
500 + assert!(
501 + pressed.contains("background: var(--surface-well, var(--surface-page))"),
502 + "pressed dropped its fill: {pressed}"
503 + );
504 + assert!(pressed.contains("box-shadow: var(--bevel-inset)"));
505 + }
506 +
507 + #[test]
508 + fn pressed_takes_its_fill_from_the_description_not_from_the_app() {
509 + // goingson presses to --surface-sunken. The description says a pressed
510 + // raised region reads as a well, and makeover says outright that
511 + // surface-sunken cannot serve as one, so the app is the thing that
512 + // moves.
513 + let css = stylesheet(&Emit::default());
514 + assert!(
515 + !css.contains("surface-sunken"),
516 + "the app's pressed fill leaked into the generated sheet"
517 + );
518 + assert_eq!(
519 + Depth::Raised.pressed().fill(),
520 + Some(Fill::Well),
521 + "the description changed under us"
522 + );
523 + }
524 +
525 + #[test]
526 + fn hover_resolves_against_the_token_makeover_already_derives() {
527 + let css = interactive_rules("card");
528 + assert!(css.contains(".card:hover {"));
529 + assert!(css.contains("background: var(--hover-surface)"));
530 + // Not the app's choice, which was --surface-overlay.
531 + assert!(!css.contains("surface-overlay"));
532 + }
533 +
534 + #[test]
535 + fn a_badge_gets_no_edge_and_no_fill() {
536 + // Decision 2, and the one visible redesign in phase A. Token::Badge is
537 + // Flat: an edge on a label says it can be pressed.
538 + let css = token_rules(&Emit::default());
539 + let badge = css
540 + .lines()
541 + .skip_while(|l| !l.starts_with(".badge {"))
542 + .take_while(|l| !l.starts_with('}'))
543 + .collect::<Vec<_>>()
544 + .join("\n");
545 + assert!(!badge.contains("box-shadow"), "badge kept an edge: {badge}");
546 + assert!(!badge.contains("background"), "badge kept a fill: {badge}");
547 + assert_eq!(Token::Badge.depth(false), Depth::Flat);
548 + assert_eq!(Token::Badge.depth(true), Depth::Flat);
549 + }
550 +
551 + #[test]
552 + fn a_badge_carries_a_tone_and_neutral_is_the_bare_class() {
553 + let css = token_rules(&Emit::default());
554 + // Neutral is the absence of a status, not a status named "none".
555 + assert!(css.contains(".badge {\n color: var(--content-muted);"));
556 + assert!(!css.contains("data-tone=\"content-muted\""));
557 + for tone in ["info", "success", "warning", "danger"] {
558 + assert!(
559 + css.contains(&format!(".badge[data-tone=\"{tone}\"]")),
560 + "missing tone {tone}"
561 + );
562 + assert!(css.contains(&format!("color: var(--{tone})")));
563 + }
564 + }
565 +
566 + #[test]
567 + fn a_chip_is_raised_and_latches_into_a_well() {
568 + let css = token_rules(&Emit::default());
569 + assert!(css.contains(".chip {"));
570 + assert!(css.contains(".chip.latched {"));
571 + assert!(css.contains(".chip:active {"));
572 + // The whole difference from a badge: it answers a click.
573 + assert!(Token::Chip { removable: false }.interactive());
574 + assert!(!Token::Badge.interactive());
575 + }
576 +
577 + #[test]
578 + fn only_a_tab_comes_forward_when_chosen() {
579 + // The folder semantic. Collapsing the three selectors would lose it.
580 + let css = selector_rules(&Emit::default());
581 + assert!(css.contains(".tab.chosen {"));
582 + assert!(css.contains(".segment.chosen {"));
583 + assert!(css.contains(".toggle.chosen {"));
584 + assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
585 + assert_eq!(Selector::Segmented.chosen(), Depth::Well);
586 + assert_eq!(Selector::Toggle.chosen(), Depth::Well);
587 +
588 + let tab = css
589 + .lines()
590 + .skip_while(|l| !l.starts_with(".tab.chosen {"))
591 + .take_while(|l| !l.starts_with('}'))
592 + .collect::<Vec<_>>()
593 + .join("\n");
594 + assert!(tab.contains("var(--bevel-raised)"), "tab was held in: {tab}");
595 + }
596 +
597 + #[test]
598 + fn an_unchosen_option_has_no_edge_of_its_own() {
599 + let css = selector_rules(&Emit::default());
600 + // `.tab {` with a body would make every option look picked. Only the
601 + // interaction states and the chosen rule may exist.
602 + assert!(!css.contains(".tab {\n"));
603 + assert!(css.contains(".tab:hover {"));
604 + }
605 +
606 + #[test]
607 + fn row_actions_are_revealed_without_moving_the_row() {
608 + let css = row_rules(&Emit::default());
609 + assert!(css.contains(".row-actions {\n visibility: hidden;"));
610 + // Not display:none, which would reflow the row under the pointer.
611 + assert!(!css.contains("display: none"));
612 + // Hover alone would lock the keyboard out.
613 + assert!(css.contains(".row:focus-within .row-actions"));
614 + assert!(RowPart::Actions.revealed_on_hover());
615 + }
616 +
617 + #[test]
618 + fn the_three_text_parts_take_their_intents_and_actions_inherits() {
619 + let css = row_rules(&Emit::default());
620 + assert!(css.contains(".row-primary {\n color: var(--content);"));
621 + assert!(css.contains(".row-secondary {\n color: var(--content-secondary);"));
622 + assert!(css.contains(".row-meta {\n color: var(--content-muted);"));
623 + // Actions carry controls, not text. Pinning the colour it would inherit
624 + // anyway is louder than saying nothing.
625 + assert!(!css.contains(".row-actions {\n color:"));
626 + }
627 +
628 + #[test]
629 + fn the_progress_trough_is_a_well() {
630 + let css = progress_rules(&Emit::default());
631 + assert!(css.contains(".progress {"));
632 + assert!(css.contains("box-shadow: var(--bevel-inset)"));
633 + assert!(css.contains(".progress > .progress-fill {"));
634 + assert!(css.contains("background: var(--action)"));
635 + // A bare `.fill` would catch things that have nothing to do with
636 + // progress once the sheet lands unprefixed.
637 + assert!(!css.contains("> .fill "));
638 + }
639 +
640 + #[test]
641 + fn no_scrollbar_track_is_emitted() {
642 + // Decision 3's negative half. It was on the phase A list and came off;
643 + // this is what stops it drifting back in.
644 + let css = stylesheet(&Emit::default());
645 + assert!(!css.contains("scrollbar"));
646 + assert!(!css.contains("::-webkit"));
647 + }
648 +
649 + #[test]
650 + fn an_invalid_field_is_ringed_without_being_lit() {
651 + let css = surface_rules(&Emit::default());
Lines truncated