Skip to main content

max / makeover-layout

Describe the unchosen option: Selector::unchosen, Fill and Depth::Sunken Release 0.3.0. Closes a gap the first real adoption found, which is what adopting against goingson first was for. Selector described only the chosen option, so an unchosen one fell through to Depth::Flat -- level with the strip it sits in -- and no renderer emitted anything for it. Both halves of that are wrong, and goingson proved it: its unchosen tabs are recessed by hand, and being recessed is why the chosen tab reads as coming forward. Against a flat strip a raised chosen tab is a bevel drawn on the strip's own colour, a much weaker effect than the contrast the folder idiom is named after. GO could not delete the line because no member described it. Selector::unchosen is each member's inverse, which is the whole content of "picked" once colour is deferred: tabs recede so the chosen one comes forward, a segment and a toggle stand up so the chosen one is held in. Asserted both ways, including that unchosen().pressed() == chosen() for the two that are held in. Saying it needed a surface that is recessed with no edge, which the crate could not express. Depth::Flat has no fill and inherits; a well is a hole with an inset bevel. So Fill::Sunken and Depth::Sunken, taking the surface-sunken token makeover already authors and Depth deliberately avoided for wells -- makeover derives surface-well by inverting against the theme's content colour, while surface-sunken is authored and free to sit darker than raised, which is why one cannot serve as the other. Depth::Sunken is the first member carrying a fill without a bevel, so a renderer can no longer assume the two arrive together. Still the pairing rule: both halves come off one Depth and cannot disagree, and here one is legitimately absent. makeover-immediate and makeover-tui are caret-pinned to makeover-layout 0.1.0 and are unaffected. They owe Fill::Sunken an answer whenever they bump, and each has a task filed for it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 21:52 UTC
Signed with PGP, not checked
Commit: f308dc1ad7a6cfed24ea303afbe563a5a4f44139
Parent: 68a3f0f
2 files changed, +121 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.2.0"
3 + version = "0.3.0"
4 4 edition = "2024"
5 5 description = "The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition."
6 6 license = "MIT"
M src/lib.rs +120 -1
@@ -45,6 +45,15 @@
45 45 //! constraint layer per app, which is how the divergence this crate exists to
46 46 //! end got started.
47 47 //!
48 + //! 0.3.0 closes a gap the first real adoption found, which is what adopting
49 + //! against goingson first was for. [`Selector`] described only the *chosen*
50 + //! option, so an unchosen one fell through to [`Depth::Flat`] and no renderer
51 + //! drew it; goingson's tab strip recesses its unchosen tabs by hand and could
52 + //! not delete the line, because being recessed is *why* the chosen tab reads as
53 + //! coming forward. So [`Selector::unchosen`] joins `chosen`, and saying it
54 + //! needed [`Fill::Sunken`] and [`Depth::Sunken`]: a surface set back by colour
55 + //! with no edge, which is neither a well nor level-with.
56 + //!
48 57 //! # Where the description stops
49 58 //!
50 59 //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
@@ -142,6 +151,19 @@
142 151 Overlay,
143 152 /// The inside of a well.
144 153 Well,
154 + /// A surface set back from the one it sits on, by colour and nothing else.
155 + ///
156 + /// Not a well. A well is a hole with an edge, and the two are authored in
157 + /// opposite directions: `makeover` derives `surface-well` by inverting
158 + /// against the theme's own content colour, while `surface-sunken` is
159 + /// authored and free to sit darker than raised (goingson's does). Naming
160 + /// only the well left the recessed-with-no-edge surface unsayable, which is
161 + /// what an unchosen tab is: it recedes so the chosen one can come forward,
162 + /// and it carries no bevel of its own.
163 + ///
164 + /// Added 0.3.0, from goingson's tab strip, which hand-writes exactly this
165 + /// and could not delete the line because no member described it.
166 + Sunken,
145 167 }
146 168
147 169 // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back
@@ -161,6 +183,7 @@
161 183 Self::Raised => "surface-raised",
162 184 Self::Overlay => "surface-overlay",
163 185 Self::Well => "surface-well",
186 + Self::Sunken => "surface-sunken",
164 187 }
165 188 }
166 189 }
@@ -182,6 +205,17 @@
182 205 /// A hole in the panel, with content down inside it. For anything the
183 206 /// user looks *into*: a table body, a tag tree, a text field.
184 207 Well,
208 + /// Set back from what it sits on, by colour alone. No edge.
209 + ///
210 + /// The one member carrying a fill without a bevel, so a renderer cannot
211 + /// assume the two arrive together. That is deliberate and it is still the
212 + /// pairing rule: both halves come off the same `Depth`, so they cannot
213 + /// disagree, and here one half is legitimately absent.
214 + ///
215 + /// Distinct from [`Depth::Flat`], which has no fill either and inherits.
216 + /// Recessed and level-with are different claims, and only one of them
217 + /// needs a colour.
218 + Sunken,
185 219 }
186 220
187 221 impl Depth {
@@ -189,7 +223,10 @@
189 223 #[must_use]
190 224 pub const fn bevel(self) -> Option<Bevel> {
191 225 match self {
192 - Self::Flat => None,
226 + // Sunken joins Flat here, for the opposite reason: Flat has no edge
227 + // because nothing separates it from its surroundings, and Sunken has
228 + // none because its colour is already doing the separating.
229 + Self::Flat | Self::Sunken => None,
193 230 Self::Raised => Some(Bevel::Raised),
194 231 Self::Well => Some(Bevel::Inset),
195 232 }
@@ -205,6 +242,7 @@
205 242 Self::Flat => None,
206 243 Self::Raised => Some(Fill::Raised),
207 244 Self::Well => Some(Fill::Well),
245 + Self::Sunken => Some(Fill::Sunken),
208 246 }
209 247 }
210 248
@@ -457,6 +495,30 @@
457 495 }
458 496 }
459 497
498 + /// How the options that were *not* picked sit.
499 + ///
500 + /// Added 0.3.0. Describing only [`Selector::chosen`] left the unchosen
501 + /// option falling through to [`Depth::Flat`], which says it is level with
502 + /// the strip it sits in, and no renderer emitted anything for it. That is
503 + /// wrong in both directions and goingson proved it: its unchosen tabs are
504 + /// recessed by hand, and being recessed is *why* the chosen one reads as
505 + /// coming forward. Against a flat strip, a raised chosen tab is a bevel
506 + /// drawn on the strip's own colour, which is a much weaker folder effect
507 + /// than the contrast the idiom is named after.
508 + ///
509 + /// Each member is the inverse of its chosen state, which is the whole
510 + /// content of "picked" once colour is deferred:
511 + ///
512 + /// - Tabs recede, so the chosen one comes forward.
513 + /// - A segment and a toggle stand up, so the chosen one is held in.
514 + #[must_use]
515 + pub const fn unchosen(self) -> Depth {
516 + match self {
517 + Self::Tabs => Depth::Sunken,
518 + Self::Segmented | Self::Toggle => Depth::Raised,
519 + }
520 + }
521 +
460 522 /// Whether the options touch.
461 523 ///
462 524 /// The gap is the entire difference between a segmented control and a row
@@ -793,6 +855,63 @@
793 855 assert_eq!(Depth::Flat.fill(), None);
794 856 }
795 857
858 + #[test]
859 + fn sunken_is_recessed_by_colour_with_no_edge() {
860 + // The one member carrying a fill without a bevel. A renderer that
861 + // assumes the two arrive together drops the fill silently, which is
862 + // exactly what makeover-webview did before 0.3.0.
863 + assert_eq!(Depth::Sunken.fill(), Some(Fill::Sunken));
864 + assert_eq!(Depth::Sunken.bevel(), None);
865 + }
866 +
867 + #[test]
868 + fn sunken_and_flat_are_different_claims() {
869 + // Both edgeless, and only one of them needs a colour. Collapsing them
870 + // is what left an unchosen tab unsayable.
871 + assert_eq!(Depth::Flat.bevel(), Depth::Sunken.bevel());
872 + assert_ne!(Depth::Flat.fill(), Depth::Sunken.fill());
873 + }
874 +
875 + #[test]
876 + fn a_sunken_surface_is_not_a_well() {
877 + // Authored in opposite directions: makeover derives surface-well by
878 + // inverting against the theme's content colour, while surface-sunken is
879 + // authored and may sit darker than raised.
880 + assert_ne!(Fill::Sunken, Fill::Well);
881 + assert_eq!(Fill::Sunken.token(), "surface-sunken");
882 + assert_eq!(Fill::Well.token(), "surface-well");
883 + }
884 +
885 + #[test]
886 + fn every_selector_describes_both_of_its_states() {
887 + // The gap 0.3.0 closed. Before it, only `chosen` existed and the
888 + // unchosen option fell through to Flat at every renderer.
889 + for s in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
890 + assert_ne!(
891 + s.chosen(),
892 + s.unchosen(),
893 + "{s:?} cannot tell picked from unpicked"
894 + );
895 + }
896 + }
897 +
898 + #[test]
899 + fn only_a_tab_inverts_the_other_way() {
900 + // Tabs recede so the chosen one comes forward; a segment and a toggle
901 + // stand up so the chosen one is held in. That inversion is the whole
902 + // content of "picked" once colour is deferred, and it is why the three
903 + // are not one member with a flag.
904 + assert_eq!(Selector::Tabs.unchosen(), Depth::Sunken);
905 + assert_eq!(Selector::Tabs.chosen(), Depth::Raised);
906 +
907 + for s in [Selector::Segmented, Selector::Toggle] {
908 + assert_eq!(s.unchosen(), Depth::Raised);
909 + assert_eq!(s.chosen(), Depth::Well);
910 + // Held in is what pressing produces: one appearance, two reasons.
911 + assert_eq!(s.unchosen().pressed(), s.chosen());
912 + }
913 + }
914 +
796 915 #[test]
797 916 fn pressing_a_card_makes_a_well() {
798 917 assert_eq!(Depth::Raised.pressed(), Depth::Well);