max / makeover-touch
| 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 | |
| 90 | |
| 91 | pub use ; |
| 92 | pub use 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 | |
| 106 | |
| 107 | |
| 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 | |
| 162 | |
| 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 | |
| 169 | pub const |
| 170 | match self |
| 171 | // Contact patch. Screen budget has no opinion about either. |
| 172 | SelfHover | SelfHint => matches!, |
| 173 | // Screen budget. The input device has no opinion about any of them. |
| 174 | SelfAncillary => matches!, |
| 175 | SelfDetail => matches!, |
| 176 | SelfAnchored | SelfOverflow => matches!, |
| 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 | |
| 186 | pub const |
| 187 | matches! |
| 188 | |
| 189 | |
| 190 | /// Whether this affordance's availability reads [`SizeClass`] at all. |
| 191 | |
| 192 | pub const |
| 193 | !self.reads_density |
| 194 | |
| 195 | |
| 196 | /// Every member, in declaration order. |
| 197 | |
| 198 | pub const |
| 199 | |
| 200 | SelfHover, |
| 201 | SelfHint, |
| 202 | SelfAncillary, |
| 203 | SelfDetail, |
| 204 | SelfAnchored, |
| 205 | SelfOverflow, |
| 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 | |
| 215 | pub const |
| 216 | match self |
| 217 | SelfHover => "offers-hover", |
| 218 | SelfHint => "offers-hint", |
| 219 | SelfAncillary => "offers-ancillary", |
| 220 | SelfDetail => "offers-detail", |
| 221 | SelfAnchored => "offers-anchored", |
| 222 | SelfOverflow => "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 | |
| 239 | pub const |
| 240 | match size |
| 241 | // Only what identifies the row. |
| 242 | Compact => Essential, |
| 243 | // The optional columns go first. |
| 244 | Medium => Secondary, |
| 245 | // Everything survives. |
| 246 | Expanded => Optional, |
| 247 | |
| 248 | |
| 249 | |
| 250 | |
| 251 | |
| 252 | use *; |
| 253 | |
| 254 | /// Every combination of the two axes, narrowest and coarsest first. |
| 255 | |
| 256 | let mut out = Vecnew; |
| 257 | for d in |
| 258 | for s in all |
| 259 | out.push; |
| 260 | |
| 261 | |
| 262 | out |
| 263 | |
| 264 | |
| 265 | |
| 266 | |
| 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 all |
| 273 | let varies_by_density = all |
| 274 | .iter |
| 275 | .any; |
| 276 | assert_eq! |
| 277 | varies_by_density, |
| 278 | a.reads_density, |
| 279 | "{a:?} disagrees with its own reads_density()" |
| 280 | ; |
| 281 | |
| 282 | |
| 283 | |
| 284 | |
| 285 | |
| 286 | for a in all |
| 287 | let varies_by_size = .iter.any |
| 288 | all |
| 289 | .iter |
| 290 | .any |
| 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 | |
| 301 | |
| 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 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 | |
| 314 | |
| 315 | // A member available everywhere, or nowhere, is not describing an |
| 316 | // adaptation and does not belong in this crate. |
| 317 | for a in all |
| 318 | let yes = surfaces |
| 319 | .iter |
| 320 | .filter |
| 321 | .count; |
| 322 | assert!; |
| 323 | assert!; |
| 324 | |
| 325 | |
| 326 | |
| 327 | |
| 328 | |
| 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 all |
| 332 | for d in |
| 333 | let run: = all |
| 334 | .iter |
| 335 | .map |
| 336 | .collect; |
| 337 | let transitions = run.windows.filter.count; |
| 338 | assert! |
| 339 | transitions <= 1, |
| 340 | "{a:?} at {d:?} is available in a broken run: {run:?}" |
| 341 | ; |
| 342 | |
| 343 | |
| 344 | |
| 345 | |
| 346 | |
| 347 | |
| 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 |
| 352 | assert!; |
| 353 | assert!; |
| 354 | |
| 355 | |
| 356 | |
| 357 | |
| 358 | |
| 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 in surfaces |
| 363 | assert_eq! |
| 364 | Hover.available, |
| 365 | Hint.available |
| 366 | ; |
| 367 | |
| 368 | assert_ne!; |
| 369 | |
| 370 | |
| 371 | |
| 372 | |
| 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 all |
| 377 | for a in all |
| 378 | if a.available |
| 379 | assert! |
| 380 | a.available, |
| 381 | "{a:?} exists on touch at {s:?} but not on pointer" |
| 382 | ; |
| 383 | |
| 384 | |
| 385 | |
| 386 | |
| 387 | |
| 388 | |
| 389 | |
| 390 | let mut seen: = all.iter.map.collect; |
| 391 | seen.sort_unstable; |
| 392 | let before = seen.len; |
| 393 | seen.dedup; |
| 394 | assert_eq!; |
| 395 | |
| 396 | |
| 397 | |
| 398 | |
| 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!; |
| 403 | assert!; |
| 404 | |
| 405 | |
| 406 | |
| 407 | |
| 408 | // goingson's bug, written against this crate's answer: inserting a |
| 409 | // column must not change which column drops. |
| 410 | use ; |
| 411 | |
| 412 | let before = |
| 413 | Column |
| 414 | name: "Title", |
| 415 | width: Fill, |
| 416 | priority: Essential, |
| 417 | , |
| 418 | Column |
| 419 | name: "Due", |
| 420 | width: Fixed, |
| 421 | priority: Secondary, |
| 422 | , |
| 423 | Column |
| 424 | name: "Estimate", |
| 425 | width: Fixed, |
| 426 | priority: Optional, |
| 427 | , |
| 428 | ]; |
| 429 | let after = |
| 430 | Column |
| 431 | name: "Title", |
| 432 | width: Fill, |
| 433 | priority: Essential, |
| 434 | , |
| 435 | Column |
| 436 | name: "Project", |
| 437 | width: Fill, |
| 438 | priority: Secondary, |
| 439 | , |
| 440 | Column |
| 441 | name: "Due", |
| 442 | width: Fixed, |
| 443 | priority: Secondary, |
| 444 | , |
| 445 | Column |
| 446 | name: "Estimate", |
| 447 | width: Fixed, |
| 448 | priority: Optional, |
| 449 | , |
| 450 | ]; |
| 451 | |
| 452 | let cutoff = column_cutoff; |
| 453 | let kept: = before |
| 454 | .iter |
| 455 | .filter |
| 456 | .map |
| 457 | .collect; |
| 458 | assert_eq!; |
| 459 | |
| 460 | let kept: = after |
| 461 | .iter |
| 462 | .filter |
| 463 | .map |
| 464 | .collect; |
| 465 | assert_eq!; |
| 466 | |
| 467 | |
| 468 | |
| 469 | |
| 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!; |
| 474 | assert_eq!; |
| 475 | assert_eq!; |
| 476 | |
| 477 | |
| 478 |