Skip to main content

gpui_kit_material/tokens/
elevation.rs

1//! M3 elevation shadows, painted with [`ColorRole::SHADOW`].
2//!
3//! Each level is the reference two-layer box shadow: a key layer at 30% and
4//! an ambient layer at 15% of the shadow role. Reference priority is Figma
5//! kit > md3.io > m3e.kyralabs.dev; the table is the Figma Material 3 Design
6//! Kit's local effect styles `M3/Elevation Light/1-5` and
7//! `M3/Elevation Dark/1-5` (file key ozddDB2Z1v51f1IFZlUuJ2), which share
8//! geometry and opacities in both themes and differ only in layer order.
9//! Fixture: `tests/fixtures/figma-sys-tokens.json`. md3.io and m3e
10//! (`tests/fixtures/m3e-sys-tokens.json`) agree.
11//!
12//! | Level | Key (x/y/blur/spread @ 30%) | Ambient (x/y/blur/spread @ 15%) |
13//! |-------|-----------------------------|---------------------------------|
14//! | 0     | none                        | none                            |
15//! | 1     | 0/1/2/0                     | 0/1/3/1                         |
16//! | 2     | 0/1/2/0                     | 0/2/6/2                         |
17//! | 3     | 0/1/3/0                     | 0/4/8/3                         |
18//! | 4     | 0/2/3/0                     | 0/6/10/4                        |
19//! | 5     | 0/4/4/0                     | 0/8/12/6                        |
20use crate::color::ColorRole;
21use crate::theme::color;
22use gpui::{BoxShadow, Hsla, px};
23
24/// The highest M3 elevation level.
25pub const MAX_LEVEL: u8 = 5;
26
27const KEY_OPACITY: f32 = 0.3;
28const AMBIENT_OPACITY: f32 = 0.15;
29
30// (y, blur, spread) of the key and ambient layers for levels 1..=5.
31const GEOMETRY: [[(f32, f32, f32); 2]; MAX_LEVEL as usize] = [
32    [(1., 2., 0.), (1., 3., 1.)],
33    [(1., 2., 0.), (2., 6., 2.)],
34    [(1., 3., 0.), (4., 8., 3.)],
35    [(2., 3., 0.), (6., 10., 4.)],
36    [(4., 4., 0.), (8., 12., 6.)],
37];
38
39fn shadow_color(opacity: f32) -> Hsla {
40    let mut shadow = color(ColorRole::SHADOW);
41    shadow.a *= opacity;
42    shadow.into()
43}
44
45fn layer(y: f32, blur: f32, spread: f32, opacity: f32) -> BoxShadow {
46    BoxShadow::new(px(0.), px(y), shadow_color(opacity))
47        .blur_radius(px(blur))
48        .spread_radius(px(spread))
49}
50
51/// Both layers of `level` (clamped to [`MAX_LEVEL`]), key first.
52///
53/// Level 0 is two transparent, zero-geometry entries: CSS pads `none` this
54/// way before interpolating shadow lists, so a transition to or from level 0
55/// keeps matched entries.
56pub fn layers(level: u8) -> [BoxShadow; 2] {
57    match level.min(MAX_LEVEL) {
58        0 => std::array::from_fn(|_| layer(0., 0., 0., 0.)),
59        level => {
60            let [key, ambient] = GEOMETRY[level as usize - 1];
61            [
62                layer(key.0, key.1, key.2, KEY_OPACITY),
63                layer(ambient.0, ambient.1, ambient.2, AMBIENT_OPACITY),
64            ]
65        }
66    }
67}
68
69/// The shadow list of `level`; level 0 paints no shadow.
70pub fn level(level: u8) -> Vec<BoxShadow> {
71    if level == 0 {
72        Vec::new()
73    } else {
74        layers(level).into()
75    }
76}
77
78/// M3 level 0: flat, no shadow.
79pub fn level0() -> Vec<BoxShadow> {
80    level(0)
81}
82
83/// M3 level 1: elevated cards, elevated buttons at rest.
84pub fn level1() -> Vec<BoxShadow> {
85    level(1)
86}
87
88/// M3 level 2: hovered elevated controls, menus.
89pub fn level2() -> Vec<BoxShadow> {
90    level(2)
91}
92
93/// M3 level 3: snackbars, dialogs, FABs at rest.
94pub fn level3() -> Vec<BoxShadow> {
95    level(3)
96}
97
98/// M3 level 4: hovered FABs.
99pub fn level4() -> Vec<BoxShadow> {
100    level(4)
101}
102
103/// M3 level 5.
104pub fn level5() -> Vec<BoxShadow> {
105    level(5)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn assert_layer(shadow: &BoxShadow, y: f32, blur: f32, spread: f32, opacity: f32) {
113        let mut expected = color(ColorRole::SHADOW);
114        expected.a *= opacity;
115        assert_eq!(shadow.offset.x, px(0.));
116        assert_eq!(shadow.offset.y, px(y));
117        assert_eq!(shadow.blur_radius, px(blur));
118        assert_eq!(shadow.spread_radius, px(spread));
119        assert_eq!(shadow.color, Hsla::from(expected));
120        assert!(!shadow.inset);
121    }
122
123    #[test]
124    fn levels_match_the_m3_two_layer_shadows() {
125        assert!(level0().is_empty());
126        let expected = [
127            [(1., 2., 0.), (1., 3., 1.)],
128            [(1., 2., 0.), (2., 6., 2.)],
129            [(1., 3., 0.), (4., 8., 3.)],
130            [(2., 3., 0.), (6., 10., 4.)],
131            [(4., 4., 0.), (8., 12., 6.)],
132        ];
133        let levels = [level1(), level2(), level3(), level4(), level5()];
134        for (shadows, [key, ambient]) in levels.iter().zip(expected) {
135            assert_eq!(shadows.len(), 2);
136            assert_layer(&shadows[0], key.0, key.1, key.2, 0.3);
137            assert_layer(&shadows[1], ambient.0, ambient.1, ambient.2, 0.15);
138        }
139        assert_eq!(level(9), level5(), "levels above 5 clamp");
140    }
141
142    #[test]
143    fn levels_match_the_figma_kit_effect_styles() {
144        let fixture: serde_json::Value =
145            serde_json::from_str(include_str!("../../tests/fixtures/figma-sys-tokens.json"))
146                .unwrap();
147        for theme in ["light", "dark"] {
148            for level in 1..=MAX_LEVEL {
149                let style = &fixture["elevation"][theme][level.to_string()];
150                let mut reference = style["layers"].as_array().unwrap().clone();
151                // Key (30%) first, as painted; the kit orders them per theme.
152                reference.sort_by(|a, b| {
153                    b["opacity"]
154                        .as_f64()
155                        .unwrap()
156                        .total_cmp(&a["opacity"].as_f64().unwrap())
157                });
158                let shadows = super::level(level);
159                assert_eq!(shadows.len(), reference.len(), "{theme} {level}");
160                for (shadow, layer) in shadows.iter().zip(&reference) {
161                    let value = |key: &str| layer[key].as_f64().unwrap() as f32;
162                    assert_eq!(value("x"), 0.);
163                    assert_eq!(layer["color"], "#000000", "the shadow role");
164                    assert_layer(
165                        shadow,
166                        value("y"),
167                        value("blur"),
168                        value("spread"),
169                        value("opacity"),
170                    );
171                }
172            }
173        }
174    }
175
176    #[test]
177    fn level_zero_pads_two_transparent_entries() {
178        for shadow in layers(0) {
179            assert_layer(&shadow, 0., 0., 0., 0.);
180        }
181    }
182}