Skip to main content

max / makeover-layout

0.7.0: State, the interaction axis The description named rest and, through Depth::pressed, pressed. It named neither focus nor disabled, so makeover-webview emitted a hover rule and stopped, and every consuming app completed the primitive the only way that works from outside: by out-specifying a rule it does not own. goingson carries 19 of those, the MNW server 21, and the three focus rings do not match each other. State is its own axis rather than a Depth member because the two are orthogonal. A disabled button is still Raised and a disabled field is still a Well, so folding it in would make bevel() and fill() answer for something that is not a depth, and would leave both sharing one variant that cannot tell them apart. Two members, not four. Hover is renderer policy, which makeover-webview already says in its own header: a terminal and an immediate-mode painter have no pointer hovering over anything. Pressed arrives through Bevel::pressed, where it belongs, since pressing is a depth inversion. Focus and disabled are different in kind, because a TUI has both. Neither needs a new token: focus-ring and content-muted are already derived by makeover, so this costs no release there.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 15:34 UTC
Signed with PGP, not checked
Commit: 15cfe29e0a66dc0938d0692399d2acf628b3baad
Parent: b9768bd
2 files changed, +122 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.6.1"
3 + version = "0.7.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 +121
@@ -54,6 +54,16 @@
54 54 //! needed [`Fill::Sunken`] and [`Depth::Sunken`]: a surface set back by colour
55 55 //! with no edge, which is neither a well nor level-with.
56 56 //!
57 + //! 0.7.0 adds [`State`], the interaction axis, closing the gap that adopting
58 + //! against three apps rather than one made visible. The description named
59 + //! rest and, through [`Depth::pressed`], pressed. It named neither focus nor
60 + //! disabled, so `makeover-webview` emitted a hover rule and stopped, and each
61 + //! consumer completed the primitive from outside by out-specifying a rule it
62 + //! did not own: 19 such rules in goingson, 21 in the MNW server, a further set
63 + //! in Balanced Breakfast, and three focus rings that do not match. The axis is
64 + //! deliberately two members wide, because hover and pressed belong where they
65 + //! already are. [`State`]'s own docs carry that argument.
66 + //!
57 67 //! # Where the description stops
58 68 //!
59 69 //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
@@ -295,6 +305,90 @@
295 305 }
296 306 }
297 307
308 + /// An interaction state a region can be in, beside whatever [`Depth`] it is.
309 + ///
310 + /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`]
311 + /// and a disabled field is still a [`Depth::Well`], so folding either member
312 + /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for
313 + /// something that is not a depth, and would leave disabled-button and
314 + /// disabled-field sharing one variant that cannot tell them apart.
315 + ///
316 + /// # Why hover and pressed are not members
317 + ///
318 + /// The line is whether every renderer has the state to express, not whether CSS
319 + /// does. Hover is renderer policy and `makeover-webview` says so in its own
320 + /// header: a terminal and an immediate-mode painter have no pointer hovering
321 + /// over anything, and pressed already arrives through [`Bevel::pressed`] and
322 + /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion
323 + /// rather than a separate condition.
324 + ///
325 + /// Focus and disabled are different in kind. A TUI has a focused widget and a
326 + /// greyed-out one; so does egui. Both were unsayable here, so all three webview
327 + /// consumers supplied them from outside the primitive by out-specifying rules
328 + /// they did not own: goingson alone carries 19 of them, and the MNW server
329 + /// another 21. That is the divergence this crate exists to end, arriving one
330 + /// layer down.
331 + ///
332 + /// # The principle this encodes
333 + ///
334 + /// A primitive owns every state it implies. A renderer that emits a hover rule
335 + /// for a thing owes disabled, focus and the capability answer for that same
336 + /// thing, because anything less exports the completion work to N consumers who
337 + /// will each do it differently.
338 + ///
339 + /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth
340 + /// must not be a lockstep event across the three renderers.
341 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
342 + #[non_exhaustive]
343 + pub enum State {
344 + /// Keyboard focus, as distinct from the pointer having landed on something.
345 + ///
346 + /// One ring, not one per primitive. Where the ring sits is [`Depth`]'s
347 + /// question and not a per-component choice: a well takes it inside its own
348 + /// edge and a raised surface takes it outside. That is one decision with
349 + /// two renderings rather than one decision per component, which is how the
350 + /// three apps ended up with three rings.
351 + Focus,
352 + /// Present, visible, and not answering.
353 + ///
354 + /// Not the same as absent, and deliberately not a [`Fill`]: a disabled
355 + /// control keeps the surface it always had and stops responding, so what
356 + /// changes is its content and its interactivity rather than what it is.
357 + Disabled,
358 + }
359 +
360 + impl State {
361 + /// Whether a region in this state stops answering the pointer.
362 + ///
363 + /// Stated in the description rather than left to each renderer, on the same
364 + /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an
365 + /// immediate-mode renderer resolves it per call site, so leaving it unsaid
366 + /// means resolving it once per consumer and disagreeing.
367 + #[must_use]
368 + pub const fn suppresses_interaction(self) -> bool {
369 + match self {
370 + Self::Disabled => true,
371 + Self::Focus => false,
372 + }
373 + }
374 + }
375 +
376 + impl Intent for State {
377 + fn token(self) -> &'static str {
378 + match self {
379 + // Already derived by `makeover` from `action.primary`, and unused
380 + // until now for the same reason `hover-surface` was: nothing
381 + // emitted the rule that would consume it.
382 + Self::Focus => "focus-ring",
383 + // Reusing the muted content intent rather than minting a
384 + // `disabled` colour. Disabled is a reduction and not a status, and
385 + // `makeover-webview`'s progress rules already record the reading
386 + // that `content-muted` is what disabled looks like.
387 + Self::Disabled => "content-muted",
388 + }
389 + }
390 + }
391 +
298 392 /// What a region is saying, when it is saying something.
299 393 ///
300 394 /// The one intent family shared by badges, notices and nothing else. Kept
@@ -919,6 +1013,31 @@
919 1013 assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
920 1014 }
921 1015
1016 + #[test]
1017 + fn state_is_orthogonal_to_depth() {
1018 + // The reason State is its own axis and not a Depth member: a disabled
1019 + // button and a disabled field are both disabled and are not the same
1020 + // shape, which one shared variant could not have said.
1021 + assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
1022 + assert_eq!(Depth::Well.fill(), Some(Fill::Well));
1023 + assert!(State::Disabled.suppresses_interaction());
1024 + }
1025 +
1026 + #[test]
1027 + fn only_disabled_stops_answering() {
1028 + // Focus is a thing you can still click. Getting this backwards is how
1029 + // a focus ring ends up on something inert.
1030 + assert!(!State::Focus.suppresses_interaction());
1031 + assert!(State::Disabled.suppresses_interaction());
1032 + }
1033 +
1034 + #[test]
1035 + fn both_states_resolve_against_intents_makeover_already_derives() {
1036 + // Neither needs a new token, so this costs no `makeover` release.
1037 + assert_eq!(State::Focus.token(), "focus-ring");
1038 + assert_eq!(State::Disabled.token(), "content-muted");
1039 + }
1040 +
922 1041 #[test]
923 1042 fn flat_has_neither_edge_nor_fill() {
924 1043 assert_eq!(Depth::Flat.bevel(), None);
@@ -1006,6 +1125,8 @@
1006 1125 Edge::Dark.token(),
1007 1126 Tone::Danger.token(),
1008 1127 Tone::Neutral.token(),
1128 + State::Focus.token(),
1129 + State::Disabled.token(),
1009 1130 ] {
1010 1131 assert!(!t.starts_with('#'), "{t} looks like a value");
1011 1132 assert!(