Skip to main content

max / makeover-touch

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