Skip to main content

max / makeover-webview

0.65.0: the theme picker, as one optgroup per variant makeover-layout 0.38.0's FieldKind::Theme (518c650a, ruled by Max on 70028e00). This is the one place this renderer emits <optgroup>, and it emits it because the description finally says there is a group: `optgroup` appears at exactly one live site in the whole tree, and both apps that had grouped theme pickers lost the grouping the moment they were described. The grouping comes out of the order rather than out of a group list. Field::themes arrives sorted by variant, so the run of one variant is the group and a new optgroup opens whenever the variant changes. A list that arrived unsorted emits repeated groups rather than silently merging distant rows, which is the honest report on a description that broke its contract. The contrast tier rides in the option's text and in data-contrast, because a <select>'s options take no elements and no second line -- push_options' finding about Choice::unavailable, met again. The badge spelling is makeover_layout::Contrast::badge rather than one invented here. The follow-the-system row is emitted first and outside every group: it names no theme and sits in no variant. It is also excluded from the stray-value check, or a picker set to `system` would emit a duplicate unmatched row.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01AAbx8dxVmuVeyKbKoRrUL2
Author: Max Johnson <me@maxj.phd> · 2026-08-28 19:09 UTC
Signed with PGP, not checked
Commit: 0a5bbd6a1b12d060b9fc1161acd68d320a490cb6
Parent: d839722
3 files changed, +254 insertions, -6 deletions
M Cargo.toml +3 -3
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.64.0"
3 + version = "0.65.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
@@ -17,7 +17,7 @@
17 17 # patch satisfy the requirement and still fail to compile. That happened once
18 18 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
19 19 # it surfaced, one release later.
20 - makeover-layout = "0.37.0"
20 + makeover-layout = "0.38.0"
21 21 # The capability axis. `makeover-touch` decides whether a hover rule should be
22 22 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
23 23 # answers are owned elsewhere and neither is re-derived here.
@@ -27,7 +27,7 @@
27 27 # satisfies "0.8" and keeps a second makeover-geometry in the graph next to the
28 28 # 0.7 this crate asks for. `Density` is nominally distinct across the two and
29 29 # the build fails on a type that reads as identical.
30 - makeover-touch = "0.27.0"
30 + makeover-touch = "0.28.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/corpus.rs +14 -2
@@ -26,8 +26,8 @@
26 26 use crate::{Emit, facet::facet_html, figure::figures_html};
27 27 use crate::{meter::meter_html, placeholder::placeholder_html};
28 28 use makeover_layout::{
29 - Accepted, CellPart, Choice, Column, Facet, FacetValue, Field, FieldKind, Figure, Meter,
30 - Priority, Readiness, Selecting, Sort, Standing, Tone, Width,
29 + Accepted, CellPart, Choice, Column, Contrast, Facet, FacetValue, Field, FieldKind, Figure,
30 + Meter, Priority, Readiness, Selecting, Sort, Standing, ThemeChoice, ThemeVariant, Tone, Width,
31 31 };
32 32 use std::collections::BTreeSet;
33 33
@@ -139,6 +139,15 @@
139 139 Choice::new("b", "The second").unless("Not while the first is running"),
140 140 ];
141 141 const ACCEPT: &[Accepted<'_>] = &[Accepted::Type("image/png"), Accepted::Suffix(".zip")];
142 + // Two variants and two tiers, so the corpus carries a group boundary and a
143 + // badge that is not the same badge twice. One theme per variant would emit
144 + // one `<optgroup>` and prove nothing about where the next one opens.
145 + const THEMES: &[ThemeChoice<'_>] = &[
146 + ThemeChoice::new("goingson", "GoingsOn", ThemeVariant::Light, Contrast::High),
147 + ThemeChoice::new("ayu-light", "Ayu Light", ThemeVariant::Light, Contrast::Low),
148 + ThemeChoice::new("carbonfox", "Carbonfox", ThemeVariant::Dark, Contrast::High),
149 + ThemeChoice::new("nord", "Nord", ThemeVariant::Dark, Contrast::Standard),
150 + ];
142 151
143 152 let mut html = String::new();
144 153 for kind in [
@@ -158,10 +167,13 @@
158 167 FieldKind::Radio,
159 168 FieldKind::Checkbox,
160 169 FieldKind::File,
170 + FieldKind::Theme,
161 171 FieldKind::Hidden,
162 172 ] {
163 173 let plain = Field {
164 174 options: OPTIONS,
175 + themes: THEMES,
176 + follows: Some(Choice::new("system", "Follow System")),
165 177 accept: ACCEPT,
166 178 upper_name: Some("upper"),
167 179 min: Some("0"),
M src/form.rs +237 -1
@@ -41,7 +41,7 @@
41 41 //! write it back, at which point it is a form model.
42 42
43 43 use crate::{Emit, class, push_class};
44 - use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, Tone};
44 + use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone};
45 45 use std::fmt::Write as _;
46 46
47 47 /// Every class this module can put in markup.
@@ -739,6 +739,95 @@
739 739 }
740 740 }
741 741
742 + /// The themes, as one `<optgroup>` per variant with a contrast mark per row.
743 + ///
744 + /// # The grouping comes out of the order, not out of a group list
745 + ///
746 + /// [`makeover_layout::Field::themes`] arrives sorted by variant and then by
747 + /// measured contrast, and the run of one variant is the group. So this walks
748 + /// the list once and opens a new `<optgroup>` whenever the variant changes,
749 + /// which is the whole of the grouping logic and cannot disagree with the order
750 + /// the way a separately-carried group list could.
751 + ///
752 + /// A theme whose variant equals its predecessor's never opens a group, so a
753 + /// list that arrived unsorted would emit repeated groups rather than silently
754 + /// merging distant rows. That is the honest report on a description that broke
755 + /// its own contract, and it is visible on screen rather than in a log.
756 + ///
757 + /// # The follow row is not in a group
758 + ///
759 + /// It names no theme and sits in no variant, so it is emitted first and bare.
760 + /// Grouping it under a heading would be inventing a fourth variant for one row.
761 + ///
762 + /// # The badge is text, because a `<select>` has nowhere else to put it
763 + ///
764 + /// A `<select>`'s options take no elements, no second line and no title the
765 + /// keyboard reaches, which is [`push_options`]' finding about
766 + /// [`Choice::unavailable`] met a second time. So the tier rides in the option's
767 + /// own text, in brackets after the name, and it is
768 + /// [`makeover_layout::Contrast::badge`]'s spelling rather than one invented
769 + /// here — three renderers picking their own is one picker reading three ways.
770 + fn push_theme_options(out: &mut String, field: &Field<'_>, value: &str) {
771 + if let Some(follow) = field.follows {
772 + out.push_str("<option value=\"");
773 + escape_into(follow.value, out);
774 + out.push('"');
775 + if follow.value == value {
776 + out.push_str(" selected");
777 + }
778 + out.push('>');
779 + escape_into(follow.label, out);
780 + out.push_str("</option>");
781 + }
782 +
783 + // A stored id naming a theme that is no longer installed. `push_options`'
784 + // reasoning applies unchanged: a value no row carries is a wrong answer
785 + // rather than an absent one, and dropping it would silently show the user
786 + // a different theme than the one their config names.
787 + let known = field.themes.iter().any(|theme| theme.id == value)
788 + || field.follows.is_some_and(|follow| follow.value == value);
789 + if !value.is_empty() && !known {
790 + let escaped = escape(value);
791 + let _ = write!(
792 + out,
793 + "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
794 + );
795 + }
796 +
797 + let mut open: Option<ThemeVariant> = None;
798 + for theme in field.themes {
799 + if open != Some(theme.variant) {
800 + if open.is_some() {
801 + out.push_str("</optgroup>");
802 + }
803 + out.push_str("<optgroup label=\"");
804 + escape_into(theme.variant.heading(), out);
805 + out.push_str("\" data-variant=\"");
806 + out.push_str(theme.variant.as_str());
807 + out.push_str("\">");
808 + open = Some(theme.variant);
809 + }
810 +
811 + out.push_str("<option value=\"");
812 + escape_into(theme.id, out);
813 + out.push_str("\" data-contrast=\"");
814 + out.push_str(theme.contrast.as_str());
815 + out.push('"');
816 + if theme.id == value {
817 + out.push_str(" selected");
818 + }
819 + out.push('>');
820 + escape_into(theme.name, out);
821 + out.push_str(" (");
822 + out.push_str(theme.contrast.badge());
823 + out.push(')');
824 + out.push_str("</option>");
825 + }
826 + if open.is_some() {
827 + out.push_str("</optgroup>");
828 + }
829 + }
830 +
742 831 /// The control itself, without its label, hint or error.
743 832 fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
744 833 // Emitted before anything else is computed: a radio group carries its
@@ -809,6 +898,21 @@
809 898 push_options(out, field, field.options, filling.value.as_text());
810 899 out.push_str("</select>");
811 900 }
901 + // The one place this renderer emits `<optgroup>`, and it emits it
902 + // because the description finally says there is a group. The measured
903 + // history is the argument: `optgroup` appears at one live site in the
904 + // whole tree, and the two apps that had grouped theme pickers lost the
905 + // grouping the moment they were described, because `Choice` is a value
906 + // and a label and a group is neither.
907 + FieldKind::Theme => {
908 + out.push_str("<select class=\"");
909 + push_class(out, "field", opts);
910 + out.push('"');
911 + push_control_attributes(out, field, filling, &id, field.name);
912 + out.push('>');
913 + push_theme_options(out, field, filling.value.as_text());
914 + out.push_str("</select>");
915 + }
812 916 FieldKind::Checkbox => {
813 917 out.push_str("<label class=\"");
814 918 push_class(out, "form-checkbox-label", opts);
@@ -2367,4 +2471,136 @@
2367 2471 );
2368 2472 assert!(html.contains(r#"id="title" name="title""#), "{html}");
2369 2473 }
2474 +
2475 + /// Two variants and two tiers, which is the smallest list that can show
2476 + /// where a group opens and that two badges differ.
2477 + const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[
2478 + makeover_layout::ThemeChoice::new(
2479 + "goingson",
2480 + "GoingsOn",
2481 + ThemeVariant::Light,
2482 + makeover_layout::Contrast::High,
2483 + ),
2484 + makeover_layout::ThemeChoice::new(
2485 + "ayu-light",
2486 + "Ayu Light",
2487 + ThemeVariant::Light,
2488 + makeover_layout::Contrast::Low,
2489 + ),
2490 + makeover_layout::ThemeChoice::new(
2491 + "carbonfox",
2492 + "Carbonfox",
2493 + ThemeVariant::Dark,
2494 + makeover_layout::Contrast::High,
2495 + ),
2496 + ];
2497 +
2498 + #[test]
2499 + fn a_theme_picker_opens_one_optgroup_per_variant() {
2500 + let f = Field::theme("theme", "Theme", THEMES);
2501 + let html = field_html(&f, &Filling::default(), &Emit::default());
2502 +
2503 + assert_eq!(html.matches("<optgroup").count(), 2, "{html}");
2504 + assert_eq!(html.matches("</optgroup>").count(), 2, "{html}");
2505 + assert!(
2506 + html.contains(r#"<optgroup label="Light" data-variant="light">"#),
2507 + "{html}"
2508 + );
2509 + assert!(
2510 + html.contains(r#"<optgroup label="Dark" data-variant="dark">"#),
2511 + "{html}"
2512 + );
2513 + // The two light themes share one group: a new group opens on a change
2514 + // of variant and on nothing else.
2515 + assert!(
2516 + html.find("Ayu Light") < html.find("<optgroup label=\"Dark\""),
2517 + "{html}"
2518 + );
2519 + }
2520 +
2521 + #[test]
2522 + fn every_theme_carries_its_measured_tier() {
2523 + // The fact the three hand-written pickers lost. It rides in the text
2524 + // because a `<select>`'s options take no elements, and in an attribute
2525 + // because a stylesheet cannot read text.
2526 + let f = Field::theme("theme", "Theme", THEMES);
2527 + let html = field_html(&f, &Filling::default(), &Emit::default());
2528 +
2529 + assert!(html.contains(r#"data-contrast="high""#), "{html}");
2530 + assert!(html.contains(r#"data-contrast="low""#), "{html}");
2531 + assert!(html.contains("GoingsOn (AA)"), "{html}");
2532 + assert!(html.contains("Ayu Light (low)"), "{html}");
2533 + }
2534 +
2535 + #[test]
2536 + fn the_follow_row_is_first_and_sits_in_no_group() {
2537 + // It names no theme and belongs to no variant, so grouping it would be
2538 + // inventing a fourth variant for one row.
2539 + let f = Field::theme("theme", "Theme", THEMES)
2540 + .following(Choice::new("system", "Follow System"));
2541 + let html = field_html(&f, &Filling::default(), &Emit::default());
2542 +
2543 + let follow = html.find("Follow System").expect("the row was offered");
2544 + assert!(follow < html.find("<optgroup").expect("groups"), "{html}");
2545 + }
2546 +
2547 + #[test]
2548 + fn the_stored_theme_is_the_selected_one() {
2549 + let f = Field::theme("theme", "Theme", THEMES)
2550 + .following(Choice::new("system", "Follow System"));
2551 +
2552 + let named = field_html(&f, &Filling::of(Value::Text("carbonfox")), &Emit::default());
2553 + assert!(
2554 + named.contains(r#"value="carbonfox" data-contrast="high" selected"#),
2555 + "{named}"
2556 + );
2557 + assert!(!named.contains(r#"value="system" selected"#), "{named}");
2558 +
2559 + let following = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
2560 + assert!(
2561 + following.contains(r#"value="system" selected"#),
2562 + "{following}"
2563 + );
2564 + }
2565 +
2566 + #[test]
2567 + fn a_theme_that_is_no_longer_installed_keeps_its_value() {
2568 + // `push_options`' rule, met again: a value no row carries is a wrong
2569 + // answer rather than an absent one, and dropping it would save a
2570 + // different theme over the user's on the next write.
2571 + let f = Field::theme("theme", "Theme", THEMES);
2572 + let html = field_html(
2573 + &f,
2574 + &Filling::of(Value::Text("deleted-theme")),
2575 + &Emit::default(),
2576 + );
2577 + assert!(html.contains(r#"data-unmatched="true""#), "{html}");
2578 + assert!(
2579 + html.contains(r#"<option value="deleted-theme" selected"#),
2580 + "{html}"
2581 + );
2582 + }
2583 +
2584 + #[test]
2585 + fn the_follow_row_is_not_a_stray_value() {
2586 + // The near-miss: `system` is carried by no `ThemeChoice`, so a check
2587 + // that only walked the theme list would emit a duplicate unmatched row
2588 + // beside the real one.
2589 + let f = Field::theme("theme", "Theme", THEMES)
2590 + .following(Choice::new("system", "Follow System"));
2591 + let html = field_html(&f, &Filling::of(Value::Text("system")), &Emit::default());
2592 + assert!(!html.contains("data-unmatched"), "{html}");
2593 + }
2594 +
2595 + #[test]
2596 + fn a_machine_with_no_themes_still_gets_a_picker() {
2597 + // `Field::themes`' own position: an app whose theme directories hold
2598 + // nothing has exactly this, and the empty control says so on screen.
2599 + let f =
2600 + Field::theme("theme", "Theme", &[]).following(Choice::new("system", "Follow System"));
2601 + let html = field_html(&f, &Filling::default(), &Emit::default());
2602 + assert!(html.contains("<select"), "{html}");
2603 + assert!(!html.contains("<optgroup"), "{html}");
2604 + assert!(html.contains("Follow System"), "{html}");
2605 + }
2370 2606 }