Skip to main content

max / makeover-touch

26.2 KB · 620 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 has the
41 //! same shape in a different file: goingson `frontend/js/touch.js:12` and
42 //! Balanced Breakfast `frontend/js/touch.js:12` carry the identical line
43 //!
44 //! ```js
45 //! const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
46 //! ```
47 //!
48 //! and each hangs five gestures off it. Two apps, arrived at independently,
49 //! hand-rolling the density question this crate exists to answer.
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. goingson
208 /// `frontend/js/haptics.js`.
209 ///
210 /// The proxy breaks where the hardware has haptics and the user or the OS
211 /// has switched them off. That is neither [`Density`] nor [`SizeClass`], and
212 /// it is stated here rather than fixed for the same reason [`Self::Hint`]
213 /// states the detachable-keyboard case: a third axis is what fixing it would
214 /// cost. **This member says the surface can, never that the user wants.**
215 /// Asking the platform whether haptics are enabled is the renderer's job.
216 Haptic,
217 }
218
219 impl Affordance {
220 /// Whether this affordance exists on a surface with the given input class
221 /// and screen budget.
222 ///
223 /// The whole crate in one call. A renderer asks per affordance and never
224 /// branches on a width.
225 #[must_use]
226 pub const fn available(self, density: Density, size: SizeClass) -> bool {
227 match self {
228 // Contact patch, lost to a fingertip.
229 Self::Hover | Self::Hint => matches!(density, Density::Pointer),
230 // Contact patch, gained by one. A mouse can neither swipe a row nor
231 // feel a confirmation, and a phone-sized window has nothing to do
232 // with either.
233 Self::Gesture | Self::Haptic => matches!(density, Density::Touch),
234 // Screen budget. The input device has no opinion about any of them.
235 Self::Ancillary => matches!(size, SizeClass::Expanded),
236 Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
237 Self::Anchored | Self::Overflow => matches!(size, SizeClass::Compact),
238 }
239 }
240
241 /// Which [`Density`] this affordance belongs to, or `None` when it reads the
242 /// screen budget instead.
243 ///
244 /// Each member declares its own direction, and
245 /// `a_density_member_is_available_on_exactly_the_density_it_declares` holds
246 /// the declaration to the rule. There is no crate-wide one-directional
247 /// rule: see the crate doc.
248 ///
249 /// Exposed rather than kept private for the same reason [`Self::reads_density`]
250 /// is: it is the crate's claim about itself, and a renderer that has one
251 /// density can read it directly instead of probing [`Self::available`] twice.
252 #[must_use]
253 pub const fn gained_by(self) -> Option<Density> {
254 match self {
255 Self::Hover | Self::Hint => Some(Density::Pointer),
256 Self::Gesture | Self::Haptic => Some(Density::Touch),
257 Self::Ancillary | Self::Detail | Self::Anchored | Self::Overflow => None,
258 }
259 }
260
261 /// Whether this affordance's availability reads [`Density`] at all.
262 ///
263 /// Exposed rather than kept private because it is the crate's own claim
264 /// about itself: exactly the members gating a contact-patch interaction say
265 /// yes. A renderer with one density can skip the rest entirely.
266 #[must_use]
267 pub const fn reads_density(self) -> bool {
268 self.gained_by().is_some()
269 }
270
271 /// Whether this affordance's availability reads [`SizeClass`] at all.
272 #[must_use]
273 pub const fn reads_size(self) -> bool {
274 !self.reads_density()
275 }
276
277 /// Every member, in declaration order.
278 #[must_use]
279 pub const fn all() -> [Self; 8] {
280 [
281 Self::Hover,
282 Self::Hint,
283 Self::Ancillary,
284 Self::Detail,
285 Self::Anchored,
286 Self::Overflow,
287 Self::Gesture,
288 Self::Haptic,
289 ]
290 }
291
292 /// The CSS class name an app may hang off this, without the leading dot.
293 ///
294 /// Present for the same reason [`SizeClass::token`] is: a webview renderer
295 /// needs a stable name, and minting it per app is how two apps end up with
296 /// `has-hover` and `hover-capable`.
297 #[must_use]
298 pub const fn token(self) -> &'static str {
299 match self {
300 Self::Hover => "offers-hover",
301 Self::Hint => "offers-hint",
302 Self::Ancillary => "offers-ancillary",
303 Self::Detail => "offers-detail",
304 Self::Anchored => "offers-anchored",
305 Self::Overflow => "offers-overflow",
306 Self::Gesture => "offers-gesture",
307 Self::Haptic => "offers-haptic",
308 }
309 }
310 }
311
312 /// The column-drop cutoff a window of this size class asks for.
313 ///
314 /// The seam between `makeover-layout` and `makeover-geometry` that neither
315 /// crate could close. Layout defines the priority ladder and `Column::kept_at`;
316 /// geometry defines the boundaries. Nothing said *which* cutoff a compact
317 /// window uses, so both webview apps answered it with `nth-child` on an ordinal
318 /// and inserting a column silently hid the wrong one.
319 ///
320 /// A free function rather than an [`Affordance`] member because a column is not
321 /// gated, it is ranked: the question is which cutoff to raise to, not whether
322 /// the table exists.
323 #[must_use]
324 pub const fn column_cutoff(size: SizeClass) -> Priority {
325 match size {
326 // Only what identifies the row.
327 SizeClass::Compact => Priority::Essential,
328 // The optional columns go first.
329 SizeClass::Medium => Priority::Secondary,
330 // Everything survives.
331 SizeClass::Expanded => Priority::Optional,
332 }
333 }
334
335 #[cfg(test)]
336 mod tests {
337 use super::*;
338
339 /// Every combination of the two axes, narrowest and coarsest first.
340 fn surfaces() -> Vec<(Density, SizeClass)> {
341 let mut out = Vec::new();
342 for d in [Density::Pointer, Density::Touch] {
343 for s in SizeClass::all() {
344 out.push((d, s));
345 }
346 }
347 out
348 }
349
350 #[test]
351 fn density_gates_only_what_the_contact_patch_touches() {
352 // The failure this crate exists to avoid: a screen-budget claim
353 // smuggled onto the input axis, which is what let the old Touch gap
354 // preset set a floor under a preset quoted from the HIG. Adding a
355 // density dependency to a screen-budget affordance has to come here
356 // and say so.
357 for a in Affordance::all() {
358 let varies_by_density = SizeClass::all()
359 .iter()
360 .any(|&s| a.available(Density::Pointer, s) != a.available(Density::Touch, s));
361 assert_eq!(
362 varies_by_density,
363 a.reads_density(),
364 "{a:?} disagrees with its own reads_density()"
365 );
366 }
367 }
368
369 #[test]
370 fn size_gates_only_what_screen_budget_touches() {
371 for a in Affordance::all() {
372 let varies_by_size = [Density::Pointer, Density::Touch].iter().any(|&d| {
373 SizeClass::all()
374 .iter()
375 .any(|&s| a.available(d, s) != a.available(d, SizeClass::Compact))
376 });
377 assert_eq!(
378 varies_by_size,
379 a.reads_size(),
380 "{a:?} disagrees with its own reads_size()"
381 );
382 }
383 }
384
385 #[test]
386 fn no_member_reads_both_axes() {
387 // Not a law of adaptation, a statement about the six that exist. A
388 // seventh reading both is allowed, and this test is where the claim
389 // gets withdrawn rather than quietly falsified.
390 for a in Affordance::all() {
391 assert!(
392 a.reads_density() != a.reads_size(),
393 "{a:?} reads both axes; update this test and say why"
394 );
395 }
396 }
397
398 #[test]
399 fn every_member_is_an_adaptation() {
400 // A member available everywhere, or nowhere, is not describing an
401 // adaptation and does not belong in this crate.
402 for a in Affordance::all() {
403 let yes = surfaces()
404 .iter()
405 .filter(|&&(d, s)| a.available(d, s))
406 .count();
407 assert!(yes > 0, "{a:?} exists on no surface");
408 assert!(yes < surfaces().len(), "{a:?} exists on every surface");
409 }
410 }
411
412 #[test]
413 fn availability_is_contiguous_across_the_size_ladder() {
414 // No member may exist at compact and expanded but not medium. A hole
415 // in the middle is always an off-by-one, never a design.
416 for a in Affordance::all() {
417 for d in [Density::Pointer, Density::Touch] {
418 let run: Vec<bool> = SizeClass::all()
419 .iter()
420 .map(|&s| a.available(d, s))
421 .collect();
422 let transitions = run.windows(2).filter(|w| w[0] != w[1]).count();
423 assert!(
424 transitions <= 1,
425 "{a:?} at {d:?} is available in a broken run: {run:?}"
426 );
427 }
428 }
429 }
430
431 #[test]
432 fn compact_compensates_rather_than_only_losing() {
433 // The reposition bucket is 20% of goingson's adaptation rules and the
434 // reason this crate is not just a hide-list. Whatever compact takes
435 // away, something has to give back.
436 for d in [Density::Pointer, Density::Touch] {
437 assert!(!Affordance::Detail.available(d, SizeClass::Compact));
438 assert!(Affordance::Anchored.available(d, SizeClass::Compact));
439 }
440 }
441
442 #[test]
443 fn hover_and_hint_collapse_and_that_is_allowed() {
444 // Borrowed from makeover-geometry, where bound and peer both resolve to
445 // zero cells on a terminal and stay two members. Identical rules are
446 // not a duplicate; the call site names what is gated.
447 for (d, s) in surfaces() {
448 assert_eq!(
449 Affordance::Hover.available(d, s),
450 Affordance::Hint.available(d, s)
451 );
452 }
453 assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
454 }
455
456 /// The other density. Two members, so this is total, and writing it here
457 /// rather than in `makeover-geometry` keeps the axis definition borrowed
458 /// rather than extended.
459 fn opposite(d: Density) -> Density {
460 match d {
461 Density::Pointer => Density::Touch,
462 Density::Touch => Density::Pointer,
463 }
464 }
465
466 #[test]
467 fn a_density_member_is_available_on_exactly_the_density_it_declares() {
468 // Replaces `touch_never_gains_an_affordance_pointer_lacks`, withdrawn in
469 // 0.3.0. That test made direction a property of the whole crate: touch
470 // was pointer minus what a fingertip cannot do, and could never add.
471 // Gesture and Haptic add, so the global claim had to go.
472 //
473 // What survives is the part worth keeping. A member still cannot invert
474 // quietly -- it declares its density in `gained_by`, and this is where a
475 // declaration that disagrees with the rule shows up. The old test's job
476 // was to make a direction change deliberate; so is this one's.
477 for a in Affordance::all() {
478 match a.gained_by() {
479 Some(gained) => {
480 assert!(
481 a.reads_density(),
482 "{a:?} declares a density but denies reading one"
483 );
484 for s in SizeClass::all() {
485 assert!(
486 a.available(gained, s),
487 "{a:?} declares {gained:?} but is unavailable there at {s:?}"
488 );
489 assert!(
490 !a.available(opposite(gained), s),
491 "{a:?} declares {gained:?} but is also available on the other density at {s:?}"
492 );
493 }
494 }
495 None => assert!(
496 !a.reads_density(),
497 "{a:?} reads density but declares no side"
498 ),
499 }
500 }
501 }
502
503 #[test]
504 fn both_densities_gain_something() {
505 // The withdrawn rule, inverted into a statement of what replaced it. A
506 // crate where only pointer gains members is the one this stopped being,
507 // and if a refactor ever takes the touch-gained members back out, the
508 // honest move is to restore the old one-directional test rather than
509 // let this one quietly pass on an empty set.
510 for d in [Density::Pointer, Density::Touch] {
511 assert!(
512 Affordance::all().iter().any(|a| a.gained_by() == Some(d)),
513 "no member is gained by {d:?}"
514 );
515 }
516 }
517
518 #[test]
519 fn the_density_axis_compensates_rather_than_only_losing() {
520 // The density-axis twin of `compact_compensates_rather_than_only_losing`,
521 // and the hole that motivated 0.3.0: Hover's own doc says something else
522 // has to carry the actions a fingertip cannot hover to reveal, and until
523 // Gesture existed nothing here could be that something.
524 for s in SizeClass::all() {
525 assert!(!Affordance::Hover.available(Density::Touch, s));
526 assert!(Affordance::Gesture.available(Density::Touch, s));
527 }
528 }
529
530 #[test]
531 fn tokens_are_distinct() {
532 let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();
533 seen.sort_unstable();
534 let before = seen.len();
535 seen.dedup();
536 assert_eq!(seen.len(), before);
537 }
538
539 #[test]
540 fn the_column_cutoff_relaxes_as_the_window_widens() {
541 // Priority derives Ord with Optional lowest, so a narrower window is a
542 // higher cutoff. Asserted by comparison rather than by naming the three
543 // constants, so reordering the ladder in makeover-layout breaks here.
544 assert!(column_cutoff(SizeClass::Compact) > column_cutoff(SizeClass::Medium));
545 assert!(column_cutoff(SizeClass::Medium) > column_cutoff(SizeClass::Expanded));
546 }
547
548 #[test]
549 fn the_column_cutoff_replaces_the_ordinal() {
550 // goingson's bug, written against this crate's answer: inserting a
551 // column must not change which column drops.
552 use makeover_layout::{Column, Priority as P, Width};
553
554 let before = [
555 Column {
556 width: Width::Fill,
557 priority: P::Essential,
558 ..Column::new("Title")
559 },
560 Column {
561 width: Width::Fixed,
562 priority: P::Secondary,
563 ..Column::new("Due")
564 },
565 Column {
566 width: Width::Fixed,
567 priority: P::Optional,
568 ..Column::new("Estimate")
569 },
570 ];
571 let after = [
572 Column {
573 width: Width::Fill,
574 priority: P::Essential,
575 ..Column::new("Title")
576 },
577 Column {
578 width: Width::Fill,
579 priority: P::Secondary,
580 ..Column::new("Project")
581 },
582 Column {
583 width: Width::Fixed,
584 priority: P::Secondary,
585 ..Column::new("Due")
586 },
587 Column {
588 width: Width::Fixed,
589 priority: P::Optional,
590 ..Column::new("Estimate")
591 },
592 ];
593
594 let cutoff = column_cutoff(SizeClass::Compact);
595 let kept: Vec<&str> = before
596 .iter()
597 .filter(|c| c.kept_at(cutoff))
598 .map(|c| c.name)
599 .collect();
600 assert_eq!(kept, ["Title"]);
601
602 let kept: Vec<&str> = after
603 .iter()
604 .filter(|c| c.kept_at(cutoff))
605 .map(|c| c.name)
606 .collect();
607 assert_eq!(kept, ["Title"]);
608 }
609
610 #[test]
611 fn the_axes_are_borrowed_not_redefined() {
612 // Re-exported rather than mirrored, so there is exactly one definition
613 // of each in the family. A local copy is how two crates start
614 // disagreeing about where 600px is.
615 assert_eq!(SizeClass::Medium.min_px(), 600);
616 assert_eq!(SizeClass::Expanded.min_px(), 840);
617 assert_eq!(SizeClass::at_width(599), SizeClass::Compact);
618 }
619 }
620