| 39 |
39 |
|
//! instead of fighting it, an accessibility setting becomes one value rather
|
| 40 |
40 |
|
//! than a sweep, and the scale means the same thing at any display density.
|
| 41 |
41 |
|
//!
|
|
42 |
+ |
//! # Type is relational too
|
|
43 |
+ |
//!
|
|
44 |
+ |
//! [`Text`] names what a piece of text is — body, note, head — and the size
|
|
45 |
+ |
//! follows, exactly as [`Gap`] names what is being separated. It is the same
|
|
46 |
+ |
//! argument: whether a caption should be 13px or 14px cannot be reviewed,
|
|
47 |
+ |
//! whether a piece of text is a caption can.
|
|
48 |
+ |
//!
|
|
49 |
+ |
//! Type has its own rungs rather than reusing [`Step`], because the spacing
|
|
50 |
+ |
//! scale is eighths of the base to match the HIG's distances and a type ramp
|
|
51 |
+ |
//! wants different fractions. What the two axes share is the base, so the
|
|
52 |
+ |
//! reader's root font size moves the text and the space around it together.
|
|
53 |
+ |
//!
|
|
54 |
+ |
//! Unlike spacing, type does not move with [`Density`]. The reason is in
|
|
55 |
+ |
//! [`Text`], and it is the same one that keeps shells out of the touch preset.
|
|
56 |
+ |
//!
|
| 42 |
57 |
|
//! # Density presets
|
| 43 |
58 |
|
//!
|
| 44 |
59 |
|
//! Naming relationships instead of sizes is what makes a density preset
|
| 636 |
651 |
|
}
|
| 637 |
652 |
|
}
|
| 638 |
653 |
|
|
|
654 |
+ |
/// What a piece of text is, from which its size follows.
|
|
655 |
+ |
///
|
|
656 |
+ |
/// The type axis, and the same move [`Gap`] makes on the spacing axis: name
|
|
657 |
+ |
/// the role and let the size follow, so the choice is reviewable. Whether a
|
|
658 |
+ |
/// caption should be 13px or 14px is unanswerable in isolation; whether a
|
|
659 |
+ |
/// piece of text is a caption is not.
|
|
660 |
+ |
///
|
|
661 |
+ |
/// # Why the ratios are their own ramp
|
|
662 |
+ |
///
|
|
663 |
+ |
/// Type does not reuse [`Step`]. The spacing scale is built in eighths of the
|
|
664 |
+ |
/// base because that is what the HIG's distances land on, and a type ramp
|
|
665 |
+ |
/// needs different rungs — 7/8 and 9/8 sit either side of body copy and have
|
|
666 |
+ |
/// no spacing meaning at all, while `Hair` and `Tight` are far below any
|
|
667 |
+ |
/// legible size. Sharing the enum would have meant widening it for rungs
|
|
668 |
+ |
/// spacing never asks for.
|
|
669 |
+ |
///
|
|
670 |
+ |
/// What is shared is the thing that matters: every rung here is a [`Ratio`]
|
|
671 |
+ |
/// of `--geometry-base`, so text tracks the user's chosen root size exactly
|
|
672 |
+ |
/// as spacing does, and one knob still moves the whole design.
|
|
673 |
+ |
///
|
|
674 |
+ |
/// # Why the floor is 3/4
|
|
675 |
+ |
///
|
|
676 |
+ |
/// Twelve pixels at the default base, and nothing below it. Sizes under that
|
|
677 |
+ |
/// are a legibility problem rather than a tier, and a scale that offers one
|
|
678 |
+ |
/// is a scale that invites it. Text that needs to recede should recede by
|
|
679 |
+ |
/// colour or weight, which cost no legibility.
|
|
680 |
+ |
///
|
|
681 |
+ |
/// # Why type does not shift on touch
|
|
682 |
+ |
///
|
|
683 |
+ |
/// [`Density`] is a claim about the contact patch and nothing else, and text
|
|
684 |
+ |
/// is not a tap target. The reader's own root font size is already the knob
|
|
685 |
+ |
/// for how large text should be, and it already moves this whole ramp. So the
|
|
686 |
+ |
/// type axis is density-invariant, and a phone gets the same tiers a desktop
|
|
687 |
+ |
/// does.
|
|
688 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
689 |
+ |
pub enum Text {
|
|
690 |
+ |
/// Three quarters of the base. Timestamps, badges, legal lines.
|
|
691 |
+ |
Fine,
|
|
692 |
+ |
/// Seven eighths of the base. Secondary text: metadata, table cells,
|
|
693 |
+ |
/// captions, form help.
|
|
694 |
+ |
Note,
|
|
695 |
+ |
/// The base itself. Running copy, and the size everything else is read
|
|
696 |
+ |
/// against.
|
|
697 |
+ |
Body,
|
|
698 |
+ |
/// Nine eighths of the base. Emphasised copy: intros, card titles.
|
|
699 |
+ |
Lead,
|
|
700 |
+ |
/// Five quarters of the base. The third heading level.
|
|
701 |
+ |
Subhead,
|
|
702 |
+ |
/// One and a half times the base. Section headings, the second level.
|
|
703 |
+ |
Head,
|
|
704 |
+ |
/// Twice the base. The page's own title, the first level.
|
|
705 |
+ |
Title,
|
|
706 |
+ |
/// Two and a half times the base. Display copy, above the document
|
|
707 |
+ |
/// hierarchy rather than at the top of it.
|
|
708 |
+ |
Display,
|
|
709 |
+ |
/// Three times the base. One per page at most: a landing hero.
|
|
710 |
+ |
Hero,
|
|
711 |
+ |
}
|
|
712 |
+ |
|
|
713 |
+ |
impl Text {
|
|
714 |
+ |
/// This role's size as a fraction of the base unit.
|
|
715 |
+ |
#[must_use]
|
|
716 |
+ |
pub const fn ratio(self) -> Ratio {
|
|
717 |
+ |
let (numerator, denominator) = match self {
|
|
718 |
+ |
Self::Fine => (3, 4),
|
|
719 |
+ |
Self::Note => (7, 8),
|
|
720 |
+ |
Self::Body => (1, 1),
|
|
721 |
+ |
Self::Lead => (9, 8),
|
|
722 |
+ |
Self::Subhead => (5, 4),
|
|
723 |
+ |
Self::Head => (3, 2),
|
|
724 |
+ |
Self::Title => (2, 1),
|
|
725 |
+ |
Self::Display => (5, 2),
|
|
726 |
+ |
Self::Hero => (3, 1),
|
|
727 |
+ |
};
|
|
728 |
+ |
Ratio {
|
|
729 |
+ |
numerator,
|
|
730 |
+ |
denominator,
|
|
731 |
+ |
}
|
|
732 |
+ |
}
|
|
733 |
+ |
|
|
734 |
+ |
/// Size in CSS pixels at the default base.
|
|
735 |
+ |
#[must_use]
|
|
736 |
+ |
pub const fn px(self) -> u16 {
|
|
737 |
+ |
self.ratio().px_at(DEFAULT_BASE_PX)
|
|
738 |
+ |
}
|
|
739 |
+ |
|
|
740 |
+ |
/// The CSS custom-property name, without the leading `--`.
|
|
741 |
+ |
#[must_use]
|
|
742 |
+ |
pub const fn token(self) -> &'static str {
|
|
743 |
+ |
match self {
|
|
744 |
+ |
Self::Fine => "text-fine",
|
|
745 |
+ |
Self::Note => "text-note",
|
|
746 |
+ |
Self::Body => "text-body",
|
|
747 |
+ |
Self::Lead => "text-lead",
|
|
748 |
+ |
Self::Subhead => "text-subhead",
|
|
749 |
+ |
Self::Head => "text-head",
|
|
750 |
+ |
Self::Title => "text-title",
|
|
751 |
+ |
Self::Display => "text-display",
|
|
752 |
+ |
Self::Hero => "text-hero",
|
|
753 |
+ |
}
|
|
754 |
+ |
}
|
|
755 |
+ |
|
|
756 |
+ |
/// Every role, smallest first.
|
|
757 |
+ |
#[must_use]
|
|
758 |
+ |
pub const fn all() -> [Self; 9] {
|
|
759 |
+ |
[
|
|
760 |
+ |
Self::Fine,
|
|
761 |
+ |
Self::Note,
|
|
762 |
+ |
Self::Body,
|
|
763 |
+ |
Self::Lead,
|
|
764 |
+ |
Self::Subhead,
|
|
765 |
+ |
Self::Head,
|
|
766 |
+ |
Self::Title,
|
|
767 |
+ |
Self::Display,
|
|
768 |
+ |
Self::Hero,
|
|
769 |
+ |
]
|
|
770 |
+ |
}
|
|
771 |
+ |
}
|
|
772 |
+ |
|
| 639 |
773 |
|
/// Emit the base unit and the raw scale as CSS declarations, no selector.
|
| 640 |
774 |
|
///
|
| 641 |
775 |
|
/// Density-invariant: the steps are the vocabulary, and only which step a
|
| 657 |
791 |
|
out
|
| 658 |
792 |
|
}
|
| 659 |
793 |
|
|
|
794 |
+ |
/// Emit the type axis as CSS declarations, no selector.
|
|
795 |
+ |
///
|
|
796 |
+ |
/// Takes no [`Density`]: text is not a tap target, so the contact patch has no
|
|
797 |
+ |
/// opinion on it. See [`Text`] for the derivation.
|
|
798 |
+ |
#[must_use]
|
|
799 |
+ |
pub fn text_css_declarations() -> String {
|
|
800 |
+ |
let mut out = String::new();
|
|
801 |
+ |
out.push_str(" /* Type. Named for what the text is; the size follows.\n");
|
|
802 |
+ |
out.push_str(" Ratios of the base, so text tracks the reader's own\n");
|
|
803 |
+ |
out.push_str(" root size. Density-invariant: text is not a target. */\n");
|
|
804 |
+ |
for text in Text::all() {
|
|
805 |
+ |
let _ = writeln!(out, " --{}: {};", text.token(), text.ratio().css());
|
|
806 |
+ |
}
|
|
807 |
+ |
out
|
|
808 |
+ |
}
|
|
809 |
+ |
|
| 660 |
810 |
|
/// Emit the relational layer for one density as CSS declarations, no selector.
|
| 661 |
811 |
|
///
|
| 662 |
812 |
|
/// Gaps reference their step rather than repeating a value, so the scale has
|
| 729 |
879 |
|
/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
|
| 730 |
880 |
|
/// constant, so a web consumer should bake it in at build time rather than
|
| 731 |
881 |
|
/// apply it from JS on every load.
|
|
882 |
+ |
///
|
|
883 |
+ |
/// The density argument reaches the gaps only. The scale and the type ramp are
|
|
884 |
+ |
/// the same at every density, which is why neither takes one.
|
| 732 |
885 |
|
#[must_use]
|
| 733 |
886 |
|
pub fn geometry_css_vars(density: Density) -> String {
|
| 734 |
887 |
|
format!(
|
| 735 |
|
- |
":root {{\n{}\n{}}}\n",
|
|
888 |
+ |
":root {{\n{}\n{}\n{}}}\n",
|
| 736 |
889 |
|
scale_css_declarations(),
|
| 737 |
|
- |
gap_css_declarations(density)
|
|
890 |
+ |
gap_css_declarations(density),
|
|
891 |
+ |
text_css_declarations()
|
| 738 |
892 |
|
)
|
| 739 |
893 |
|
}
|
| 740 |
894 |
|
|
| 831 |
985 |
|
assert!(css.contains("--gap-peer"));
|
| 832 |
986 |
|
}
|
| 833 |
987 |
|
|
|
988 |
+ |
#[test]
|
|
989 |
+ |
fn the_type_ramp_ascends_and_never_repeats_a_size() {
|
|
990 |
+ |
// A tier that resolves to the same size as its neighbour is a name
|
|
991 |
+ |
// with no distinction behind it, which is how a scale grows rungs
|
|
992 |
+ |
// nobody can choose between.
|
|
993 |
+ |
let sizes: Vec<u16> = Text::all().iter().map(|t| t.px()).collect();
|
|
994 |
+ |
assert!(sizes.windows(2).all(|w| w[0] < w[1]), "{sizes:?}");
|
|
995 |
+ |
assert_eq!(sizes, vec![12, 14, 16, 18, 20, 24, 32, 40, 48]);
|
|
996 |
+ |
}
|
|
997 |
+ |
|
|
998 |
+ |
#[test]
|
|
999 |
+ |
fn the_type_ramp_has_a_legibility_floor() {
|
|
1000 |
+ |
// Nothing under 12px at the default base. Text that should recede
|
|
1001 |
+ |
// recedes by colour or weight, not by shrinking out of legibility.
|
|
1002 |
+ |
assert_eq!(Text::Fine.px(), 12);
|
|
1003 |
+ |
assert!(Text::all().iter().all(|t| t.px() >= 12));
|
|
1004 |
+ |
}
|
|
1005 |
+ |
|
|
1006 |
+ |
#[test]
|
|
1007 |
+ |
fn type_does_not_move_with_density() {
|
|
1008 |
+ |
// Density is a claim about the contact patch, and text is not a
|
|
1009 |
+ |
// target. A --text-* inside the touch override means that argument
|
|
1010 |
+ |
// was lost somewhere.
|
|
1011 |
+ |
let css = density_css(Some(".ui-mode-mobile"));
|
|
1012 |
+ |
let root_end = css.find("@media").expect("a touch block");
|
|
1013 |
+ |
assert!(css[..root_end].contains("--text-body"));
|
|
1014 |
+ |
assert!(!css[root_end..].contains("--text-"), "{}", &css[root_end..]);
|
|
1015 |
+ |
}
|
|
1016 |
+ |
|
|
1017 |
+ |
#[test]
|
|
1018 |
+ |
fn every_type_token_scales_from_the_one_base() {
|
|
1019 |
+ |
// A literal rem here would be a size that stops tracking the reader's
|
|
1020 |
+ |
// root font size, which is the whole point of the base.
|
|
1021 |
+ |
for text in Text::all() {
|
|
1022 |
+ |
let css = text.ratio().css();
|
|
1023 |
+ |
assert!(css.contains(BASE_TOKEN), "{}: {css}", text.token());
|
|
1024 |
+ |
}
|
|
1025 |
+ |
}
|
|
1026 |
+ |
|
| 834 |
1027 |
|
#[test]
|
| 835 |
1028 |
|
fn wrapping_leaves_no_trailing_whitespace_on_blank_lines() {
|
| 836 |
1029 |
|
// A formatter strips these later and calls it a diff.
|
| 1120 |
1313 |
|
fn tokens_are_unique() {
|
| 1121 |
1314 |
|
let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
|
| 1122 |
1315 |
|
names.extend(Gap::all().iter().map(|g| g.token()));
|
|
1316 |
+ |
names.extend(Text::all().iter().map(|t| t.token()));
|
| 1123 |
1317 |
|
let count = names.len();
|
| 1124 |
1318 |
|
names.sort_unstable();
|
| 1125 |
1319 |
|
names.dedup();
|