Skip to main content

max / makeover-touch

0.3.0: the density-gained affordances Gesture and Haptic, both available on Touch and absent on Pointer. That is the direction the crate previously forbade outright, so the withdrawal is the substance of this release and the two members are the consequence. Hover's own doc already said a fingertip has no hover state and that something else has to carry the same actions. Nothing here was allowed to be that something: Anchored and Overflow compensate on the size axis, and the density axis had no compensating member at all, only subtraction. The evidence is not in the @media census the other six came from, because these adapt behaviour rather than style. goingson frontend/js/touch.js:12 and balanced_breakfast frontend/js/touch.js:12 carry the same line and each hangs five gestures off it -- two apps hand-rolling the density question this crate exists to answer. One Gesture member for all five gestures. Both apps gate them behind a single boolean, so one member is what is measured and five would be minted from one fact. Splitting later is additive. touch_never_gains_an_affordance_pointer_lacks is deleted, not weakened. gained_by() makes each member declare its density instead, and a_density_member_is_available_on_exactly_the_density_it_declares holds the declaration to the rule, so a member still cannot invert quietly. Two tests guard the withdrawal itself: both_densities_gain_something fails if the touch-gained members are ever removed and leave the new rule passing over an empty set, and the_density_axis_compensates_rather_than_only_losing is the density twin of the compact rule. Breaking because all() goes [Self; 6] to [Self; 8]. makeover-webview pins "0.2" and has to follow, or a consumer taking both resolves two incompatible copies of Density.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-04 13:45 UTC
Signed with PGP, not checked
Commit: 0f9bbde8a7fb597c09c14dc63366f082838dc680
Parent: 1184d76
2 files changed, +166 insertions, -16 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-touch"
3 - version = "0.2.0"
3 + version = "0.3.0"
4 4 edition = "2024"
5 5 description = "The adaptation layer of the make-family design system: which affordances exist at a given input class and window size class. Names what appears and disappears, never how much space it takes."
6 6 license = "MIT"
M src/lib.rs +165 -15
@@ -36,6 +36,20 @@
36 36 //! product claim onto a measurement axis — which is exactly what the 2026-07-29
37 37 //! Touch demolition was for.
38 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 + //!
39 53 //! # The two axes are borrowed, never redefined
40 54 //!
41 55 //! Boundaries are not this crate's job. `makeover-geometry` quotes Material 3's
@@ -57,6 +71,28 @@
57 71 //! claim on the input device is the specific failure that produced this crate,
58 72 //! and re-introducing it should have to come to the test and say so.
59 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 + //!
60 96 //! # Collapsing is allowed, inverting is not
61 97 //!
62 98 //! Borrowed verbatim from `makeover-geometry`, where two gap relationships both
@@ -93,10 +129,10 @@
93 129
94 130 /// An affordance whose existence depends on the surface it is offered on.
95 131 ///
96 - /// Six members, drawn from what the two measured apps already gate by hand
132 + /// Eight members, drawn from what the two measured apps already gate by hand
97 133 /// 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.
134 + /// reason each addition has to point at call sites rather than at a category:
135 + /// guessing is how a description becomes a framework.
100 136 ///
101 137 /// Each answers [`Self::available`] against the two axes and nothing else. An
102 138 /// affordance that is always available is not an affordance this crate has
@@ -157,6 +193,34 @@
157 193 /// same pressure on a cluster of controls, which cannot drop any of them
158 194 /// and folds instead.
159 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,
160 224 }
161 225
162 226 impl Affordance {
@@ -168,8 +232,12 @@
168 232 #[must_use]
169 233 pub const fn available(self, density: Density, size: SizeClass) -> bool {
170 234 match self {
171 - // Contact patch. Screen budget has no opinion about either.
235 + // Contact patch, lost to a fingertip.
172 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),
173 241 // Screen budget. The input device has no opinion about any of them.
174 242 Self::Ancillary => matches!(size, SizeClass::Expanded),
175 243 Self::Detail => matches!(size, SizeClass::Medium | SizeClass::Expanded),
@@ -177,6 +245,27 @@
177 245 }
178 246 }
179 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 +
180 269 /// Whether this affordance's availability reads [`Density`] at all.
181 270 ///
182 271 /// Exposed rather than kept private because it is the crate's own claim
@@ -184,7 +273,7 @@
184 273 /// yes. A renderer with one density can skip the rest entirely.
185 274 #[must_use]
186 275 pub const fn reads_density(self) -> bool {
187 - matches!(self, Self::Hover | Self::Hint)
276 + self.gained_by().is_some()
188 277 }
189 278
190 279 /// Whether this affordance's availability reads [`SizeClass`] at all.
@@ -195,7 +284,7 @@
195 284
196 285 /// Every member, in declaration order.
197 286 #[must_use]
198 - pub const fn all() -> [Self; 6] {
287 + pub const fn all() -> [Self; 8] {
199 288 [
200 289 Self::Hover,
201 290 Self::Hint,
@@ -203,6 +292,8 @@
203 292 Self::Detail,
204 293 Self::Anchored,
205 294 Self::Overflow,
295 + Self::Gesture,
296 + Self::Haptic,
206 297 ]
207 298 }
208 299
@@ -220,6 +311,8 @@
220 311 Self::Detail => "offers-detail",
221 312 Self::Anchored => "offers-anchored",
222 313 Self::Overflow => "offers-overflow",
314 + Self::Gesture => "offers-gesture",
315 + Self::Haptic => "offers-haptic",
223 316 }
224 317 }
225 318 }
@@ -368,23 +461,80 @@
368 461 assert_ne!(Affordance::Hover.token(), Affordance::Hint.token());
369 462 }
370 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 +
371 474 #[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) {
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) => {
379 488 assert!(
380 - a.available(Density::Pointer, s),
381 - "{a:?} exists on touch at {s:?} but not on pointer"
489 + a.reads_density(),
490 + "{a:?} declares a density but denies reading one"
382 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 + }
383 502 }
503 + None => assert!(
504 + !a.reads_density(),
505 + "{a:?} reads density but declares no side"
506 + ),
384 507 }
385 508 }
386 509 }
387 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 +
388 538 #[test]
389 539 fn tokens_are_distinct() {
390 540 let mut seen: Vec<&str> = Affordance::all().iter().map(|a| a.token()).collect();