Skip to main content

frust_theme/
typography.rs

1//! [`TypeScale::neutral`]: the design-language-free type-scale progression —
2//! 15 named [`frust_text::TextStyle`] presets plus 15 `_emphasized` siblings
3//! (see "Emphasized type scale" below).
4//!
5//! This crate constructs no other `TypeScale` — a design system builds its
6//! own from its own plugin crate (`frust-material`'s `tokens` module carries
7//! the Material 3 mapping this table's numeric progression originates from,
8//! citations included; `frust-cupertino`'s carries the Cupertino/SF Pro
9//! mapping). The numeric progression below is duplicated into
10//! `frust-material` deliberately: a type *scale* (a
11//! size/weight/tracking/line-height ladder) isn't a branded design-system
12//! artifact the way a named font face is, so [`TypeScale::neutral`] reuses
13//! the identical numbers rather than inventing an unbranded ladder — only
14//! `family` differs (see that constructor's doc comment).
15//!
16//! Source for the numbers themselves: Material 3's type-scale tokens
17//! (<https://m3.material.io/styles/typography/type-scale-tokens>, verified
18//! 2026-07-17). Sizes are specified in sp; Frust treats sp and logical px 1:1
19//! (see `frust-text`'s scale). Line heights are absolute logical pixels
20//! (`LineHeight::Absolute`, not a font-size-relative ratio) — M3 publishes
21//! them as fixed px values per token, not a ratio. Letter spacing is in
22//! logical pixels; `displayLarge`'s spacing is negative (tighter tracking at
23//! very large sizes).
24//!
25//! `titleLarge` is weight 400 (Regular) per m3.material.io; a secondary
26//! source claims 500 — this module follows m3.material.io as the primary,
27//! more authoritative source.
28//!
29//! # Emphasized type scale
30//!
31//! [`TypeScale`] additionally carries 15 `_emphasized` variants (one per
32//! baseline role, 30 slots total), filled by [`TypeScale::neutral`] alongside
33//! the baseline 15 (`frust-material`/`frust-cupertino`'s own constructors do
34//! the same for their own baselines).
35//!
36//! **Role-count resolution:** an earlier belief that emphasized variants were
37//! "15 baseline + 15 emphasized (30 total), applied to Display/Headline/Title
38//! roles" was contested — the "30 total" count was right but the
39//! "Display/Headline/Title only" scope was wrong. Verified directly against
40//! the primary source: Jetpack Compose Material3's generated token file
41//! (`androidx.compose.material3.tokens.TypographyTokens`/`TypeScaleTokens`,
42//! `VERSION: v0_103`,
43//! <https://github.com/androidx/androidx/blob/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/TypeScaleTokens.kt>
44//! — Compose's `Typography` class doc comments enumerate an
45//! `*Emphasized` property for *all 15* baseline roles: `displayLarge`
46//! through `labelSmall`, not a Display/Headline/Title-only subset;
47//! retrieved/verified 2026-07-18). This module follows that: every one of
48//! the 15 baseline roles gets an emphasized sibling.
49//!
50//! **M3 deltas** (from the same source): size and line height are unchanged
51//! between a role's baseline and emphasized token — only weight (and, for a
52//! handful of roles, letter spacing) shift. Weight always steps up one rung
53//! from the baseline token's own weight: Regular → Medium for every
54//! Regular-weight baseline role (`display_*`, `headline_*`, `title_large`,
55//! `body_*`), and Medium → Bold for every Medium-weight baseline role
56//! (`title_medium`, `title_small`, `label_*`) — so every emphasized style is
57//! guaranteed to differ from its base in weight. Letter spacing mostly
58//! matches the baseline value already in this module's tables; `body_large`
59//! is the one role whose emphasized tracking differs from its own baseline
60//! (0.5px baseline → 0.15px emphasized, matching the source's
61//! `BodyLargeEmphasizedTracking`).
62
63use frust_text::{FontFamily, FontWeight, GenericSlot, LineHeight, TextStyle};
64
65/// The 15 named type-scale presets, each a full [`TextStyle`], plus 15
66/// `_emphasized` siblings (see the module docs' "Emphasized type scale"
67/// section).
68///
69/// Build one from a `base` style (its `family`/`style`/`color` are kept;
70/// `size`/`weight`/`letter_spacing`/`line_height` are overridden per token)
71/// via [`TypeScale::neutral`] — the only constructor this crate carries; a
72/// design system builds its own from its own plugin crate (see the module
73/// docs).
74#[derive(Clone, Debug, PartialEq)]
75pub struct TypeScale {
76    pub display_large: TextStyle,
77    pub display_medium: TextStyle,
78    pub display_small: TextStyle,
79    pub headline_large: TextStyle,
80    pub headline_medium: TextStyle,
81    pub headline_small: TextStyle,
82    pub title_large: TextStyle,
83    pub title_medium: TextStyle,
84    pub title_small: TextStyle,
85    pub body_large: TextStyle,
86    pub body_medium: TextStyle,
87    pub body_small: TextStyle,
88    pub label_large: TextStyle,
89    pub label_medium: TextStyle,
90    pub label_small: TextStyle,
91    pub display_large_emphasized: TextStyle,
92    pub display_medium_emphasized: TextStyle,
93    pub display_small_emphasized: TextStyle,
94    pub headline_large_emphasized: TextStyle,
95    pub headline_medium_emphasized: TextStyle,
96    pub headline_small_emphasized: TextStyle,
97    pub title_large_emphasized: TextStyle,
98    pub title_medium_emphasized: TextStyle,
99    pub title_small_emphasized: TextStyle,
100    pub body_large_emphasized: TextStyle,
101    pub body_medium_emphasized: TextStyle,
102    pub body_small_emphasized: TextStyle,
103    pub label_large_emphasized: TextStyle,
104    pub label_medium_emphasized: TextStyle,
105    pub label_small_emphasized: TextStyle,
106}
107
108/// One type-scale token's numeric shape: `(size_px, line_height_px,
109/// letter_spacing_px, weight)`.
110type Token = (f32, f32, f32, FontWeight);
111
112const DISPLAY_LARGE: Token = (57.0, 64.0, -0.25, FontWeight::REGULAR);
113const DISPLAY_MEDIUM: Token = (45.0, 52.0, 0.0, FontWeight::REGULAR);
114const DISPLAY_SMALL: Token = (36.0, 44.0, 0.0, FontWeight::REGULAR);
115const HEADLINE_LARGE: Token = (32.0, 40.0, 0.0, FontWeight::REGULAR);
116const HEADLINE_MEDIUM: Token = (28.0, 36.0, 0.0, FontWeight::REGULAR);
117const HEADLINE_SMALL: Token = (24.0, 32.0, 0.0, FontWeight::REGULAR);
118const TITLE_LARGE: Token = (22.0, 28.0, 0.0, FontWeight::REGULAR);
119const TITLE_MEDIUM: Token = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
120const TITLE_SMALL: Token = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
121const BODY_LARGE: Token = (16.0, 24.0, 0.5, FontWeight::REGULAR);
122const BODY_MEDIUM: Token = (14.0, 20.0, 0.25, FontWeight::REGULAR);
123const BODY_SMALL: Token = (12.0, 16.0, 0.4, FontWeight::REGULAR);
124const LABEL_LARGE: Token = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
125const LABEL_MEDIUM: Token = (12.0, 16.0, 0.5, FontWeight::MEDIUM);
126const LABEL_SMALL: Token = (11.0, 16.0, 0.5, FontWeight::MEDIUM);
127
128// M3-Expressive emphasized tokens: same size/line-height as the matching
129// baseline `Token` above in every case; weight steps up one rung from the
130// baseline role's own weight (Regular -> Medium, Medium -> Bold) and letter
131// spacing is the source's `*Emphasized*Tracking` value (see the module
132// docs' "Emphasized type scale" section for the primary-source citation and
133// resolution of the contested Display/Headline/Title-only scope claim).
134const DISPLAY_LARGE_EMPHASIZED: Token = (57.0, 64.0, 0.0, FontWeight::MEDIUM);
135const DISPLAY_MEDIUM_EMPHASIZED: Token = (45.0, 52.0, 0.0, FontWeight::MEDIUM);
136const DISPLAY_SMALL_EMPHASIZED: Token = (36.0, 44.0, 0.0, FontWeight::MEDIUM);
137const HEADLINE_LARGE_EMPHASIZED: Token = (32.0, 40.0, 0.0, FontWeight::MEDIUM);
138const HEADLINE_MEDIUM_EMPHASIZED: Token = (28.0, 36.0, 0.0, FontWeight::MEDIUM);
139const HEADLINE_SMALL_EMPHASIZED: Token = (24.0, 32.0, 0.0, FontWeight::MEDIUM);
140const TITLE_LARGE_EMPHASIZED: Token = (22.0, 28.0, 0.0, FontWeight::MEDIUM);
141const TITLE_MEDIUM_EMPHASIZED: Token = (16.0, 24.0, 0.15, FontWeight::BOLD);
142const TITLE_SMALL_EMPHASIZED: Token = (14.0, 20.0, 0.1, FontWeight::BOLD);
143const BODY_LARGE_EMPHASIZED: Token = (16.0, 24.0, 0.15, FontWeight::MEDIUM);
144const BODY_MEDIUM_EMPHASIZED: Token = (14.0, 20.0, 0.25, FontWeight::MEDIUM);
145const BODY_SMALL_EMPHASIZED: Token = (12.0, 16.0, 0.4, FontWeight::MEDIUM);
146const LABEL_LARGE_EMPHASIZED: Token = (14.0, 20.0, 0.1, FontWeight::BOLD);
147const LABEL_MEDIUM_EMPHASIZED: Token = (12.0, 16.0, 0.5, FontWeight::BOLD);
148const LABEL_SMALL_EMPHASIZED: Token = (11.0, 16.0, 0.5, FontWeight::BOLD);
149
150fn apply(base: &TextStyle, token: Token) -> TextStyle {
151    let (size, line_height_px, letter_spacing, weight) = token;
152    TextStyle {
153        size,
154        weight,
155        letter_spacing,
156        line_height: LineHeight::Absolute(line_height_px),
157        ..base.clone()
158    }
159}
160
161impl TypeScale {
162    /// Builds the neutral, design-language-free type scale from `base` (its
163    /// `style`/`color` are preserved).
164    ///
165    /// Reuses the same numeric size/weight/tracking/line-height progression
166    /// `frust-material`'s M3-sourced type scale carries (see the module
167    /// docs) — a type *scale* isn't a branded artifact the way a named font
168    /// face is, so there's no design-language-specific value here to
169    /// invent (see `crate::theme::Theme::neutral`'s module docs). The one
170    /// thing this constructor overrides is `family`: every slot forces a
171    /// generic sans-serif stack via [`FontFamily::stack_with_generic`] (no
172    /// named font at all, ending in [`GenericSlot::SansSerif`]) rather than
173    /// inheriting whatever `base` supplies — **no bundled font bytes are
174    /// referenced**, so this compiles and behaves identically whether or not
175    /// the `glyph-fonts` feature is enabled.
176    pub fn neutral(base: &TextStyle) -> Self {
177        let family =
178            FontFamily::stack_with_generic(std::iter::empty::<&str>(), GenericSlot::SansSerif);
179        let base = TextStyle {
180            family,
181            ..base.clone()
182        };
183        Self {
184            display_large: apply(&base, DISPLAY_LARGE),
185            display_medium: apply(&base, DISPLAY_MEDIUM),
186            display_small: apply(&base, DISPLAY_SMALL),
187            headline_large: apply(&base, HEADLINE_LARGE),
188            headline_medium: apply(&base, HEADLINE_MEDIUM),
189            headline_small: apply(&base, HEADLINE_SMALL),
190            title_large: apply(&base, TITLE_LARGE),
191            title_medium: apply(&base, TITLE_MEDIUM),
192            title_small: apply(&base, TITLE_SMALL),
193            body_large: apply(&base, BODY_LARGE),
194            body_medium: apply(&base, BODY_MEDIUM),
195            body_small: apply(&base, BODY_SMALL),
196            label_large: apply(&base, LABEL_LARGE),
197            label_medium: apply(&base, LABEL_MEDIUM),
198            label_small: apply(&base, LABEL_SMALL),
199            display_large_emphasized: apply(&base, DISPLAY_LARGE_EMPHASIZED),
200            display_medium_emphasized: apply(&base, DISPLAY_MEDIUM_EMPHASIZED),
201            display_small_emphasized: apply(&base, DISPLAY_SMALL_EMPHASIZED),
202            headline_large_emphasized: apply(&base, HEADLINE_LARGE_EMPHASIZED),
203            headline_medium_emphasized: apply(&base, HEADLINE_MEDIUM_EMPHASIZED),
204            headline_small_emphasized: apply(&base, HEADLINE_SMALL_EMPHASIZED),
205            title_large_emphasized: apply(&base, TITLE_LARGE_EMPHASIZED),
206            title_medium_emphasized: apply(&base, TITLE_MEDIUM_EMPHASIZED),
207            title_small_emphasized: apply(&base, TITLE_SMALL_EMPHASIZED),
208            body_large_emphasized: apply(&base, BODY_LARGE_EMPHASIZED),
209            body_medium_emphasized: apply(&base, BODY_MEDIUM_EMPHASIZED),
210            body_small_emphasized: apply(&base, BODY_SMALL_EMPHASIZED),
211            label_large_emphasized: apply(&base, LABEL_LARGE_EMPHASIZED),
212            label_medium_emphasized: apply(&base, LABEL_MEDIUM_EMPHASIZED),
213            label_small_emphasized: apply(&base, LABEL_SMALL_EMPHASIZED),
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use peniko::Color;
222
223    #[test]
224    fn display_large_matches_table() {
225        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
226        assert_eq!(scale.display_large.size, 57.0);
227        assert_eq!(scale.display_large.line_height, LineHeight::Absolute(64.0));
228        assert_eq!(scale.display_large.letter_spacing, -0.25);
229        assert_eq!(scale.display_large.weight, FontWeight::REGULAR);
230    }
231
232    #[test]
233    fn title_medium_is_medium_weight() {
234        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
235        assert_eq!(scale.title_medium.weight, FontWeight::MEDIUM);
236        assert_eq!(scale.title_medium.size, 16.0);
237        assert_eq!(scale.title_medium.letter_spacing, 0.15);
238    }
239
240    #[test]
241    fn title_large_is_regular_weight() {
242        // m3.material.io: titleLarge is weight 400, not 500 (see module docs
243        // for the contested secondary-source claim this rejects).
244        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
245        assert_eq!(scale.title_large.weight, FontWeight::REGULAR);
246    }
247
248    #[test]
249    fn label_small_matches_table() {
250        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
251        assert_eq!(scale.label_small.size, 11.0);
252        assert_eq!(scale.label_small.line_height, LineHeight::Absolute(16.0));
253        assert_eq!(scale.label_small.letter_spacing, 0.5);
254        assert_eq!(scale.label_small.weight, FontWeight::MEDIUM);
255    }
256
257    /// Every emphasized role differs from its own baseline in weight — the
258    /// "spec'd dimension" per the module docs' M3-deltas paragraph (weight
259    /// always steps up one rung: Regular -> Medium or Medium -> Bold),
260    /// verified across all 15 roles, not just Display/Headline/Title (the
261    /// resolved, previously-contested scope question — see module docs).
262    #[test]
263    fn every_emphasized_role_differs_in_weight_from_its_base() {
264        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
265        let pairs: [(&TextStyle, &TextStyle); 15] = [
266            (&scale.display_large, &scale.display_large_emphasized),
267            (&scale.display_medium, &scale.display_medium_emphasized),
268            (&scale.display_small, &scale.display_small_emphasized),
269            (&scale.headline_large, &scale.headline_large_emphasized),
270            (&scale.headline_medium, &scale.headline_medium_emphasized),
271            (&scale.headline_small, &scale.headline_small_emphasized),
272            (&scale.title_large, &scale.title_large_emphasized),
273            (&scale.title_medium, &scale.title_medium_emphasized),
274            (&scale.title_small, &scale.title_small_emphasized),
275            (&scale.body_large, &scale.body_large_emphasized),
276            (&scale.body_medium, &scale.body_medium_emphasized),
277            (&scale.body_small, &scale.body_small_emphasized),
278            (&scale.label_large, &scale.label_large_emphasized),
279            (&scale.label_medium, &scale.label_medium_emphasized),
280            (&scale.label_small, &scale.label_small_emphasized),
281        ];
282        for (base, emphasized) in pairs {
283            assert_ne!(
284                base.weight, emphasized.weight,
285                "expected emphasized weight to differ from base weight"
286            );
287        }
288    }
289
290    #[test]
291    fn emphasized_keeps_base_size_and_line_height() {
292        // Only weight (and, for a few roles, letter spacing) shift between
293        // a role's baseline and emphasized token — size and line height
294        // are unchanged (see module docs' M3-deltas paragraph).
295        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
296        assert_eq!(
297            scale.display_large.size,
298            scale.display_large_emphasized.size
299        );
300        assert_eq!(
301            scale.display_large.line_height,
302            scale.display_large_emphasized.line_height
303        );
304        assert_eq!(scale.label_small.size, scale.label_small_emphasized.size);
305        assert_eq!(
306            scale.label_small.line_height,
307            scale.label_small_emphasized.line_height
308        );
309    }
310
311    #[test]
312    fn display_large_emphasized_is_medium_weight() {
313        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
314        assert_eq!(scale.display_large_emphasized.weight, FontWeight::MEDIUM);
315        assert_eq!(scale.display_large_emphasized.letter_spacing, 0.0);
316    }
317
318    #[test]
319    fn title_medium_emphasized_is_bold_weight() {
320        // title_medium's baseline weight is already Medium (500), so its
321        // emphasized sibling steps up to Bold (700), not Medium again.
322        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
323        assert_eq!(scale.title_medium.weight, FontWeight::MEDIUM);
324        assert_eq!(scale.title_medium_emphasized.weight, FontWeight::BOLD);
325    }
326
327    #[test]
328    fn label_small_emphasized_is_bold_weight() {
329        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
330        assert_eq!(scale.label_small.weight, FontWeight::MEDIUM);
331        assert_eq!(scale.label_small_emphasized.weight, FontWeight::BOLD);
332    }
333
334    #[test]
335    fn body_large_emphasized_tracking_differs_from_base() {
336        // body_large is the one role whose emphasized letter spacing also
337        // differs from its own baseline (0.5px -> 0.15px), per the primary
338        // source's BodyLargeEmphasizedTracking (see module docs).
339        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
340        assert_eq!(scale.body_large.letter_spacing, 0.5);
341        assert_eq!(scale.body_large_emphasized.letter_spacing, 0.15);
342    }
343
344    // ---- Neutral scale ----------------------------------------------------
345
346    #[test]
347    fn neutral_uses_no_bundled_fonts() {
348        // Every slot names only a generic/system family, never "Space
349        // Mono"/"IBM Plex Mono" or any other concrete named font.
350        let scale = TypeScale::neutral(&TextStyle::new(16.0, Color::BLACK));
351        let expected =
352            FontFamily::stack_with_generic(std::iter::empty::<&str>(), GenericSlot::SansSerif);
353        for family in [
354            &scale.display_large.family,
355            &scale.headline_medium.family,
356            &scale.title_large.family,
357            &scale.body_large.family,
358            &scale.label_small.family,
359            &scale.body_large_emphasized.family,
360        ] {
361            assert_eq!(family, &expected);
362            match family {
363                FontFamily::NamedWithGeneric(parts) => {
364                    // No `FamilyName::Named` entry — a generic-only stack.
365                    assert!(
366                        !parts
367                            .iter()
368                            .any(|p| matches!(p, frust_text::FamilyName::Named(_)))
369                    );
370                }
371                other => panic!("expected a generic-only stack, got {other:?}"),
372            }
373        }
374    }
375
376    #[test]
377    fn neutral_preserves_base_color_but_overrides_family() {
378        let base = TextStyle {
379            family: FontFamily::named("Roboto"),
380            ..TextStyle::new(16.0, Color::from_rgb8(1, 2, 3))
381        };
382        let scale = TypeScale::neutral(&base);
383        assert_ne!(scale.body_large.family, base.family);
384        assert_eq!(scale.body_large.color, base.color);
385    }
386}