//! 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. //! `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. //! //! Two of the eight members are not in that census at all, and the exception is //! worth stating rather than leaving to be noticed. [`Affordance::Gesture`] and //! [`Affordance::Haptic`] adapt *behaviour*, which no stylesheet contains, so //! counting `@media` blocks could never have found them. Their evidence was a //! line of JavaScript rather than a media query: //! //! ```js //! const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); //! ``` //! //! which two apps carried identically, each hanging five gestures off it. Two //! codebases arriving independently at the density question this crate exists //! to answer, and neither able to state it where a stylesheet could see it. //! //! # 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 failure this crate exists to prevent, and //! re-introducing it has to come to the test and say so. //! //! # Both densities gain something //! //! Touch is not pointer minus what a fingertip cannot do. //! [`Affordance::Hover`]'s own doc says a fingertip has no hover state *and that //! something else has to carry the same actions*, so something here has to be //! that something. [`Affordance::Anchored`] and [`Affordance::Overflow`] //! compensate on the size axis; [`Affordance::Gesture`] and //! [`Affordance::Haptic`] compensate on the density axis, and both are gained //! by touch rather than lost to it. //! //! [`Affordance::gained_by`] makes each member declare which density it belongs //! to, and //! `a_density_member_is_available_on_exactly_the_density_it_declares` checks the //! declaration against the rule. So a new member still cannot quietly invert: //! it has to say which way it goes, in code, and the test is where a wrong //! answer surfaces. //! //! # 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. /// /// Eight 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 each addition has to point at call sites rather than at a category: /// guessing 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, /// A direct-manipulation gesture on content: swipe-to-action, long-press to /// select, pull to refresh, swipe to navigate, drag to dismiss. /// /// The density axis's compensating member, and the answer to the question /// [`Self::Hover`] asks and cannot answer. A fingertip has no hover state, /// so the row actions a pointer reveals by hovering have to arrive some /// other way; on both webview apps that way is a swipe. /// /// **One member for five gestures, on purpose.** goingson and Balanced /// Breakfast each gate all five behind a single boolean, so one member is /// what is measured and five would be minted from one fact. If a surface /// ever offers swipe without long-press, splitting this is additive and the /// call sites that named `Gesture` keep meaning what they meant. Gesture, /// Confirmation delivered through the contact patch rather than the eye: the /// tick as a drag crosses a threshold, the thump as a gesture fires. /// /// A contact-patch claim, which is what makes it this crate's business /// despite being the one member nothing on screen shows, and the one with /// no consumer at present: the renderers that would deliver it do not ask /// for it yet. /// /// The proxy breaks where the hardware has haptics and the user or the OS /// has switched them off. That is neither [`Density`] nor [`SizeClass`], and /// it is stated here rather than fixed for the same reason [`Self::Hint`] /// states the detachable-keyboard case: a third axis is what fixing it would /// cost. **This member says the surface can, never that the user wants.** /// Asking the platform whether haptics are enabled is the renderer's job. Haptic, } 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, lost to a fingertip. Self::Hover | Self::Hint => matches!(density, Density::Pointer), // Contact patch, gained by one. A mouse can neither swipe a row nor // feel a confirmation, and a phone-sized window has nothing to do // with either. Self::Gesture | Self::Haptic => matches!(density, Density::Touch), // 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), } } /// Which [`Density`] this affordance belongs to, or `None` when it reads the /// screen budget instead. /// /// Each member declares its own direction, and /// `a_density_member_is_available_on_exactly_the_density_it_declares` holds /// the declaration to the rule. There is no crate-wide one-directional /// rule: see the crate doc. /// /// Exposed rather than kept private for the same reason [`Self::reads_density`] /// is: it is the crate's claim about itself, and a renderer that has one /// density can read it directly instead of probing [`Self::available`] twice. #[must_use] pub const fn gained_by(self) -> Option { match self { Self::Hover | Self::Hint => Some(Density::Pointer), Self::Gesture | Self::Haptic => Some(Density::Touch), Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None, } } /// 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 { self.gained_by().is_some() } /// 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; 8] { [ Self::Hover, Self::Hint, Self::Ancillary, Self::Detail, Self::Anchored, Self::Overflow, Self::Gesture, Self::Haptic, ] } /// 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", Self::Gesture => "offers-gesture", Self::Haptic => "offers-haptic", } } } /// 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()); } /// The other density. Two members, so this is total, and writing it here /// rather than in `makeover-geometry` keeps the axis definition borrowed /// rather than extended. fn opposite(d: Density) -> Density { match d { Density::Pointer => Density::Touch, Density::Touch => Density::Pointer, } } #[test] fn a_density_member_is_available_on_exactly_the_density_it_declares() { // Replaces `touch_never_gains_an_affordance_pointer_lacks`, withdrawn in // 0.3.0. That test made direction a property of the whole crate: touch // was pointer minus what a fingertip cannot do, and could never add. // Gesture and Haptic add, so the global claim had to go. // // What survives is the part worth keeping. A member still cannot invert // quietly -- it declares its density in `gained_by`, and this is where a // declaration that disagrees with the rule shows up. The old test's job // was to make a direction change deliberate; so is this one's. for a in Affordance::all() { match a.gained_by() { Some(gained) => { assert!( a.reads_density(), "{a:?} declares a density but denies reading one" ); for s in SizeClass::all() { assert!( a.available(gained, s), "{a:?} declares {gained:?} but is unavailable there at {s:?}" ); assert!( !a.available(opposite(gained), s), "{a:?} declares {gained:?} but is also available on the other density at {s:?}" ); } } None => assert!( !a.reads_density(), "{a:?} reads density but declares no side" ), } } } #[test] fn both_densities_gain_something() { // The withdrawn rule, inverted into a statement of what replaced it. A // crate where only pointer gains members is the one this stopped being, // and if a refactor ever takes the touch-gained members back out, the // honest move is to restore the old one-directional test rather than // let this one quietly pass on an empty set. for d in [Density::Pointer, Density::Touch] { assert!( Affordance::all().iter().any(|a| a.gained_by() == Some(d)), "no member is gained by {d:?}" ); } } #[test] fn the_density_axis_compensates_rather_than_only_losing() { // The density-axis twin of `compact_compensates_rather_than_only_losing`, // and the hole that motivated 0.3.0: Hover's own doc says something else // has to carry the actions a fingertip cannot hover to reveal, and until // Gesture existed nothing here could be that something. for s in SizeClass::all() { assert!(!Affordance::Hover.available(Density::Touch, s)); assert!(Affordance::Gesture.available(Density::Touch, s)); } } #[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 { width: Width::Fill, priority: P::Essential, ..Column::new("Title") }, Column { width: Width::Fixed, priority: P::Secondary, ..Column::new("Due") }, Column { width: Width::Fixed, priority: P::Optional, ..Column::new("Estimate") }, ]; let after = [ Column { width: Width::Fill, priority: P::Essential, ..Column::new("Title") }, Column { width: Width::Fill, priority: P::Secondary, ..Column::new("Project") }, Column { width: Width::Fixed, priority: P::Secondary, ..Column::new("Due") }, Column { width: Width::Fixed, priority: P::Optional, ..Column::new("Estimate") }, ]; 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); } }