Skip to main content

max / makeover-touch

Seed makeover-touch: which affordances exist at an input and size class The adaptation layer the two axes were landed for. makeover-geometry 0.4.0 carries SizeClass and Density and says explicitly that what appears or disappears at each is this crate's decision, not its own. Scope is the roughly 43% of both apps' adaptation rules that no spacing scale retires: show/hide, reposition, appearance. `display: none` on a keyboard hint says the affordance does not exist on touch, and a gap preset cannot say that. Six members, drawn from what MNW and goingson already gate by hand rather than from a taxonomy. Two read density, four read size, and the split is asserted: density is a claim about the contact patch and nothing else, which is the lesson the thrown-out Touch gap preset paid for. A navigation shell fork is deliberately absent. goingson has two shells today and building two is a product decision rather than an adaptation; its restructure is how that stops being true. column_cutoff closes the one seam neither neighbour could reach. Layout owns the priority ladder, geometry owns the boundaries, and nothing said which cutoff a compact window uses, so both apps answered it with nth-child on an ordinal. 12 tests, clippy and fmt clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 20:18 UTC
Signed with PGP, not checked
Commit: 26e8c69fe8611cf3efd0b527826810f5c146097b
7 files changed, +669 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +43
@@ -1,0 +1,43 @@
1 + [package]
2 + name = "makeover-touch"
3 + version = "0.1.0"
4 + edition = "2024"
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 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-touch"
8 +
9 + [dependencies]
10 + makeover-geometry = "0.4"
11 + makeover-layout = "0.6"
12 +
13 + [lints.rust]
14 + unused = "warn"
15 + unreachable_pub = "warn"
16 +
17 + [lints.clippy]
18 + pedantic = { level = "warn", priority = -1 }
19 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
20 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
21 + # else in `pedantic` stays a warning. Keep this block identical across repos.
22 + module_name_repetitions = "allow"
23 + # Doc lints. No docs-completeness push is underway.
24 + missing_errors_doc = "allow"
25 + missing_panics_doc = "allow"
26 + doc_markdown = "allow"
27 + # Numeric casts. Endemic and mostly intentional in size and byte math.
28 + cast_possible_truncation = "allow"
29 + cast_sign_loss = "allow"
30 + cast_precision_loss = "allow"
31 + cast_possible_wrap = "allow"
32 + cast_lossless = "allow"
33 + # Subjective structure and style nags. High churn, low signal.
34 + must_use_candidate = "allow"
35 + too_many_lines = "allow"
36 + struct_excessive_bools = "allow"
37 + similar_names = "allow"
38 + items_after_statements = "allow"
39 + single_match_else = "allow"
40 + # Frequent false-positives in TUI and router-heavy code.
41 + match_same_arms = "allow"
42 + unnecessary_wraps = "allow"
43 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +111
@@ -1,0 +1,111 @@
1 + # makeover-touch
2 +
3 + The adaptation layer of the make-family design system.
4 +
5 + `makeover` answers *what colour*. `makeover-geometry` answers *how much space*,
6 + and owns the two axes an adaptation is stated against: `Density` (pointer or
7 + touch) and `SizeClass` (compact, medium, expanded). `makeover-layout` answers
8 + *what the thing is*. This crate answers one question and no other:
9 +
10 + > Does this affordance exist here?
11 +
12 + Like `makeover-layout`, it emits nothing. It is a description, rendered to CSS by
13 + `makeover-webview` and to whatever the other renderers can express.
14 +
15 + ## Why it is a crate and not a density preset
16 +
17 + Measured across the MNW server's `@media` blocks (137) and goingson's
18 + `ui-mode-*` blocks (192), bucketed by what the declarations inside actually
19 + change:
20 +
21 + | bucket | MNW | GO | retired by |
22 + |---|---|---|---|
23 + | density | 32% | 27% | a `makeover-geometry` preset |
24 + | type | 23% | 15% | the type scale |
25 + | columns | 20% | 12% | `makeover_layout::Column` |
26 + | reflow | 16% | 17% | `makeover_layout::Arrangement` |
27 + | **show/hide** | **12%** | **12%** | **this crate** |
28 + | **reposition** | **6%** | **20%** | **this crate** |
29 + | **appearance** | **1%** | **17%** | **this crate** |
30 +
31 + The bottom three are the roughly 43% no spacing scale can retire. `display:
32 + none` on a keyboard hint says the affordance does not exist on touch. No amount
33 + of gap retuning expresses that, and a scale that tried would be putting a
34 + product claim on a measurement axis, which is what the July 2026 Touch
35 + demolition was for.
36 +
37 + ## What is here
38 +
39 + `Affordance`, six members, drawn from what the two measured apps already gate by
40 + hand:
41 +
42 + | member | reads | rule |
43 + |---|---|---|
44 + | `Hover` | density | pointer only |
45 + | `Hint` | density | pointer only |
46 + | `Ancillary` | size | expanded only |
47 + | `Detail` | size | medium and up |
48 + | `Anchored` | size | compact only |
49 + | `Overflow` | size | compact only |
50 +
51 + `Affordance::available(density, size)` is the whole crate in one call. A
52 + renderer asks per affordance and never branches on a width.
53 +
54 + `column_cutoff(size)` closes the one seam neither neighbour could: layout defines
55 + the priority ladder and `Column::kept_at`, geometry defines the boundaries, and
56 + nothing said which cutoff a compact window uses. Both webview apps answered it
57 + with `nth-child` on an ordinal, so inserting a column silently hid the wrong one.
58 +
59 + ## The rules the tests hold
60 +
61 + **Density gates only what the contact patch touches.** Hovering, and the
62 + keyboard chrome documenting shortcuts a touch surface cannot send. Not how much
63 + screen there is: a phone is small *and* touch, a tablet is big *and* touch.
64 + Putting a screen-budget claim on the input device is the specific failure that
65 + produced this crate.
66 +
67 + **Touch never gains an affordance pointer lacks.** Touch is derived from pointer
68 + by subtracting what a fingertip cannot do, so it loses members and never gains
69 + one.
70 +
71 + **Collapsing is allowed, inverting is not.** Borrowed from `makeover-geometry`,
72 + where two gap relationships both resolve to zero cells on a terminal and stay
73 + two members. `Hover` and `Hint` have identical rules today and are still two
74 + members, because the call site names what is gated rather than the rule.
75 +
76 + **Compact compensates rather than only losing.** `Anchored` and `Overflow` are
77 + available at the narrow end and not the wide one. An adaptation that only
78 + removes things describes a degraded layout, not a different one.
79 +
80 + ## What is not here, on purpose
81 +
82 + **Boundaries.** `makeover-geometry` quotes Material 3's window size classes at
83 + 600 and 840. This crate adds no third axis, no fourth class and no breakpoint of
84 + its own.
85 +
86 + **A navigation shell fork.** goingson currently carries two, concentrated in
87 + `.app-header`, `.tab`, `.tab-navigation`, `.pill-nav`, `.saved-views-sidebar`
88 + and `.modal-container`. That is not one shell adapting, it is two shells, and
89 + choosing to build two is a product decision. goingson's own restructure is how
90 + it stops being true.
91 +
92 + **Which class applies.** The app decides, from a measured width and from what it
93 + already knows about the input. This crate takes both as arguments and never
94 + sniffs.
95 +
96 + **What a renderer does when an affordance is unavailable.** Hiding it,
97 + substituting it or showing it anyway is renderer policy, the same class of
98 + decision `makeover-layout` deleted `Fill::fallback` for.
99 +
100 + ## Status
101 +
102 + Seeded at 0.1.0, unpublished, no renderer has been through it. The vocabulary is
103 + six members and the intent is to stop there: `makeover-layout`'s own warning is
104 + that guessing at eight is how a description becomes a framework.
105 +
106 + Design lives in the wiki note `makeover-touch`; the backlog is in GoingsOn under
107 + the project of the same name.
108 +
109 + ## Licence
110 +
111 + MIT.
A bento.toml +8
@@ -1,0 +1,8 @@
1 + # How Bento releases makeover-touch. Lives here rather than in the daemon's config so it is
2 + # versioned with the code it describes.
3 +
4 + # A crate, not an app: one publish.rhai rather than a recipe per platform, and
5 + # the target below names the host that uploads rather than a build matrix.
6 + kind = "library"
7 +
8 + targets = ["linux/x86_64"]
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +477
@@ -1,0 +1,477 @@
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 +
91 + pub use makeover_geometry::{Density, SizeClass};
92 + pub 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]
107 + pub 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 +
162 + impl 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]
239 + pub 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)]
251 + mod 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 + }