//! The adaptation layer of the make-family design system. //! //! //! //! `makeover` answers *what colour*. `makeover-geometry` answers *how much //! space*, and owns the two axes an adaptation is stated against: //! [`Density`] (pointer or touch) and [`SizeClass`] (compact, medium, //! expanded). `makeover-layout` answers *what the thing is*. This crate //! answers one question and no other: //! //! > Does this affordance exist here? //! //! Like `makeover-layout` it emits nothing. It is a description, rendered to //! CSS by `makeover-webview` and to whatever the other renderers can express. //! //! # Why this is a crate and not a density preset //! //! Measured across the MNW server's `@media` blocks (137) and goingson's //! `ui-mode-*` blocks (192), bucketed by what the declarations inside actually //! change: //! //! | bucket | MNW | GO | retired by | //! |---|---|---|---| //! | density | 32% | 27% | a `makeover-geometry` preset | //! | type | 23% | 15% | the type scale | //! | columns | 20% | 12% | `makeover_layout::Column` | //! | reflow | 16% | 17% | `makeover_layout::Arrangement` | //! | **show/hide** | **12%** | **12%** | **this crate** | //! | **reposition** | **6%** | **20%** | **this crate** | //! | **appearance** | **1%** | **17%** | **this crate** | //! //! The bottom three are the roughly 43% that no spacing scale can retire, and //! trying is the mistake this whole family already made once. `display: none` //! on a keyboard hint says *the affordance does not exist on touch*. No amount //! of gap retuning expresses that, and a scale that tried would be smuggling a //! product claim onto a measurement axis — which is exactly what the 2026-07-29 //! Touch demolition was for. //! //! # The two axes are borrowed, never redefined //! //! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's //! window size classes at 600 and 840 and carries [`Density`]; this crate names //! affordances *against* those two and adds no third axis, no fourth class and //! no breakpoint of its own. If a rule here wants a boundary that does not //! exist, that is a conversation with `makeover-geometry`, not a constant. //! //! # What density is allowed to gate //! //! Density is a claim about **the contact patch and nothing else**. So it gates //! affordances that depend on an interaction a fingertip cannot perform — //! hovering, and the keyboard chrome that documents shortcuts a touch surface //! has no way to send. It does not gate anything that is really about how much //! screen there is. A phone is small *and* touch; a tablet is big *and* touch. //! //! That separation is asserted, not merely intended, by //! `density_gates_only_what_the_contact_patch_touches`. Putting a screen-budget //! claim on the input device is the specific failure that produced this crate, //! and re-introducing it should have to come to the test and say so. //! //! # Collapsing is allowed, inverting is not //! //! Borrowed verbatim from `makeover-geometry`, where two gap relationships both //! resolve to zero cells on a terminal and stay two members regardless. Two //! affordances here may have identical availability today — [`Affordance::Hover`] //! and [`Affordance::Hint`] do — and are still two members, because the call //! site names *what is being gated*, not the rule. What must never happen is //! one of them becoming available where the other is not for a reason that is //! really the same reason. //! //! # Deliberately absent //! //! **A navigation shell fork.** goingson currently carries two: 12 forked //! selectors and 10 desktop-only rules concentrated in `.app-header`, `.tab`, //! `.tab-navigation`, `.pill-nav`, `.saved-views-sidebar` and //! `.modal-container`. That is not one shell adapting, it is two shells, and //! choosing to build two is a product decision rather than an adaptation. This //! crate will not describe it, and goingson's own restructure is the way it //! stops being true. Named here the way `makeover-layout` names validation //! absent, so nobody has to discover it. //! //! **Which class applies.** The app decides, from a measured width via //! [`SizeClass::at_width`] and from whatever it already knows about the input. //! This crate takes both as arguments and never sniffs. //! //! **What a renderer does when an affordance is unavailable.** Hiding it, //! substituting it, or showing it unconditionally anyway is renderer policy. //! `makeover-layout` already deleted `Fill::fallback` for being exactly that. #![forbid(unsafe_code)] pub use makeover_geometry::{Density, SizeClass}; pub use makeover_layout::Priority; /// An affordance whose existence depends on the surface it is offered on. /// /// Six members, drawn from what the two measured apps already gate by hand /// rather than from a taxonomy. `makeover-layout`'s warning applies and is the /// reason for stopping here: guessing at eight is how a description becomes a /// framework. /// /// Each answers [`Self::available`] against the two axes and nothing else. An /// affordance that is always available is not an affordance this crate has /// anything to say about, and `every_member_is_an_adaptation` asserts none has /// snuck in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Affordance { /// Anything a consumer reveals on hover: a row's action cluster, a /// hover toolbar, a preview popover. /// /// Both webview apps arrived at hover-revealed row actions independently /// (goingson `.task-row-action`, Balanced Breakfast `.row-actions`), which /// is why `makeover-layout` records the reveal as behaviour of /// `RowPart::Actions` rather than as app policy. What neither app can say /// is that a fingertip has no hover state at all, so the affordance is not /// hidden on touch — it does not exist there, and something else has to /// carry the same actions. Hover, /// Chrome documenting a keyboard interaction: shortcut badges, key hints, /// a "press / to search" line. /// /// goingson hides `.kbd-hint` on touch. Strictly this is a claim about /// having a keyboard rather than about the contact patch, and [`Density`] /// is the closest honest proxy the family carries. Stated rather than /// hidden, because a detachable-keyboard tablet is where the proxy breaks /// and a third axis is what fixing it would cost. Hint, /// A secondary panel standing beside the primary content: a saved-views /// rail, a filter sidebar, an inspector. /// /// goingson hides `.saved-views-sidebar` below its widest layout. Purely a /// screen-budget claim — a touchscreen laptop should keep it — so this /// reads [`SizeClass`] alone. Ancillary, /// The detail half of a list-detail split, shown *alongside* the list /// rather than navigated to. /// /// goingson's `.main-content` and Balanced Breakfast's `.detail-panel`. /// Unavailable is not the same as absent: the detail still exists, it is /// reached by navigation instead of by adjacency, and which of the two a /// screen gets is what `makeover-layout`'s `Arrangement` is describing. Detail, /// Navigation or a primary action cluster pinned to a fixed screen edge /// instead of sitting in the flow of the page. /// /// The reposition bucket, and the largest single one in goingson at 20%. /// It exists to compensate for what a compact window cannot hold in flow, /// so unlike the two above it is available at the *narrow* end and not the /// wide one. That inversion is the point: an adaptation that only ever /// removes things describes a degraded layout rather than a different one. Anchored, /// An action cluster collapsed behind one control rather than laid out /// inline. /// /// The other compensating member. `makeover-layout`'s `Column::kept_at` /// already handles a *table* narrowing by dropping columns; this is the /// same pressure on a cluster of controls, which cannot drop any of them /// and folds instead. Overflow, } impl Affordance { /// Whether this affordance exists on a surface with the given input class /// and screen budget. /// /// The whole crate in one call. A renderer asks per affordance and never /// branches on a width. #[must_use] pub const fn available(self, density: Density, size: SizeClass) -> bool { match self { // Contact patch. Screen budget has no opinion about either. Self::Hover | Self::Hint => matches!(density, Density::Pointer), // Screen budget. The input device has no opinion about any of them. Self::Ancillary => matches!(size, SizeClass::Expanded), Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded), Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact), } } /// Whether this affordance's availability reads [`Density`] at all. /// /// Exposed rather than kept private because it is the crate's own claim /// about itself: exactly the members gating a contact-patch interaction say /// yes. A renderer with one density can skip the rest entirely. #[must_use] pub const fn reads_density(self) -> bool { matches!(self, Self::Hover | Self::Hint) } /// Whether this affordance's availability reads [`SizeClass`] at all. #[must_use] pub const fn reads_size(self) -> bool { !self.reads_density() } /// Every member, in declaration order. #[must_use] pub const fn all() -> [Self; 6] { [ Self::Hover, Self::Hint, Self::Ancillary, Self::Detail, Self::Anchored, Self::Overflow, ] } /// The CSS class name an app may hang off this, without the leading dot. /// /// Present for the same reason [`SizeClass::token`] is: a webview renderer /// needs a stable name, and minting it per app is how two apps end up with /// `has-hover` and `hover-capable`. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Hover => "offers-hover", Self::Hint => "offers-hint", Self::Ancillary => "offers-ancillary", Self::Detail => "offers-detail", Self::Anchored => "offers-anchored", Self::Overflow => "offers-overflow", } } } /// The column-drop cutoff a window of this size class asks for. /// /// The seam between `makeover-layout` and `makeover-geometry` that neither /// crate could close. Layout defines the priority ladder and `Column::kept_at`; /// geometry defines the boundaries. Nothing said *which* cutoff a compact /// window uses, so both webview apps answered it with `nth-child` on an ordinal /// and inserting a column silently hid the wrong one. /// /// A free function rather than an [`Affordance`] member because a column is not /// gated, it is ranked: the question is which cutoff to raise to, not whether /// the table exists. #[must_use] pub const fn column_cutoff(size: SizeClass) -> Priority { match size { // Only what identifies the row. SizeClass::Compact => Priority::Essential, // The optional columns go first. SizeClass::Medium => Priority::Secondary, // Everything survives. SizeClass::Expanded => Priority::Optional, } } #[cfg(test)] mod tests { use super::*; /// Every combination of the two axes, narrowest and coarsest first. fn surfaces() -> Vec<(Density, SizeClass)> { let mut out = Vec::new(); for d in [Density::Pointer, Density::Touch] { for s in SizeClass::all() { out.push((d, s)); } } out } #[test] fn density_gates_only_what_the_contact_patch_touches() { // The failure this crate exists to avoid: a screen-budget claim // smuggled onto the input axis, which is what let the old Touch gap // preset set a floor under a preset quoted from the HIG. Adding a // density dependency to a screen-budget affordance has to come here // and say so. for a in Affordance::all() { let varies_by_density = SizeClass::all() .iter() .any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s)); assert_eq!( varies_by_density, a.reads_density(), "{a:?} disagrees with its own reads_density()" ); } } #[test] fn size_gates_only_what_screen_budget_touches() { for a in Affordance::all() { let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| { SizeClass::all() .iter() .any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact)) }); assert_eq!( varies_by_size, a.reads_size(), "{a:?} disagrees with its own reads_size()" ); } } #[test] fn no_member_reads_both_axes() { // Not a law of adaptation, a statement about the six that exist. A // seventh reading both is allowed, and this test is where the claim // gets withdrawn rather than quietly falsified. for a in Affordance::all() { assert!( a.reads_density() != a.reads_size(), "{a:?} reads both axes; update this test and say why" ); } } #[test] fn every_member_is_an_adaptation() { // A member available everywhere, or nowhere, is not describing an // adaptation and does not belong in this crate. for a in Affordance::all() { let yes = surfaces() .iter() .filter(|&&(d, s)| a.available(d, s)) .count(); assert!(yes > 0, "{a:?} exists on no surface"); assert!(yes < surfaces().len(), "{a:?} exists on every surface"); } } #[test] fn availability_is_contiguous_across_the_size_ladder() { // No member may exist at compact and expanded but not medium. A hole // in the middle is always an off-by-one, never a design. for a in Affordance::all() { for d in [Density::Pointer, Density::Touch] { let run: Vec = SizeClass::all() .iter() .map(|&s| a.available(d, s)) .collect(); let transitions = run.windows(2).filter(|w| w[0] != w[1]).count(); assert!( transitions <= 1, "{a:?} at {d:?} is available in a broken run: {run:?}" ); } } } #[test] fn compact_compensates_rather_than_only_losing() { // The reposition bucket is 20% of goingson's adaptation rules and the // reason this crate is not just a hide-list. Whatever compact takes // away, something has to give back. for d in [Density::Pointer, Density::Touch] { assert!(!Affordance::Detail.available(d, SizeClass::Compact)); assert!(Affordance::Anchored.available(d, SizeClass::Compact)); } } #[test] fn hover_and_hint_collapse_and_that_is_allowed() { // Borrowed from makeover-geometry, where bound and peer both resolve to // zero cells on a terminal and stay two members. Identical rules are // not a duplicate; the call site names what is gated. for (d, s) in surfaces() { assert_eq!( Affordance::Hover.available(d, s), Affordance::Hint.available(d, s) ); } assert_ne!(Affordance::Hover.token(), Affordance::Hint.token()); } #[test] fn touch_never_gains_an_affordance_pointer_lacks() { // Direction matters, same as makeover-geometry's cross-density rule. // Touch is derived from pointer by subtracting what a fingertip cannot // do, so it can lose members and never gain one. for s in SizeClass::all() { for a in Affordance::all() { if a.available(Density::Touch, s) { assert!( a.available(Density::Pointer, s), "{a:?} exists on touch at {s:?} but not on pointer" ); } } } } #[test] fn tokens_are_distinct() { let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect(); seen.sort_unstable(); let before = seen.len(); seen.dedup(); assert_eq!(seen.len(), before); } #[test] fn the_column_cutoff_relaxes_as_the_window_widens() { // Priority derives Ord with Optional lowest, so a narrower window is a // higher cutoff. Asserted by comparison rather than by naming the three // constants, so reordering the ladder in makeover-layout breaks here. assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium)); assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded)); } #[test] fn the_column_cutoff_replaces_the_ordinal() { // goingson's bug, written against this crate's answer: inserting a // column must not change which column drops. use makeover_layout::{Column, Priority as P, Width}; let before = [ Column { name: "Title", width: Width::Fill, priority: P::Essential, }, Column { name: "Due", width: Width::Fixed, priority: P::Secondary, }, Column { name: "Estimate", width: Width::Fixed, priority: P::Optional, }, ]; let after = [ Column { name: "Title", width: Width::Fill, priority: P::Essential, }, Column { name: "Project", width: Width::Fill, priority: P::Secondary, }, Column { name: "Due", width: Width::Fixed, priority: P::Secondary, }, Column { name: "Estimate", width: Width::Fixed, priority: P::Optional, }, ]; let cutoff = column_cutoff(SizeClass::Compact); let kept: Vec<&str> = before .iter() .filter(|c| c.kept_at(cutoff)) .map(|c| c.name) .collect(); assert_eq!(kept, ["Title"]); let kept: Vec<&str> = after .iter() .filter(|c| c.kept_at(cutoff)) .map(|c| c.name) .collect(); assert_eq!(kept, ["Title"]); } #[test] fn the_axes_are_borrowed_not_redefined() { // Re-exported rather than mirrored, so there is exactly one definition // of each in the family. A local copy is how two crates start // disagreeing about where 600px is. assert_eq!(SizeClass::Medium.min_px(), 600); assert_eq!(SizeClass::Expanded.min_px(), 840); assert_eq!(SizeClass::at_width(599), SizeClass::Compact); } }