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}