| 129 |
129 |
|
//! carries the boundaries only; what appears or disappears at each is a product
|
| 130 |
130 |
|
//! decision and belongs to `makeover-touch`.
|
| 131 |
131 |
|
//!
|
| 132 |
|
- |
//! Deliberately absent: size class does not feed [`Gap::step_at`] yet. Whether
|
| 133 |
|
- |
//! shells tighten on a compact window is a look call, and deriving it a second
|
| 134 |
|
- |
//! time is how the last one went wrong.
|
|
132 |
+ |
//! Size class feeds [`Gap::step_at_size`], and only the two shells listen to
|
|
133 |
+ |
//! it: `pane` and `page` come down one step on a compact window. That is where
|
|
134 |
+ |
//! "outer margin is screen you don't get" belongs. It sat in the Pointer
|
|
135 |
+ |
//! preset's pending retune until 2026-08-09, which would have been the same
|
|
136 |
+ |
//! mistake the old Touch preset made, one axis over.
|
| 135 |
137 |
|
//!
|
| 136 |
138 |
|
//! # Surfaces, and why a TUI is not a third density
|
| 137 |
139 |
|
//!
|
| 388 |
390 |
|
///
|
| 389 |
391 |
|
/// This enum carries the **boundaries only**. What appears, disappears or
|
| 390 |
392 |
|
/// reflows at each is a product decision and belongs to `makeover-touch`, not
|
| 391 |
|
- |
/// here. Deliberately absent for now: size class does not feed [`Gap::step_at`].
|
| 392 |
|
- |
/// Whether shells should tighten on a compact window is a look call of exactly
|
| 393 |
|
- |
/// the kind that produced the 2026-07-29 demolition, so it waits for an eyeball
|
| 394 |
|
- |
/// rather than being derived a second time.
|
|
393 |
+ |
/// here, with one exception: shells tighten on a compact window, through
|
|
394 |
+ |
/// [`Gap::step_at_size`]. That was a look call of exactly the kind that
|
|
395 |
+ |
/// produced the 2026-07-29 demolition, so it waited for an eyeball rather than
|
|
396 |
+ |
/// being derived a second time, and got one on 2026-08-09.
|
| 395 |
397 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
| 396 |
398 |
|
pub enum SizeClass {
|
| 397 |
399 |
|
/// Under 600px. Phones in either orientation, and any window narrowed to
|
| 580 |
582 |
|
}
|
| 581 |
583 |
|
|
| 582 |
584 |
|
impl Gap {
|
| 583 |
|
- |
/// The step this relationship resolves to at a given density.
|
|
585 |
+ |
/// The step this relationship resolves to at a given density, on a
|
|
586 |
+ |
/// [`SizeClass::Medium`] or wider window.
|
| 584 |
587 |
|
///
|
| 585 |
588 |
|
/// [`Density::Pointer`]'s values are the HIG's own. [`Density::Touch`]
|
| 586 |
589 |
|
/// opens the three gaps that separate distinct tap targets and holds the
|
| 587 |
590 |
|
/// rest, per the crate-level "Density presets" section.
|
|
591 |
+ |
///
|
|
592 |
+ |
/// Shells tighten on a compact window rather than at touch density. Use
|
|
593 |
+ |
/// [`Self::step_at_size`] where the window width is known; this is the
|
|
594 |
+ |
/// wider-window answer and the one every existing caller already meant.
|
| 588 |
595 |
|
#[must_use]
|
| 589 |
596 |
|
pub const fn step_at(self, density: Density) -> Step {
|
|
597 |
+ |
self.step_at_size(density, SizeClass::Medium)
|
|
598 |
+ |
}
|
|
599 |
+ |
|
|
600 |
+ |
/// The step this relationship resolves to at a given density and window
|
|
601 |
+ |
/// size class.
|
|
602 |
+ |
///
|
|
603 |
+ |
/// Only the two shells move, and only on [`SizeClass::Compact`]: `pane`
|
|
604 |
+ |
/// 24 to 16, `page` 32 to 24, one step down each. The four gaps between
|
|
605 |
+ |
/// controls do not, because how much room a window has says nothing about
|
|
606 |
+ |
/// how far apart two tap targets should be.
|
|
607 |
+ |
///
|
|
608 |
+ |
/// **Which axis owns this** (Max, 2026-08-09). The Pointer preset was
|
|
609 |
+ |
/// carrying a retune to `pane 14 / page 16` on the strength of a specimen
|
|
610 |
+ |
/// that wanted a tighter window edge. A tighter window edge is a claim
|
|
611 |
+ |
/// about screen budget, and the 0.3.0 Touch re-derivation had already
|
|
612 |
+ |
/// ruled that smuggling screen budget into the density axis was the whole
|
|
613 |
+ |
/// bug the old Touch preset embodied — it just had nowhere else to put it,
|
|
614 |
+ |
/// because [`SizeClass`] did not exist yet. It does now, so the claim goes
|
|
615 |
+ |
/// where it belongs and the quoted Pointer values hold.
|
|
616 |
+ |
///
|
|
617 |
+ |
/// Two things fall out. There is no need for a `Step` at 7/8 = 14, which
|
|
618 |
+ |
/// is off the eighths scale and would have wanted a public name; both
|
|
619 |
+ |
/// compact values are already on it. And no consumer migrates, because no
|
|
620 |
+ |
/// quoted value changed.
|
|
621 |
+ |
#[must_use]
|
|
622 |
+ |
pub const fn step_at_size(self, density: Density, size: SizeClass) -> Step {
|
| 590 |
623 |
|
match self {
|
| 591 |
624 |
|
// Binding is not a separation, so it does not open up on touch
|
| 592 |
625 |
|
// either: separating these would say they are two objects.
|
| 608 |
641 |
|
Density::Touch => Step::Loose,
|
| 609 |
642 |
|
},
|
| 610 |
643 |
|
|
| 611 |
|
- |
// Shells. Not tap targets, so the contact patch has no opinion,
|
| 612 |
|
- |
// and screen budget is a different axis than this one.
|
| 613 |
|
- |
Self::Pane => Step::Broad,
|
| 614 |
|
- |
Self::Page => Step::Vast,
|
|
644 |
+ |
// Shells. Not tap targets, so the contact patch has no opinion.
|
|
645 |
+ |
// Screen budget is the axis that does, and it is this one.
|
|
646 |
+ |
Self::Pane => match size {
|
|
647 |
+ |
SizeClass::Compact => Step::Loose,
|
|
648 |
+ |
SizeClass::Medium | SizeClass::Expanded => Step::Broad,
|
|
649 |
+ |
},
|
|
650 |
+ |
Self::Page => match size {
|
|
651 |
+ |
SizeClass::Compact => Step::Broad,
|
|
652 |
+ |
SizeClass::Medium | SizeClass::Expanded => Step::Vast,
|
|
653 |
+ |
},
|
| 615 |
654 |
|
}
|
| 616 |
655 |
|
}
|
| 617 |
656 |
|
|
| 627 |
666 |
|
self.step_at(density).px()
|
| 628 |
667 |
|
}
|
| 629 |
668 |
|
|
|
669 |
+ |
/// Size in CSS pixels at the default base, at a given density and window
|
|
670 |
+ |
/// size class.
|
|
671 |
+ |
#[must_use]
|
|
672 |
+ |
pub const fn px_at_size(self, density: Density, size: SizeClass) -> u16 {
|
|
673 |
+ |
self.step_at_size(density, size).px()
|
|
674 |
+ |
}
|
|
675 |
+ |
|
| 630 |
676 |
|
/// Size in CSS pixels at the default base and density.
|
| 631 |
677 |
|
#[must_use]
|
| 632 |
678 |
|
pub const fn px(self) -> u16 {
|
| 1072 |
1118 |
|
css
|
| 1073 |
1119 |
|
}
|
| 1074 |
1120 |
|
|
|
1121 |
+ |
/// Emit the compact-window shell override.
|
|
1122 |
+ |
///
|
|
1123 |
+ |
/// The one place this crate is allowed to ask how wide the window is. Density
|
|
1124 |
+ |
/// must never be selected by width — [`density_css`] has a test forbidding a
|
|
1125 |
+ |
/// breakpoint from appearing in it at all — because a capability query answers
|
|
1126 |
+ |
/// "what is pointing at this" and a breakpoint does not. Size class is the
|
|
1127 |
+ |
/// opposite: width is exactly what it means, so it gets its own emitter and
|
|
1128 |
+ |
/// its own media query rather than being folded into that file.
|
|
1129 |
+ |
///
|
|
1130 |
+ |
/// Only the two shells move, and only below [`SizeClass::Medium`]'s boundary:
|
|
1131 |
+ |
/// `pane` 24 to 16, `page` 32 to 24. The four gaps between controls are absent
|
|
1132 |
+ |
/// from the block, so a narrow window never reasons about tap targets.
|
|
1133 |
+ |
///
|
|
1134 |
+ |
/// Emitted inside [`CSS_LAYER`] for the same reason [`density_css`] is: an
|
|
1135 |
+ |
/// unlayered custom property outranks a layered one, so an app that layers its
|
|
1136 |
+ |
/// own overrides would otherwise lose to this.
|
|
1137 |
+ |
///
|
|
1138 |
+ |
/// ```
|
|
1139 |
+ |
/// # use makeover_geometry::size_class_css;
|
|
1140 |
+ |
/// let css = size_class_css();
|
|
1141 |
+ |
/// assert!(css.contains("--gap-pane"));
|
|
1142 |
+ |
/// assert!(!css.contains("--gap-peer"), "a control gap crept into a width query");
|
|
1143 |
+ |
/// ```
|
|
1144 |
+ |
#[must_use]
|
|
1145 |
+ |
pub fn size_class_css() -> String {
|
|
1146 |
+ |
in_css_layer(&size_class_declarations())
|
|
1147 |
+ |
}
|
|
1148 |
+ |
|
|
1149 |
+ |
/// [`size_class_css`] without the layer wrapper.
|
|
1150 |
+ |
fn size_class_declarations() -> String {
|
|
1151 |
+ |
// Compact is everything below Medium's lower bound, so the query ends one
|
|
1152 |
+ |
// step under it. Fractional, because a 599.5px viewport is reachable on a
|
|
1153 |
+ |
// fractional-scaling display and an integer bound would drop it into
|
|
1154 |
+ |
// neither class.
|
|
1155 |
+ |
let ceiling = f32::from(SizeClass::Medium.min_px()) - 0.02;
|
|
1156 |
+ |
let mut css = String::new();
|
|
1157 |
+ |
css.push_str("/* Compact window: shells tighten. Outer margin is screen you\n");
|
|
1158 |
+ |
css.push_str(" don't get, which is a claim about the window and not about\n");
|
|
1159 |
+ |
css.push_str(" what is pointing at it, so it lives here and not in the\n");
|
|
1160 |
+ |
css.push_str(" density presets. Targets are untouched. */\n");
|
|
1161 |
+ |
let _ = writeln!(css, "@media (max-width: {ceiling}px) {{");
|
|
1162 |
+ |
css.push_str(" :root {\n");
|
|
1163 |
+ |
for gap in [Gap::Pane, Gap::Page] {
|
|
1164 |
+ |
let _ = writeln!(
|
|
1165 |
+ |
css,
|
|
1166 |
+ |
" --{}: var(--{});",
|
|
1167 |
+ |
gap.token(),
|
|
1168 |
+ |
gap.step_at_size(Density::Pointer, SizeClass::Compact).token()
|
|
1169 |
+ |
);
|
|
1170 |
+ |
}
|
|
1171 |
+ |
css.push_str(" }\n}\n");
|
|
1172 |
+ |
css
|
|
1173 |
+ |
}
|
|
1174 |
+ |
|
| 1075 |
1175 |
|
/// Emit a density preset as a scoped override block.
|
| 1076 |
1176 |
|
///
|
| 1077 |
1177 |
|
/// Only the relational layer is emitted: the scale and the base do not change
|
| 1319 |
1419 |
|
Gap::Section.px_at(Density::Touch) <= Gap::Pane.px_at(Density::Touch),
|
| 1320 |
1420 |
|
"Touch inverted internally, which is what set the old floor"
|
| 1321 |
1421 |
|
);
|
| 1322 |
|
- |
// The retune wants Pointer pane 14 / page 16, both under Touch's
|
| 1323 |
|
- |
// Section of 16. Nothing in this crate may object to that.
|
|
1422 |
+ |
// The retune this guarded is WITHDRAWN (Max, 2026-08-09): shells
|
|
1423 |
+ |
// tighten by size class, not by moving the quoted Pointer values, so
|
|
1424 |
+ |
// Pointer pane holds at 24 and never goes under Touch's Section.
|
|
1425 |
+ |
//
|
|
1426 |
+ |
// The assertion stays anyway. It is not about the retune; it is about
|
|
1427 |
+ |
// the direction of authority, and the day a derived preset can set a
|
|
1428 |
+ |
// floor under a quoted one is the day this crate has the 2026-07-29
|
|
1429 |
+ |
// bug back regardless of what anybody wanted to retune.
|
| 1324 |
1430 |
|
assert!(Gap::Section.px_at(Density::Touch) >= 16);
|
| 1325 |
1431 |
|
}
|
| 1326 |
1432 |
|
|
|
1433 |
+ |
#[test]
|
|
1434 |
+ |
fn shells_tighten_on_a_compact_window_and_nothing_else_does() {
|
|
1435 |
+ |
// The whole of the 2026-08-09 ruling, in one test. Shells come down
|
|
1436 |
+ |
// one step on a compact window; the four gaps that separate controls
|
|
1437 |
+ |
// do not move, because how much room a window has says nothing about
|
|
1438 |
+ |
// how far apart two tap targets belong.
|
|
1439 |
+ |
for density in [Density::Pointer, Density::Touch] {
|
|
1440 |
+ |
assert_eq!(Gap::Pane.px_at_size(density, SizeClass::Compact), 16);
|
|
1441 |
+ |
assert_eq!(Gap::Page.px_at_size(density, SizeClass::Compact), 24);
|
|
1442 |
+ |
|
|
1443 |
+ |
for class in [SizeClass::Medium, SizeClass::Expanded] {
|
|
1444 |
+ |
assert_eq!(Gap::Pane.px_at_size(density, class), 24);
|
|
1445 |
+ |
assert_eq!(Gap::Page.px_at_size(density, class), 32);
|
|
1446 |
+ |
}
|
|
1447 |
+ |
|
|
1448 |
+ |
for gap in [Gap::Bound, Gap::Peer, Gap::Group, Gap::Section] {
|
|
1449 |
+ |
for class in SizeClass::all() {
|
|
1450 |
+ |
assert_eq!(
|
|
1451 |
+ |
gap.px_at_size(density, class),
|
|
1452 |
+ |
gap.px_at(density),
|
|
1453 |
+ |
"{gap:?} moved on {class:?}, and only shells may"
|
|
1454 |
+ |
);
|
|
1455 |
+ |
}
|
|
1456 |
+ |
}
|
|
1457 |
+ |
}
|
|
1458 |
+ |
}
|
|
1459 |
+ |
|
|
1460 |
+ |
#[test]
|
|
1461 |
+ |
fn the_widths_never_needed_a_step_at_seven_eighths() {
|
|
1462 |
+ |
// The retune wanted pane 14, which is 7/8 of the base and off an
|
|
1463 |
+ |
// eighths scale that runs 2/4/6/8/10/12/16/24/32/48. It would have
|
|
1464 |
+ |
// needed a new public Step variant, and naming one is a cost paid
|
|
1465 |
+ |
// forever. Putting the claim on the size-class axis lands both compact
|
|
1466 |
+ |
// values on steps that already exist.
|
|
1467 |
+ |
for gap in [Gap::Pane, Gap::Page] {
|
|
1468 |
+ |
for density in [Density::Pointer, Density::Touch] {
|
|
1469 |
+ |
let step = gap.step_at_size(density, SizeClass::Compact);
|
|
1470 |
+ |
assert!(
|
|
1471 |
+ |
Step::all().contains(&step),
|
|
1472 |
+ |
"{gap:?} compact resolved off the scale"
|
|
1473 |
+ |
);
|
|
1474 |
+ |
}
|
|
1475 |
+ |
}
|
|
1476 |
+ |
}
|
|
1477 |
+ |
|
|
1478 |
+ |
#[test]
|
|
1479 |
+ |
fn no_size_class_inverts_the_ordering() {
|
|
1480 |
+ |
// Collapse is allowed, inversion is not — the same rule the surface
|
|
1481 |
+ |
// quantum test applies, now across the third axis. Touch on a compact
|
|
1482 |
+ |
// window is the tight one: Section opens to 16 and Pane comes down to
|
|
1483 |
+ |
// 16, so they meet. Meeting is fine. Crossing is not.
|
|
1484 |
+ |
for density in [Density::Pointer, Density::Touch] {
|
|
1485 |
+ |
for class in SizeClass::all() {
|
|
1486 |
+ |
let v: Vec<u16> = Gap::all()
|
|
1487 |
+ |
.iter()
|
|
1488 |
+ |
.map(|g| g.px_at_size(density, class))
|
|
1489 |
+ |
.collect();
|
|
1490 |
+ |
let mut sorted = v.clone();
|
|
1491 |
+ |
sorted.sort_unstable();
|
|
1492 |
+ |
assert_eq!(v, sorted, "{density:?} {class:?} inverted: {v:?}");
|
|
1493 |
+ |
}
|
|
1494 |
+ |
}
|
|
1495 |
+ |
}
|
|
1496 |
+ |
|
|
1497 |
+ |
#[test]
|
|
1498 |
+ |
fn step_at_is_the_wider_window_answer() {
|
|
1499 |
+ |
// Every caller that predates the size-class axis meant the wide
|
|
1500 |
+ |
// window, so the old entry point has to keep resolving to it or a
|
|
1501 |
+ |
// consumer tightens silently on a bump it did not read about.
|
|
1502 |
+ |
for gap in Gap::all() {
|
|
1503 |
+ |
for density in [Density::Pointer, Density::Touch] {
|
|
1504 |
+ |
assert_eq!(
|
|
1505 |
+ |
gap.step_at(density),
|
|
1506 |
+ |
gap.step_at_size(density, SizeClass::Medium)
|
|
1507 |
+ |
);
|
|
1508 |
+ |
assert_eq!(
|
|
1509 |
+ |
gap.step_at(density),
|
|
1510 |
+ |
gap.step_at_size(density, SizeClass::Expanded)
|
|
1511 |
+ |
);
|
|
1512 |
+ |
}
|
|
1513 |
+ |
}
|
|
1514 |
+ |
}
|
|
1515 |
+ |
|
|
1516 |
+ |
#[test]
|
|
1517 |
+ |
fn the_width_query_lives_outside_the_density_file() {
|
|
1518 |
+ |
// density_is_selected_by_capability_not_by_width_or_agent forbids a
|
|
1519 |
+ |
// breakpoint in density_css. This is the other half of that rule: the
|
|
1520 |
+ |
// width query has to exist somewhere, and somewhere is here.
|
|
1521 |
+ |
let css = size_class_css();
|
|
1522 |
+ |
assert!(css.contains("max-width"));
|
|
1523 |
+ |
assert!(css.contains("--gap-pane"));
|
|
1524 |
+ |
assert!(css.contains("--gap-page"));
|
|
1525 |
+ |
// Targets never appear in a width query.
|
|
1526 |
+ |
for token in ["--gap-bound", "--gap-peer", "--gap-group", "--gap-section"] {
|
|
1527 |
+ |
assert!(!css.contains(token), "{token} crept into a width query");
|
|
1528 |
+ |
}
|
|
1529 |
+ |
// And it stays out of the density file.
|
|
1530 |
+ |
assert!(!density_css(None).contains("max-width"));
|
|
1531 |
+ |
}
|
|
1532 |
+ |
|
| 1327 |
1533 |
|
#[test]
|
| 1328 |
1534 |
|
fn size_classes_partition_every_width_exactly_once() {
|
| 1329 |
1535 |
|
// Mutually exclusive and exhaustive, or a rule lands in two classes and
|