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}