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