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//! Two of the eight members are not in that census at all, and the exception is
40//! worth stating rather than leaving to be noticed. [`Affordance::Gesture`] and
41//! [`Affordance::Haptic`] adapt *behaviour*, which no stylesheet contains, so
42//! counting `@media` blocks could never have found them. Their evidence has the
43//! same shape in a different file: goingson `frontend/js/touch.js:12` and
44//! Balanced Breakfast `frontend/js/touch.js:12` carry the identical line
45//!
46//! ```js
47//! const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
48//! ```
49//!
50//! and each hangs five gestures off it. Two apps, arrived at independently,
51//! hand-rolling the density question this crate exists to answer.
52//!
53//! # The two axes are borrowed, never redefined
54//!
55//! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's
56//! window size classes at 600 and 840 and carries [`Density`]; this crate names
57//! affordances *against* those two and adds no third axis, no fourth class and
58//! no breakpoint of its own. If a rule here wants a boundary that does not
59//! exist, that is a conversation with `makeover-geometry`, not a constant.
60//!
61//! # What density is allowed to gate
62//!
63//! Density is a claim about **the contact patch and nothing else**. So it gates
64//! affordances that depend on an interaction a fingertip cannot perform —
65//! hovering, and the keyboard chrome that documents shortcuts a touch surface
66//! has no way to send. It does not gate anything that is really about how much
67//! screen there is. A phone is small *and* touch; a tablet is big *and* touch.
68//!
69//! That separation is asserted, not merely intended, by
70//! `density_gates_only_what_the_contact_patch_touches`. Putting a screen-budget
71//! claim on the input device is the specific failure that produced this crate,
72//! and re-introducing it should have to come to the test and say so.
73//!
74//! # Both densities gain something (0.3.0)
75//!
76//! Until 0.3.0 this crate asserted that touch is pointer minus what a fingertip
77//! cannot do — availability only ever subtracted as you moved from
78//! [`Density::Pointer`] to [`Density::Touch`], and a test called
79//! `touch_never_gains_an_affordance_pointer_lacks` said so. That claim is
80//! withdrawn, deliberately, because it left a hole the crate could not name:
81//! [`Affordance::Hover`]'s own doc says a fingertip has no hover state *and that
82//! something else has to carry the same actions*, and nothing here was allowed
83//! to be that something. [`Affordance::Anchored`] and [`Affordance::Overflow`]
84//! compensate on the size axis; nothing compensated on the density axis.
85//!
86//! [`Affordance::Gesture`] and [`Affordance::Haptic`] are that compensation, and
87//! both are gained by touch rather than lost to it.
88//!
89//! The claim is replaced rather than dropped. [`Affordance::gained_by`] makes
90//! each member declare which density it belongs to, and
91//! `a_density_member_is_available_on_exactly_the_density_it_declares` checks the
92//! declaration against the rule. So a new member still cannot quietly invert:
93//! it has to say which way it goes, in code, and the test is where a wrong
94//! answer surfaces.
95//!
96//! # Collapsing is allowed, inverting is not
97//!
98//! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
99//! resolve to zero cells on a terminal and stay two members regardless. Two
100//! affordances here may have identical availability today — [`Affordance::Hover`]
101//! and [`Affordance::Hint`] do — and are still two members, because the call
102//! site names *what is being gated*, not the rule. What must never happen is
103//! one of them becoming available where the other is not for a reason that is
104//! really the same reason.
105//!
106//! # Deliberately absent
107//!
108//! **A navigation shell fork.** goingson currently carries two: 12 forked
109//! selectors and 10 desktop-only rules concentrated in `.app-header`, `.tab`,
110//! `.tab-navigation`, `.pill-nav`, `.saved-views-sidebar` and
111//! `.modal-container`. That is not one shell adapting, it is two shells, and
112//! choosing to build two is a product decision rather than an adaptation. This
113//! crate will not describe it, and goingson's own restructure is the way it
114//! stops being true. Named here the way `makeover-layout` names validation
115//! absent, so nobody has to discover it.
116//!
117//! **Which class applies.** The app decides, from a measured width via
118//! [`SizeClass::at_width`] and from whatever it already knows about the input.
119//! This crate takes both as arguments and never sniffs.
120//!
121//! **What a renderer does when an affordance is unavailable.** Hiding it,
122//! substituting it, or showing it unconditionally anyway is renderer policy.
123//! `makeover-layout` already deleted `Fill::fallback` for being exactly that.
124
125#![forbid(unsafe_code)]
126
127pub use makeover_geometry::{Density, SizeClass};
128pub use makeover_layout::Priority;
129
130/// An affordance whose existence depends on the surface it is offered on.
131///
132/// Eight members, drawn from what the two measured apps already gate by hand
133/// rather than from a taxonomy. `makeover-layout`'s warning applies and is the
134/// reason each addition has to point at call sites rather than at a category:
135/// guessing is how a description becomes a framework.
136///
137/// Each answers [`Self::available`] against the two axes and nothing else. An
138/// affordance that is always available is not an affordance this crate has
139/// anything to say about, and `every_member_is_an_adaptation` asserts none has
140/// snuck in.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142#[non_exhaustive]
143pub enum Affordance {
144    /// Anything a consumer reveals on hover: a row's action cluster, a
145    /// hover toolbar, a preview popover.
146    ///
147    /// Both webview apps arrived at hover-revealed row actions independently
148    /// (goingson `.task-row-action`, Balanced Breakfast `.row-actions`), which
149    /// is why `makeover-layout` records the reveal as behaviour of
150    /// `RowPart::Actions` rather than as app policy. What neither app can say
151    /// is that a fingertip has no hover state at all, so the affordance is not
152    /// hidden on touch — it does not exist there, and something else has to
153    /// carry the same actions.
154    Hover,
155    /// Chrome documenting a keyboard interaction: shortcut badges, key hints,
156    /// a "press / to search" line.
157    ///
158    /// goingson hides `.kbd-hint` on touch. Strictly this is a claim about
159    /// having a keyboard rather than about the contact patch, and [`Density`]
160    /// is the closest honest proxy the family carries. Stated rather than
161    /// hidden, because a detachable-keyboard tablet is where the proxy breaks
162    /// and a third axis is what fixing it would cost.
163    Hint,
164    /// A secondary panel standing beside the primary content: a saved-views
165    /// rail, a filter sidebar, an inspector.
166    ///
167    /// goingson hides `.saved-views-sidebar` below its widest layout. Purely a
168    /// screen-budget claim — a touchscreen laptop should keep it — so this
169    /// reads [`SizeClass`] alone.
170    Ancillary,
171    /// The detail half of a list-detail split, shown *alongside* the list
172    /// rather than navigated to.
173    ///
174    /// goingson's `.main-content` and Balanced Breakfast's `.detail-panel`.
175    /// Unavailable is not the same as absent: the detail still exists, it is
176    /// reached by navigation instead of by adjacency, and which of the two a
177    /// screen gets is what `makeover-layout`'s `Arrangement` is describing.
178    Detail,
179    /// Navigation or a primary action cluster pinned to a fixed screen edge
180    /// instead of sitting in the flow of the page.
181    ///
182    /// The reposition bucket, and the largest single one in goingson at 20%.
183    /// It exists to compensate for what a compact window cannot hold in flow,
184    /// so unlike the two above it is available at the *narrow* end and not the
185    /// wide one. That inversion is the point: an adaptation that only ever
186    /// removes things describes a degraded layout rather than a different one.
187    Anchored,
188    /// An action cluster collapsed behind one control rather than laid out
189    /// inline.
190    ///
191    /// The other compensating member. `makeover-layout`'s `Column::kept_at`
192    /// already handles a *table* narrowing by dropping columns; this is the
193    /// same pressure on a cluster of controls, which cannot drop any of them
194    /// and folds instead.
195    Overflow,
196    /// A direct-manipulation gesture on content: swipe-to-action, long-press to
197    /// select, pull to refresh, swipe to navigate, drag to dismiss.
198    ///
199    /// The density axis's compensating member, and the answer to the question
200    /// [`Self::Hover`] asks and cannot answer. A fingertip has no hover state,
201    /// so the row actions a pointer reveals by hovering have to arrive some
202    /// other way; on both webview apps that way is a swipe.
203    ///
204    /// **One member for five gestures, on purpose.** goingson and Balanced
205    /// Breakfast each gate all five behind a single boolean, so one member is
206    /// what is measured and five would be minted from one fact. If a surface
207    /// ever offers swipe without long-press, splitting this is additive and the
208    /// call sites that named `Gesture` keep meaning what they meant.
209    Gesture,
210    /// Confirmation delivered through the contact patch rather than the eye: the
211    /// tick as a drag crosses a threshold, the thump as a gesture fires.
212    ///
213    /// A contact-patch claim, which is what makes it this crate's business
214    /// despite being the one member nothing on screen shows. goingson
215    /// `frontend/js/haptics.js`.
216    ///
217    /// The proxy breaks where the hardware has haptics and the user or the OS
218    /// has switched them off. That is neither [`Density`] nor [`SizeClass`], and
219    /// it is stated here rather than fixed for the same reason [`Self::Hint`]
220    /// states the detachable-keyboard case: a third axis is what fixing it would
221    /// cost. **This member says the surface can, never that the user wants.**
222    /// Asking the platform whether haptics are enabled is the renderer's job.
223    Haptic,
224}
225
226impl Affordance {
227    /// Whether this affordance exists on a surface with the given input class
228    /// and screen budget.
229    ///
230    /// The whole crate in one call. A renderer asks per affordance and never
231    /// branches on a width.
232    #[must_use]
233    pub const fn available(self, density: Density, size: SizeClass) -> bool {
234        match self {
235            // Contact patch, lost to a fingertip.
236            Self::Hover | Self::Hint => matches!(density, Density::Pointer),
237            // Contact patch, gained by one. A mouse can neither swipe a row nor
238            // feel a confirmation, and a phone-sized window has nothing to do
239            // with either.
240            Self::Gesture | Self::Haptic => matches!(density, Density::Touch),
241            // Screen budget. The input device has no opinion about any of them.
242            Self::Ancillary => matches!(size, SizeClass::Expanded),
243            Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
244            Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
245        }
246    }
247
248    /// Which [`Density`] this affordance belongs to, or `None` when it reads the
249    /// screen budget instead.
250    ///
251    /// The replacement for the withdrawn one-directional rule (see the crate
252    /// doc). Until 0.3.0 the direction was a property of the whole crate and a
253    /// test enforced it globally; now each member declares its own, and
254    /// `a_density_member_is_available_on_exactly_the_density_it_declares` holds
255    /// the declaration to the rule.
256    ///
257    /// Exposed rather than kept private for the same reason [`Self::reads_density`]
258    /// is: it is the crate's claim about itself, and a renderer that has one
259    /// density can read it directly instead of probing [`Self::available`] twice.
260    #[must_use]
261    pub const fn gained_by(self) -> Option<Density> {
262        match self {
263            Self::Hover | Self::Hint => Some(Density::Pointer),
264            Self::Gesture | Self::Haptic => Some(Density::Touch),
265            Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None,
266        }
267    }
268
269    /// Whether this affordance's availability reads [`Density`] at all.
270    ///
271    /// Exposed rather than kept private because it is the crate's own claim
272    /// about itself: exactly the members gating a contact-patch interaction say
273    /// yes. A renderer with one density can skip the rest entirely.
274    #[must_use]
275    pub const fn reads_density(self) -> bool {
276        self.gained_by().is_some()
277    }
278
279    /// Whether this affordance's availability reads [`SizeClass`] at all.
280    #[must_use]
281    pub const fn reads_size(self) -> bool {
282        !self.reads_density()
283    }
284
285    /// Every member, in declaration order.
286    #[must_use]
287    pub const fn all() -> [Self; 8] {
288        [
289            Self::Hover,
290            Self::Hint,
291            Self::Ancillary,
292            Self::Detail,
293            Self::Anchored,
294            Self::Overflow,
295            Self::Gesture,
296            Self::Haptic,
297        ]
298    }
299
300    /// The CSS class name an app may hang off this, without the leading dot.
301    ///
302    /// Present for the same reason [`SizeClass::token`] is: a webview renderer
303    /// needs a stable name, and minting it per app is how two apps end up with
304    /// `has-hover` and `hover-capable`.
305    #[must_use]
306    pub const fn token(self) -> &'static str {
307        match self {
308            Self::Hover => "offers-hover",
309            Self::Hint => "offers-hint",
310            Self::Ancillary => "offers-ancillary",
311            Self::Detail => "offers-detail",
312            Self::Anchored => "offers-anchored",
313            Self::Overflow => "offers-overflow",
314            Self::Gesture => "offers-gesture",
315            Self::Haptic => "offers-haptic",
316        }
317    }
318}
319
320/// The column-drop cutoff a window of this size class asks for.
321///
322/// The seam between `makeover-layout` and `makeover-geometry` that neither
323/// crate could close. Layout defines the priority ladder and `Column::kept_at`;
324/// geometry defines the boundaries. Nothing said *which* cutoff a compact
325/// window uses, so both webview apps answered it with `nth-child` on an ordinal
326/// and inserting a column silently hid the wrong one.
327///
328/// A free function rather than an [`Affordance`] member because a column is not
329/// gated, it is ranked: the question is which cutoff to raise to, not whether
330/// the table exists.
331#[must_use]
332pub const fn column_cutoff(size: SizeClass) -> Priority {
333    match size {
334        // Only what identifies the row.
335        SizeClass::Compact => Priority::Essential,
336        // The optional columns go first.
337        SizeClass::Medium => Priority::Secondary,
338        // Everything survives.
339        SizeClass::Expanded => Priority::Optional,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    /// Every combination of the two axes, narrowest and coarsest first.
348    fn surfaces() -> Vec<(Density, SizeClass)> {
349        let mut out = Vec::new();
350        for d in [Density::Pointer, Density::Touch] {
351            for s in SizeClass::all() {
352                out.push((d, s));
353            }
354        }
355        out
356    }
357
358    #[test]
359    fn density_gates_only_what_the_contact_patch_touches() {
360        // The failure this crate exists to avoid: a screen-budget claim
361        // smuggled onto the input axis, which is what let the old Touch gap
362        // preset set a floor under a preset quoted from the HIG. Adding a
363        // density dependency to a screen-budget affordance has to come here
364        // and say so.
365        for a in Affordance::all() {
366            let varies_by_density = SizeClass::all()
367                .iter()
368                .any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s));
369            assert_eq!(
370                varies_by_density,
371                a.reads_density(),
372                "{a:?} disagrees with its own reads_density()"
373            );
374        }
375    }
376
377    #[test]
378    fn size_gates_only_what_screen_budget_touches() {
379        for a in Affordance::all() {
380            let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| {
381                SizeClass::all()
382                    .iter()
383                    .any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
384            });
385            assert_eq!(
386                varies_by_size,
387                a.reads_size(),
388                "{a:?} disagrees with its own reads_size()"
389            );
390        }
391    }
392
393    #[test]
394    fn no_member_reads_both_axes() {
395        // Not a law of adaptation, a statement about the six that exist. A
396        // seventh reading both is allowed, and this test is where the claim
397        // gets withdrawn rather than quietly falsified.
398        for a in Affordance::all() {
399            assert!(
400                a.reads_density() != a.reads_size(),
401                "{a:?} reads both axes; update this test and say why"
402            );
403        }
404    }
405
406    #[test]
407    fn every_member_is_an_adaptation() {
408        // A member available everywhere, or nowhere, is not describing an
409        // adaptation and does not belong in this crate.
410        for a in Affordance::all() {
411            let yes = surfaces()
412                .iter()
413                .filter(|&&(d, s)| a.available(d, s))
414                .count();
415            assert!(yes > 0, "{a:?} exists on no surface");
416            assert!(yes < surfaces().len(), "{a:?} exists on every surface");
417        }
418    }
419
420    #[test]
421    fn availability_is_contiguous_across_the_size_ladder() {
422        // No member may exist at compact and expanded but not medium. A hole
423        // in the middle is always an off-by-one, never a design.
424        for a in Affordance::all() {
425            for d in [Density::Pointer, Density::Touch] {
426                let run: Vec<bool> = SizeClass::all()
427                    .iter()
428                    .map(|&s| a.available(d, s))
429                    .collect();
430                let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
431                assert!(
432                    transitions <= 1,
433                    "{a:?} at {d:?} is available in a broken run: {run:?}"
434                );
435            }
436        }
437    }
438
439    #[test]
440    fn compact_compensates_rather_than_only_losing() {
441        // The reposition bucket is 20% of goingson's adaptation rules and the
442        // reason this crate is not just a hide-list. Whatever compact takes
443        // away, something has to give back.
444        for d in [Density::Pointer, Density::Touch] {
445            assert!(!Affordance::Detail.available(d, SizeClass::Compact));
446            assert!(Affordance::Anchored.available(d, SizeClass::Compact));
447        }
448    }
449
450    #[test]
451    fn hover_and_hint_collapse_and_that_is_allowed() {
452        // Borrowed from makeover-geometry, where bound and peer both resolve to
453        // zero cells on a terminal and stay two members. Identical rules are
454        // not a duplicate; the call site names what is gated.
455        for (d, s) in surfaces() {
456            assert_eq!(
457                Affordance::Hover.available(d, s),
458                Affordance::Hint.available(d, s)
459            );
460        }
461        assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
462    }
463
464    /// The other density. Two members, so this is total, and writing it here
465    /// rather than in `makeover-geometry` keeps the axis definition borrowed
466    /// rather than extended.
467    fn opposite(d: Density) -> Density {
468        match d {
469            Density::Pointer => Density::Touch,
470            Density::Touch => Density::Pointer,
471        }
472    }
473
474    #[test]
475    fn a_density_member_is_available_on_exactly_the_density_it_declares() {
476        // Replaces `touch_never_gains_an_affordance_pointer_lacks`, withdrawn in
477        // 0.3.0. That test made direction a property of the whole crate: touch
478        // was pointer minus what a fingertip cannot do, and could never add.
479        // Gesture and Haptic add, so the global claim had to go.
480        //
481        // What survives is the part worth keeping. A member still cannot invert
482        // quietly -- it declares its density in `gained_by`, and this is where a
483        // declaration that disagrees with the rule shows up. The old test's job
484        // was to make a direction change deliberate; so is this one's.
485        for a in Affordance::all() {
486            match a.gained_by() {
487                Some(gained) => {
488                    assert!(
489                        a.reads_density(),
490                        "{a:?} declares a density but denies reading one"
491                    );
492                    for s in SizeClass::all() {
493                        assert!(
494                            a.available(gained, s),
495                            "{a:?} declares {gained:?} but is unavailable there at {s:?}"
496                        );
497                        assert!(
498                            !a.available(opposite(gained), s),
499                            "{a:?} declares {gained:?} but is also available on the other density at {s:?}"
500                        );
501                    }
502                }
503                None => assert!(
504                    !a.reads_density(),
505                    "{a:?} reads density but declares no side"
506                ),
507            }
508        }
509    }
510
511    #[test]
512    fn both_densities_gain_something() {
513        // The withdrawn rule, inverted into a statement of what replaced it. A
514        // crate where only pointer gains members is the one this stopped being,
515        // and if a refactor ever takes the touch-gained members back out, the
516        // honest move is to restore the old one-directional test rather than
517        // let this one quietly pass on an empty set.
518        for d in [Density::Pointer, Density::Touch] {
519            assert!(
520                Affordance::all().iter().any(|a| a.gained_by() == Some(d)),
521                "no member is gained by {d:?}"
522            );
523        }
524    }
525
526    #[test]
527    fn the_density_axis_compensates_rather_than_only_losing() {
528        // The density-axis twin of `compact_compensates_rather_than_only_losing`,
529        // and the hole that motivated 0.3.0: Hover's own doc says something else
530        // has to carry the actions a fingertip cannot hover to reveal, and until
531        // Gesture existed nothing here could be that something.
532        for s in SizeClass::all() {
533            assert!(!Affordance::Hover.available(Density::Touch, s));
534            assert!(Affordance::Gesture.available(Density::Touch, s));
535        }
536    }
537
538    #[test]
539    fn tokens_are_distinct() {
540        let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
541        seen.sort_unstable();
542        let before = seen.len();
543        seen.dedup();
544        assert_eq!(seen.len(), before);
545    }
546
547    #[test]
548    fn the_column_cutoff_relaxes_as_the_window_widens() {
549        // Priority derives Ord with Optional lowest, so a narrower window is a
550        // higher cutoff. Asserted by comparison rather than by naming the three
551        // constants, so reordering the ladder in makeover-layout breaks here.
552        assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium));
553        assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded));
554    }
555
556    #[test]
557    fn the_column_cutoff_replaces_the_ordinal() {
558        // goingson's bug, written against this crate's answer: inserting a
559        // column must not change which column drops.
560        use makeover_layout::{Column, Priority as P, Width};
561
562        let before = [
563            Column {
564                width: Width::Fill,
565                priority: P::Essential,
566                ..Column::new("Title")
567            },
568            Column {
569                width: Width::Fixed,
570                priority: P::Secondary,
571                ..Column::new("Due")
572            },
573            Column {
574                width: Width::Fixed,
575                priority: P::Optional,
576                ..Column::new("Estimate")
577            },
578        ];
579        let after = [
580            Column {
581                width: Width::Fill,
582                priority: P::Essential,
583                ..Column::new("Title")
584            },
585            Column {
586                width: Width::Fill,
587                priority: P::Secondary,
588                ..Column::new("Project")
589            },
590            Column {
591                width: Width::Fixed,
592                priority: P::Secondary,
593                ..Column::new("Due")
594            },
595            Column {
596                width: Width::Fixed,
597                priority: P::Optional,
598                ..Column::new("Estimate")
599            },
600        ];
601
602        let cutoff = column_cutoff(SizeClass::Compact);
603        let kept: Vec<&str> = before
604            .iter()
605            .filter(|c| c.kept_at(cutoff))
606            .map(|c| c.name)
607            .collect();
608        assert_eq!(kept, ["Title"]);
609
610        let kept: Vec<&str> = after
611            .iter()
612            .filter(|c| c.kept_at(cutoff))
613            .map(|c| c.name)
614            .collect();
615        assert_eq!(kept, ["Title"]);
616    }
617
618    #[test]
619    fn the_axes_are_borrowed_not_redefined() {
620        // Re-exported rather than mirrored, so there is exactly one definition
621        // of each in the family. A local copy is how two crates start
622        // disagreeing about where 600px is.
623        assert_eq!(SizeClass::Medium.min_px(), 600);
624        assert_eq!(SizeClass::Expanded.min_px(), 840);
625        assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
626    }
627}