Skip to main content

makeover_touch/
lib.rs

1//! The adaptation layer of the make-family design system.
2//!
3//! <!-- wiki: makeover-touch -->
4//!
5//! `makeover` answers *what colour*. `makeover-geometry` answers *how much
6//! space*, and owns the two axes an adaptation is stated against:
7//! [`Density`] (pointer or touch) and [`SizeClass`] (compact, medium,
8//! expanded). `makeover-layout` answers *what the thing is*. This crate
9//! answers one question and no other:
10//!
11//! > Does this affordance exist here?
12//!
13//! Like `makeover-layout` it emits nothing. It is a description, rendered to
14//! CSS by `makeover-webview` and to whatever the other renderers can express.
15//!
16//! # Why this is a crate and not a density preset
17//!
18//! Measured across the MNW server's `@media` blocks (137) and goingson's
19//! `ui-mode-*` blocks (192), bucketed by what the declarations inside actually
20//! change:
21//!
22//! | bucket | MNW | GO | retired by |
23//! |---|---|---|---|
24//! | density | 32% | 27% | a `makeover-geometry` preset |
25//! | type | 23% | 15% | the type scale |
26//! | columns | 20% | 12% | `makeover_layout::Column` |
27//! | reflow | 16% | 17% | `makeover_layout::Arrangement` |
28//! | **show/hide** | **12%** | **12%** | **this crate** |
29//! | **reposition** | **6%** | **20%** | **this crate** |
30//! | **appearance** | **1%** | **17%** | **this crate** |
31//!
32//! The bottom three are the roughly 43% that no spacing scale can retire, and
33//! trying is the mistake this whole family already made once. `display: none`
34//! on a keyboard hint says *the affordance does not exist on touch*. No amount
35//! of gap retuning expresses that, and a scale that tried would be smuggling a
36//! product claim onto a measurement axis — which is exactly what the 2026-07-29
37//! Touch demolition was for.
38//!
39//! # The two axes are borrowed, never redefined
40//!
41//! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's
42//! window size classes at 600 and 840 and carries [`Density`]; this crate names
43//! affordances *against* those two and adds no third axis, no fourth class and
44//! no breakpoint of its own. If a rule here wants a boundary that does not
45//! exist, that is a conversation with `makeover-geometry`, not a constant.
46//!
47//! # What density is allowed to gate
48//!
49//! Density is a claim about **the contact patch and nothing else**. So it gates
50//! affordances that depend on an interaction a fingertip cannot perform —
51//! hovering, and the keyboard chrome that documents shortcuts a touch surface
52//! has no way to send. It does not gate anything that is really about how much
53//! screen there is. A phone is small *and* touch; a tablet is big *and* touch.
54//!
55//! That separation is asserted, not merely intended, by
56//! `density_gates_only_what_the_contact_patch_touches`. Putting a screen-budget
57//! claim on the input device is the specific failure that produced this crate,
58//! and re-introducing it should have to come to the test and say so.
59//!
60//! # Collapsing is allowed, inverting is not
61//!
62//! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
63//! resolve to zero cells on a terminal and stay two members regardless. Two
64//! affordances here may have identical availability today — [`Affordance::Hover`]
65//! and [`Affordance::Hint`] do — and are still two members, because the call
66//! site names *what is being gated*, not the rule. What must never happen is
67//! one of them becoming available where the other is not for a reason that is
68//! really the same reason.
69//!
70//! # Deliberately absent
71//!
72//! **A navigation shell fork.** goingson currently carries two: 12 forked
73//! selectors and 10 desktop-only rules concentrated in `.app-header`, `.tab`,
74//! `.tab-navigation`, `.pill-nav`, `.saved-views-sidebar` and
75//! `.modal-container`. That is not one shell adapting, it is two shells, and
76//! choosing to build two is a product decision rather than an adaptation. This
77//! crate will not describe it, and goingson's own restructure is the way it
78//! stops being true. Named here the way `makeover-layout` names validation
79//! absent, so nobody has to discover it.
80//!
81//! **Which class applies.** The app decides, from a measured width via
82//! [`SizeClass::at_width`] and from whatever it already knows about the input.
83//! This crate takes both as arguments and never sniffs.
84//!
85//! **What a renderer does when an affordance is unavailable.** Hiding it,
86//! substituting it, or showing it unconditionally anyway is renderer policy.
87//! `makeover-layout` already deleted `Fill::fallback` for being exactly that.
88
89#![forbid(unsafe_code)]
90
91pub use makeover_geometry::{Density, SizeClass};
92pub use makeover_layout::Priority;
93
94/// An affordance whose existence depends on the surface it is offered on.
95///
96/// Six members, drawn from what the two measured apps already gate by hand
97/// rather than from a taxonomy. `makeover-layout`'s warning applies and is the
98/// reason for stopping here: guessing at eight is how a description becomes a
99/// framework.
100///
101/// Each answers [`Self::available`] against the two axes and nothing else. An
102/// affordance that is always available is not an affordance this crate has
103/// anything to say about, and `every_member_is_an_adaptation` asserts none has
104/// snuck in.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
106#[non_exhaustive]
107pub enum Affordance {
108    /// Anything a consumer reveals on hover: a row's action cluster, a
109    /// hover toolbar, a preview popover.
110    ///
111    /// Both webview apps arrived at hover-revealed row actions independently
112    /// (goingson `.task-row-action`, Balanced Breakfast `.row-actions`), which
113    /// is why `makeover-layout` records the reveal as behaviour of
114    /// `RowPart::Actions` rather than as app policy. What neither app can say
115    /// is that a fingertip has no hover state at all, so the affordance is not
116    /// hidden on touch — it does not exist there, and something else has to
117    /// carry the same actions.
118    Hover,
119    /// Chrome documenting a keyboard interaction: shortcut badges, key hints,
120    /// a "press / to search" line.
121    ///
122    /// goingson hides `.kbd-hint` on touch. Strictly this is a claim about
123    /// having a keyboard rather than about the contact patch, and [`Density`]
124    /// is the closest honest proxy the family carries. Stated rather than
125    /// hidden, because a detachable-keyboard tablet is where the proxy breaks
126    /// and a third axis is what fixing it would cost.
127    Hint,
128    /// A secondary panel standing beside the primary content: a saved-views
129    /// rail, a filter sidebar, an inspector.
130    ///
131    /// goingson hides `.saved-views-sidebar` below its widest layout. Purely a
132    /// screen-budget claim — a touchscreen laptop should keep it — so this
133    /// reads [`SizeClass`] alone.
134    Ancillary,
135    /// The detail half of a list-detail split, shown *alongside* the list
136    /// rather than navigated to.
137    ///
138    /// goingson's `.main-content` and Balanced Breakfast's `.detail-panel`.
139    /// Unavailable is not the same as absent: the detail still exists, it is
140    /// reached by navigation instead of by adjacency, and which of the two a
141    /// screen gets is what `makeover-layout`'s `Arrangement` is describing.
142    Detail,
143    /// Navigation or a primary action cluster pinned to a fixed screen edge
144    /// instead of sitting in the flow of the page.
145    ///
146    /// The reposition bucket, and the largest single one in goingson at 20%.
147    /// It exists to compensate for what a compact window cannot hold in flow,
148    /// so unlike the two above it is available at the *narrow* end and not the
149    /// wide one. That inversion is the point: an adaptation that only ever
150    /// removes things describes a degraded layout rather than a different one.
151    Anchored,
152    /// An action cluster collapsed behind one control rather than laid out
153    /// inline.
154    ///
155    /// The other compensating member. `makeover-layout`'s `Column::kept_at`
156    /// already handles a *table* narrowing by dropping columns; this is the
157    /// same pressure on a cluster of controls, which cannot drop any of them
158    /// and folds instead.
159    Overflow,
160}
161
162impl Affordance {
163    /// Whether this affordance exists on a surface with the given input class
164    /// and screen budget.
165    ///
166    /// The whole crate in one call. A renderer asks per affordance and never
167    /// branches on a width.
168    #[must_use]
169    pub const fn available(self, density: Density, size: SizeClass) -> bool {
170        match self {
171            // Contact patch. Screen budget has no opinion about either.
172            Self::Hover | Self::Hint => matches!(density, Density::Pointer),
173            // Screen budget. The input device has no opinion about any of them.
174            Self::Ancillary => matches!(size, SizeClass::Expanded),
175            Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
176            Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
177        }
178    }
179
180    /// Whether this affordance's availability reads [`Density`] at all.
181    ///
182    /// Exposed rather than kept private because it is the crate's own claim
183    /// about itself: exactly the members gating a contact-patch interaction say
184    /// yes. A renderer with one density can skip the rest entirely.
185    #[must_use]
186    pub const fn reads_density(self) -> bool {
187        matches!(self, Self::Hover | Self::Hint)
188    }
189
190    /// Whether this affordance's availability reads [`SizeClass`] at all.
191    #[must_use]
192    pub const fn reads_size(self) -> bool {
193        !self.reads_density()
194    }
195
196    /// Every member, in declaration order.
197    #[must_use]
198    pub const fn all() -> [Self; 6] {
199        [
200            Self::Hover,
201            Self::Hint,
202            Self::Ancillary,
203            Self::Detail,
204            Self::Anchored,
205            Self::Overflow,
206        ]
207    }
208
209    /// The CSS class name an app may hang off this, without the leading dot.
210    ///
211    /// Present for the same reason [`SizeClass::token`] is: a webview renderer
212    /// needs a stable name, and minting it per app is how two apps end up with
213    /// `has-hover` and `hover-capable`.
214    #[must_use]
215    pub const fn token(self) -> &'static str {
216        match self {
217            Self::Hover => "offers-hover",
218            Self::Hint => "offers-hint",
219            Self::Ancillary => "offers-ancillary",
220            Self::Detail => "offers-detail",
221            Self::Anchored => "offers-anchored",
222            Self::Overflow => "offers-overflow",
223        }
224    }
225}
226
227/// The column-drop cutoff a window of this size class asks for.
228///
229/// The seam between `makeover-layout` and `makeover-geometry` that neither
230/// crate could close. Layout defines the priority ladder and `Column::kept_at`;
231/// geometry defines the boundaries. Nothing said *which* cutoff a compact
232/// window uses, so both webview apps answered it with `nth-child` on an ordinal
233/// and inserting a column silently hid the wrong one.
234///
235/// A free function rather than an [`Affordance`] member because a column is not
236/// gated, it is ranked: the question is which cutoff to raise to, not whether
237/// the table exists.
238#[must_use]
239pub const fn column_cutoff(size: SizeClass) -> Priority {
240    match size {
241        // Only what identifies the row.
242        SizeClass::Compact => Priority::Essential,
243        // The optional columns go first.
244        SizeClass::Medium => Priority::Secondary,
245        // Everything survives.
246        SizeClass::Expanded => Priority::Optional,
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Every combination of the two axes, narrowest and coarsest first.
255    fn surfaces() -> Vec<(Density, SizeClass)> {
256        let mut out = Vec::new();
257        for d in [Density::Pointer, Density::Touch] {
258            for s in SizeClass::all() {
259                out.push((d, s));
260            }
261        }
262        out
263    }
264
265    #[test]
266    fn density_gates_only_what_the_contact_patch_touches() {
267        // The failure this crate exists to avoid: a screen-budget claim
268        // smuggled onto the input axis, which is what let the old Touch gap
269        // preset set a floor under a preset quoted from the HIG. Adding a
270        // density dependency to a screen-budget affordance has to come here
271        // and say so.
272        for a in Affordance::all() {
273            let varies_by_density = SizeClass::all()
274                .iter()
275                .any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s));
276            assert_eq!(
277                varies_by_density,
278                a.reads_density(),
279                "{a:?} disagrees with its own reads_density()"
280            );
281        }
282    }
283
284    #[test]
285    fn size_gates_only_what_screen_budget_touches() {
286        for a in Affordance::all() {
287            let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| {
288                SizeClass::all()
289                    .iter()
290                    .any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
291            });
292            assert_eq!(
293                varies_by_size,
294                a.reads_size(),
295                "{a:?} disagrees with its own reads_size()"
296            );
297        }
298    }
299
300    #[test]
301    fn no_member_reads_both_axes() {
302        // Not a law of adaptation, a statement about the six that exist. A
303        // seventh reading both is allowed, and this test is where the claim
304        // gets withdrawn rather than quietly falsified.
305        for a in Affordance::all() {
306            assert!(
307                a.reads_density() != a.reads_size(),
308                "{a:?} reads both axes; update this test and say why"
309            );
310        }
311    }
312
313    #[test]
314    fn every_member_is_an_adaptation() {
315        // A member available everywhere, or nowhere, is not describing an
316        // adaptation and does not belong in this crate.
317        for a in Affordance::all() {
318            let yes = surfaces()
319                .iter()
320                .filter(|&&(d, s)| a.available(d, s))
321                .count();
322            assert!(yes > 0, "{a:?} exists on no surface");
323            assert!(yes < surfaces().len(), "{a:?} exists on every surface");
324        }
325    }
326
327    #[test]
328    fn availability_is_contiguous_across_the_size_ladder() {
329        // No member may exist at compact and expanded but not medium. A hole
330        // in the middle is always an off-by-one, never a design.
331        for a in Affordance::all() {
332            for d in [Density::Pointer, Density::Touch] {
333                let run: Vec<bool> = SizeClass::all()
334                    .iter()
335                    .map(|&s| a.available(d, s))
336                    .collect();
337                let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
338                assert!(
339                    transitions <= 1,
340                    "{a:?} at {d:?} is available in a broken run: {run:?}"
341                );
342            }
343        }
344    }
345
346    #[test]
347    fn compact_compensates_rather_than_only_losing() {
348        // The reposition bucket is 20% of goingson's adaptation rules and the
349        // reason this crate is not just a hide-list. Whatever compact takes
350        // away, something has to give back.
351        for d in [Density::Pointer, Density::Touch] {
352            assert!(!Affordance::Detail.available(d, SizeClass::Compact));
353            assert!(Affordance::Anchored.available(d, SizeClass::Compact));
354        }
355    }
356
357    #[test]
358    fn hover_and_hint_collapse_and_that_is_allowed() {
359        // Borrowed from makeover-geometry, where bound and peer both resolve to
360        // zero cells on a terminal and stay two members. Identical rules are
361        // not a duplicate; the call site names what is gated.
362        for (d, s) in surfaces() {
363            assert_eq!(
364                Affordance::Hover.available(d, s),
365                Affordance::Hint.available(d, s)
366            );
367        }
368        assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
369    }
370
371    #[test]
372    fn touch_never_gains_an_affordance_pointer_lacks() {
373        // Direction matters, same as makeover-geometry's cross-density rule.
374        // Touch is derived from pointer by subtracting what a fingertip cannot
375        // do, so it can lose members and never gain one.
376        for s in SizeClass::all() {
377            for a in Affordance::all() {
378                if a.available(Density::Touch, s) {
379                    assert!(
380                        a.available(Density::Pointer, s),
381                        "{a:?} exists on touch at {s:?} but not on pointer"
382                    );
383                }
384            }
385        }
386    }
387
388    #[test]
389    fn tokens_are_distinct() {
390        let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
391        seen.sort_unstable();
392        let before = seen.len();
393        seen.dedup();
394        assert_eq!(seen.len(), before);
395    }
396
397    #[test]
398    fn the_column_cutoff_relaxes_as_the_window_widens() {
399        // Priority derives Ord with Optional lowest, so a narrower window is a
400        // higher cutoff. Asserted by comparison rather than by naming the three
401        // constants, so reordering the ladder in makeover-layout breaks here.
402        assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium));
403        assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded));
404    }
405
406    #[test]
407    fn the_column_cutoff_replaces_the_ordinal() {
408        // goingson's bug, written against this crate's answer: inserting a
409        // column must not change which column drops.
410        use makeover_layout::{Column, Priority as P, Width};
411
412        let before = [
413            Column {
414                name: "Title",
415                width: Width::Fill,
416                priority: P::Essential,
417            },
418            Column {
419                name: "Due",
420                width: Width::Fixed,
421                priority: P::Secondary,
422            },
423            Column {
424                name: "Estimate",
425                width: Width::Fixed,
426                priority: P::Optional,
427            },
428        ];
429        let after = [
430            Column {
431                name: "Title",
432                width: Width::Fill,
433                priority: P::Essential,
434            },
435            Column {
436                name: "Project",
437                width: Width::Fill,
438                priority: P::Secondary,
439            },
440            Column {
441                name: "Due",
442                width: Width::Fixed,
443                priority: P::Secondary,
444            },
445            Column {
446                name: "Estimate",
447                width: Width::Fixed,
448                priority: P::Optional,
449            },
450        ];
451
452        let cutoff = column_cutoff(SizeClass::Compact);
453        let kept: Vec<&str> = before
454            .iter()
455            .filter(|c| c.kept_at(cutoff))
456            .map(|c| c.name)
457            .collect();
458        assert_eq!(kept, ["Title"]);
459
460        let kept: Vec<&str> = after
461            .iter()
462            .filter(|c| c.kept_at(cutoff))
463            .map(|c| c.name)
464            .collect();
465        assert_eq!(kept, ["Title"]);
466    }
467
468    #[test]
469    fn the_axes_are_borrowed_not_redefined() {
470        // Re-exported rather than mirrored, so there is exactly one definition
471        // of each in the family. A local copy is how two crates start
472        // disagreeing about where 600px is.
473        assert_eq!(SizeClass::Medium.min_px(), 600);
474        assert_eq!(SizeClass::Expanded.min_px(), 840);
475        assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
476    }
477}