Skip to main content

frust_theme/
elevation.rs

1//! [`Elevation::neutral`]: the design-language-free 6-level (0-5) elevation
2//! table, each level a dp value, a v1 shadow mapping, and the
3//! surface-container role a surface at that level should paint with.
4//!
5//! This crate constructs no other `Elevation` — a design system builds its
6//! own from its own plugin crate (`frust-material`'s `tokens` module carries
7//! the Material 3 table these numbers originate from, source cited there;
8//! `frust-cupertino`'s carries the subtler iOS-idiom shadow mapping over the
9//! same dp ladder). The dp values and shadow math below are the M3 numbers
10//! reused verbatim (see `crate::theme::Theme::neutral`'s module docs).
11//!
12//! dp source: <https://m3.material.io/styles/elevation> (verified
13//! 2026-07-17): L0 0dp, L1 1dp, L2 3dp, L3 6dp, L4 8dp, L5 12dp. Component
14//! mapping per the same source: L1 = elevated cards/bottom sheets, L2 = nav
15//! bar/menus, L3 = FAB/dialogs.
16//!
17//! **Shadow math is TUNABLE, not an M3-published spec.** Material 3's 2023
18//! direction replaced tonal-overlay tinting with *static surface-container
19//! roles* for most elevated surfaces — the opposite of "primarily tonal
20//! overlay post-2023" — but shadows still exist alongside; M3 does not
21//! publish exact shadow blur/offset math, so this module defines a
22//! documented v1 mapping:
23//! `y_offset = dp / 2.0 + 1.0`, `blur_std_dev = dp`, shadow color =
24//! `ColorScheme::shadow` at `color_alpha` ~0.3. A gallery example
25//! is the visual check for this mapping; treat it as adjustable, not load-
26//! bearing, Frust-specific policy.
27//!
28//! # Per-brightness shadows
29//!
30//! [`ElevationLevel`] carries **separate** light/dark [`ShadowSpec`]s
31//! (`shadow_light`/`shadow_dark`), selected via [`ElevationLevel::shadow`].
32//! [`Elevation::neutral`] duplicates the same v1 mapping into both slots —
33//! behavior-preserving, byte-identical rendered output on either brightness.
34//! A design language whose shadow recipe actually differs by brightness
35//! (e.g. Glyph) fills the two slots independently.
36
37use crate::color::Brightness;
38
39/// Y-offset, Gaussian blur standard deviation, and shadow color alpha for
40/// one elevation level's drop shadow. All lengths in logical px; `color_alpha`
41/// multiplies against `ColorScheme::shadow`'s own alpha (usually opaque
42/// black) to get the final translucency — see module docs for the v1
43/// mapping this crate uses.
44#[derive(Clone, Copy, Debug, PartialEq)]
45pub struct ShadowSpec {
46    pub y_offset: f64,
47    pub blur_std_dev: f64,
48    pub color_alpha: f32,
49}
50
51/// Which `ColorScheme` surface-container role an elevated surface at a given
52/// level should paint with — the 2023 static-surface-container-role
53/// direction (see module docs), not tonal-overlay tinting.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum SurfaceRole {
56    Surface,
57    SurfaceContainerLowest,
58    SurfaceContainerLow,
59    SurfaceContainer,
60    SurfaceContainerHigh,
61    SurfaceContainerHighest,
62}
63
64/// One elevation level: its dp value, per-brightness v1 shadow specs, and
65/// surface-container role.
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct ElevationLevel {
68    pub dp: f64,
69    /// Shadow rendered on [`Brightness::Light`].
70    pub shadow_light: ShadowSpec,
71    /// Shadow rendered on [`Brightness::Dark`].
72    pub shadow_dark: ShadowSpec,
73    pub surface_role: SurfaceRole,
74}
75
76impl ElevationLevel {
77    /// The `ShadowSpec` to render for the given brightness.
78    pub fn shadow(&self, brightness: Brightness) -> &ShadowSpec {
79        match brightness {
80            Brightness::Light => &self.shadow_light,
81            Brightness::Dark => &self.shadow_dark,
82        }
83    }
84}
85
86const fn level(dp: f64, surface_role: SurfaceRole) -> ElevationLevel {
87    let shadow = ShadowSpec {
88        y_offset: dp / 2.0 + 1.0,
89        blur_std_dev: dp,
90        color_alpha: 0.3,
91    };
92    ElevationLevel {
93        dp,
94        shadow_light: shadow,
95        shadow_dark: shadow,
96        surface_role,
97    }
98}
99
100/// The 6 elevation levels (0-5).
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct Elevation {
103    pub level0: ElevationLevel,
104    pub level1: ElevationLevel,
105    pub level2: ElevationLevel,
106    pub level3: ElevationLevel,
107    pub level4: ElevationLevel,
108    pub level5: ElevationLevel,
109}
110
111impl Elevation {
112    /// The neutral, design-language-free elevation table (dp values
113    /// verified; shadow math and surface-role assignment are this crate's
114    /// documented v1 mapping — see module docs).
115    pub const fn neutral() -> Self {
116        Self {
117            level0: level(0.0, SurfaceRole::Surface),
118            level1: level(1.0, SurfaceRole::SurfaceContainerLow),
119            level2: level(3.0, SurfaceRole::SurfaceContainer),
120            level3: level(6.0, SurfaceRole::SurfaceContainerHigh),
121            level4: level(8.0, SurfaceRole::SurfaceContainerHigh),
122            level5: level(12.0, SurfaceRole::SurfaceContainerHighest),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn dp_matches_table() {
133        let e = Elevation::neutral();
134        assert_eq!(e.level0.dp, 0.0);
135        assert_eq!(e.level1.dp, 1.0);
136        assert_eq!(e.level2.dp, 3.0);
137        assert_eq!(e.level3.dp, 6.0);
138        assert_eq!(e.level4.dp, 8.0);
139        assert_eq!(e.level5.dp, 12.0);
140    }
141
142    #[test]
143    fn shadow_mapping_is_consistent_with_dp() {
144        let e = Elevation::neutral();
145        assert_eq!(e.level3.shadow_light.y_offset, 4.0);
146        assert_eq!(e.level3.shadow_light.blur_std_dev, 6.0);
147        assert_eq!(e.level3.shadow_light.color_alpha, 0.3);
148    }
149
150    #[test]
151    fn neutral_shadow_is_identical_on_both_brightnesses() {
152        // Behavior-preserving: the v1 mapping doesn't branch by brightness,
153        // so both slots hold the same value and the accessor returns it
154        // either way.
155        let e = Elevation::neutral();
156        assert_eq!(e.level3.shadow_light, e.level3.shadow_dark);
157        assert_eq!(
158            e.level3.shadow(Brightness::Light),
159            e.level3.shadow(Brightness::Dark)
160        );
161        assert_eq!(*e.level3.shadow(Brightness::Light), e.level3.shadow_light);
162    }
163
164    #[test]
165    fn surface_roles_match_static_container_direction() {
166        let e = Elevation::neutral();
167        assert_eq!(e.level0.surface_role, SurfaceRole::Surface);
168        assert_eq!(e.level3.surface_role, SurfaceRole::SurfaceContainerHigh);
169        assert_eq!(e.level5.surface_role, SurfaceRole::SurfaceContainerHighest);
170    }
171}