use crate::style::{AdaptiveTint, Border, CornerRadius, Paint, Sheen, SpecularEdge, Style};
use crate::theme::Theme;
use crate::tokens::{Elevation, R_FULL, R_LG, R_SM, R_XL, RadiusScale, ShadowToken};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Surface {
Card,
Raised,
Popover,
Menu,
Modal,
Thumb,
Tooltip,
Glass,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum SurfaceRadius {
Uniform(f32),
}
impl SurfaceRadius {
pub const fn outer(self) -> f32 {
match self {
SurfaceRadius::Uniform(r) => r,
}
}
pub fn inner(self, inset: f32) -> f32 {
(self.outer() - inset).max(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SurfaceFill {
SurfaceRaised,
Elevated(u8),
Inverted,
}
impl SurfaceFill {
pub fn color(self, theme: &Theme) -> crate::Color {
match self {
SurfaceFill::SurfaceRaised => theme.surface_raised,
SurfaceFill::Elevated(level) => theme.elevated_surface(level),
SurfaceFill::Inverted => theme.neutrals.step(12),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SurfaceBorder {
None,
Subtle,
Default,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Material {
pub fill_alpha: f32,
pub blur_radius: f32,
pub saturation: f32,
}
impl Material {
pub const fn new(fill_alpha: f32, blur_radius: f32, saturation: f32) -> Self {
Self {
fill_alpha,
blur_radius,
saturation,
}
}
pub const fn popover() -> Self {
Self::new(0.82, 18.0, 1.5)
}
#[must_use]
pub fn tint(self, base: crate::Color) -> crate::Color {
let [l, c, h] = crate::oklch_of(base);
let saturated = crate::theme::oklch(l, c * self.saturation.max(0.0), h);
saturated.with_alpha(self.fill_alpha.clamp(0.0, 1.0))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceBundle {
pub radius: SurfaceRadius,
pub fill: SurfaceFill,
pub border: SurfaceBorder,
pub shadow: Option<ShadowToken>,
pub highlight: Option<f32>,
pub material: Option<Material>,
}
impl Surface {
pub const fn bundle(self) -> SurfaceBundle {
match self {
Surface::Card => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Sm),
highlight: None,
material: None,
},
Surface::Raised => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Subtle,
shadow: None,
highlight: None,
material: None,
},
Surface::Popover | Surface::Menu => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::Elevated(2),
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Lg),
highlight: None,
material: None,
},
Surface::Modal => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_XL),
fill: SurfaceFill::Elevated(2),
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Xl),
highlight: None,
material: None,
},
Surface::Glass => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::Elevated(2),
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Lg),
highlight: Some(0.16),
material: Some(Material::popover()),
},
Surface::Thumb => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_FULL),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Default,
shadow: Some(ShadowToken::Sm),
highlight: None,
material: None,
},
Surface::Tooltip => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_SM),
fill: SurfaceFill::Inverted,
border: SurfaceBorder::None,
shadow: Some(ShadowToken::Md),
highlight: None,
material: None,
},
}
}
#[must_use]
pub fn radius_px(self, radius: &RadiusScale) -> f32 {
match self {
Surface::Modal => radius.xl,
Surface::Thumb => R_FULL,
Surface::Tooltip => radius.sm,
Surface::Card | Surface::Raised | Surface::Popover | Surface::Menu | Surface::Glass => {
radius.lg
}
}
}
pub const fn is_floating(self) -> bool {
matches!(
self,
Surface::Popover | Surface::Menu | Surface::Modal | Surface::Glass
)
}
}
impl SurfaceBundle {
#[must_use]
pub fn apply(self, theme: &Theme, base: Style) -> Style {
let mut style = base;
style.corner_radius = CornerRadius::all(self.radius.outer());
let fill_color = self.fill.color(theme);
let resolved = match self.material {
Some(m) => m.tint(fill_color),
None => fill_color,
};
style.fill = Some(Paint::Solid(resolved));
style.border = match self.border {
SurfaceBorder::None => None,
SurfaceBorder::Subtle => Some(Border {
width: 1.0,
color: theme.border_subtle,
}),
SurfaceBorder::Default => Some(Border {
width: 1.0,
color: theme.border,
}),
};
style.shadow_token = self.shadow;
style.highlight_top = self
.highlight
.map(|a| crate::theme::oklch(1.0, 0.0, 0.0).with_alpha(a));
style.backdrop_blur = self.material.map(|m| m.blur_radius).filter(|r| *r > 0.0);
if self.material.is_some() {
style.specular_edge = Some(SpecularEdge::glass());
style.sheen = Some(Sheen::glass());
style.adaptive_tint = Some(AdaptiveTint::glass());
}
style
}
}
impl Theme {
pub fn surface_style(&self, role: Surface) -> Style {
let mut style = role
.bundle()
.apply(self, Style::default())
.rounded(role.radius_px(&self.radius));
if self.elevation == Elevation::Flat && matches!(role, Surface::Card | Surface::Raised) {
style.shadow_token = None;
}
style
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{Border, CornerRadius, Edges, Paint};
use crate::tokens::{R_FULL, R_LG, R_MD, R_XL, SP1, ShadowToken};
#[test]
fn card_bundle_is_resting_raised() {
let b = Surface::Card.bundle();
assert_eq!(b.radius, SurfaceRadius::Uniform(R_LG));
assert_eq!(b.fill, SurfaceFill::SurfaceRaised);
assert_eq!(b.border, SurfaceBorder::Subtle);
assert_eq!(b.shadow, Some(ShadowToken::Sm));
assert_eq!(b.highlight, None);
}
#[test]
fn floating_roles_use_elevated_fill_and_deep_shadows() {
for role in [Surface::Popover, Surface::Menu] {
assert_eq!(role.bundle().fill, SurfaceFill::Elevated(2));
assert_eq!(role.bundle().shadow, Some(ShadowToken::Lg));
}
let m = Surface::Modal.bundle();
assert_eq!(m.fill, SurfaceFill::Elevated(2));
assert_eq!(m.shadow, Some(ShadowToken::Xl));
assert_eq!(m.radius.outer(), R_XL);
}
#[test]
fn ordering_invariant_floating_ge_resting() {
for f in [
Surface::Popover,
Surface::Menu,
Surface::Modal,
Surface::Glass,
] {
for r in [Surface::Card, Surface::Raised] {
assert!(
f.bundle().radius.outer() >= r.bundle().radius.outer(),
"{f:?} radius must be >= {r:?}"
);
assert!(
f.bundle().shadow >= r.bundle().shadow,
"{f:?} shadow must be >= {r:?}"
);
}
}
}
#[test]
fn resolve_uses_theme_roles_not_literals() {
for t in [Theme::light(), Theme::dark()] {
let card = Surface::Card.bundle().apply(&t, Style::default());
assert_eq!(card.fill, Some(Paint::Solid(t.surface_raised)));
assert_eq!(
card.border,
Some(Border {
width: 1.0,
color: t.border_subtle,
})
);
assert_eq!(card.shadow_token, Some(ShadowToken::Sm));
assert_eq!(card.corner_radius, CornerRadius::all(R_LG));
let menu = Surface::Menu.bundle().apply(&t, Style::default());
assert_eq!(menu.fill, Some(Paint::Solid(t.elevated_surface(2))));
let tip = Surface::Tooltip.bundle().apply(&t, Style::default());
assert_eq!(tip.fill, Some(Paint::Solid(t.neutrals.step(12))));
assert_eq!(tip.border, None);
}
}
#[test]
fn highlight_resolves_to_low_alpha_white() {
let t = Theme::light();
let bundle = SurfaceBundle {
highlight: Some(0.14),
..Surface::Card.bundle()
};
let s = bundle.apply(&t, Style::default());
let c = s.highlight_top.expect("highlight set");
let [r, g, b, a] = c.components;
assert!((a - 0.14).abs() < 1e-4, "alpha {a}");
assert!(
(r - g).abs() < 1e-3 && (g - b).abs() < 1e-3,
"achromatic: {r} {g} {b}"
);
assert!(r > 0.9, "near-white: {r}");
}
#[test]
fn thumb_is_a_pill_with_a_firm_border() {
let b = Surface::Thumb.bundle();
assert_eq!(b.radius.outer(), R_FULL);
assert_eq!(b.border, SurfaceBorder::Default);
assert_eq!(b.shadow, Some(ShadowToken::Sm));
}
#[test]
fn surface_radius_is_concentric_ready() {
assert!(matches!(
Surface::Card.bundle().radius,
SurfaceRadius::Uniform(_)
));
}
#[test]
fn concentric_inner_is_outer_minus_inset() {
assert_eq!(SurfaceRadius::Uniform(14.0).inner(4.0), 10.0);
assert_eq!(SurfaceRadius::Uniform(2.0).inner(8.0), 0.0);
}
#[test]
fn menu_item_radius_is_concentric_with_panel() {
let r = Surface::Menu.bundle().radius;
assert_eq!(r.inner(SP1), r.outer() - SP1);
assert_eq!(r.inner(SP1), R_MD);
}
#[test]
fn theme_surface_style_equals_bundle_apply() {
let t = Theme::light();
assert_eq!(
t.surface_style(Surface::Modal),
Surface::Modal.bundle().apply(&t, Style::default())
);
}
#[test]
fn surface_apply_preserves_layout() {
let t = Theme::light();
let s = Surface::Card.bundle().apply(&t, Style::default().p(8.0));
assert_eq!(s.padding, Edges::all(8.0));
assert!(s.fill.is_some());
}
fn hue_delta(a: f32, b: f32) -> f32 {
let d = (a - b).abs() % 360.0;
d.min(360.0 - d)
}
fn composite_over_opaque(fg: crate::Color, bg: crate::Color) -> crate::Color {
let f = fg.components;
let b = bg.components;
let a = f[3];
crate::Color::new([
f[0] * a + b[0] * (1.0 - a),
f[1] * a + b[1] * (1.0 - a),
f[2] * a + b[2] * (1.0 - a),
1.0,
])
}
#[test]
fn material_tint_is_translucent_theme_derived_and_in_gamut() {
for t in [Theme::light(), Theme::dark()] {
let base = t.elevated_surface(2);
let tint = Material::popover().tint(base);
assert!(
(tint.components[3] - 0.82).abs() < 1e-6,
"alpha == fill_alpha: {}",
tint.components[3]
);
let [bl, bc, _] = crate::oklch_of(base);
let [tl, tc, _] = crate::oklch_of(tint);
assert!((tl - bl).abs() < 1e-3, "L kept: {tl} vs {bl}");
assert!(tc >= bc - 1e-4, "chroma not reduced: {tc} vs {bc}");
for &ch in &tint.components[..3] {
assert!((0.0..=1.0).contains(&ch), "channel in gamut: {ch}");
}
let accent_h = crate::oklch_of(t.accent)[2];
let tinted_accent_h = crate::oklch_of(Material::popover().tint(t.accent))[2];
assert!(
hue_delta(tinted_accent_h, accent_h) < 1.0,
"hue kept on a chroma-rich color: {tinted_accent_h} vs {accent_h}"
);
}
}
#[test]
fn material_tint_is_deterministic() {
let t = Theme::dark();
let base = t.elevated_surface(2);
let m = Material::popover();
let m2 = m; let a = m.tint(base).to_rgba8();
let b = m.tint(base).to_rgba8();
let c = m2.tint(base).to_rgba8();
assert_eq!([a.r, a.g, a.b, a.a], [b.r, b.g, b.b, b.a]);
assert_eq!([a.r, a.g, a.b, a.a], [c.r, c.g, c.b, c.a]);
}
#[test]
fn material_alpha_clamps() {
let t = Theme::light();
let base = t.elevated_surface(2);
assert!(
(Material::new(1.5, 0.0, 1.0).tint(base).components[3] - 1.0).abs() < 1e-6,
"fill_alpha > 1 clamps to 1"
);
assert!(
Material::new(-0.2, 0.0, 1.0).tint(base).components[3].abs() < 1e-6,
"fill_alpha < 0 clamps to 0"
);
}
#[test]
fn glass_role_bundle_is_frosted_floating() {
let b = Surface::Glass.bundle();
assert_eq!(b.fill, SurfaceFill::Elevated(2));
assert_eq!(b.shadow, Some(ShadowToken::Lg));
assert_eq!(b.border, SurfaceBorder::Subtle);
assert_eq!(b.highlight, Some(0.16));
assert_eq!(b.material, Some(Material::popover()));
assert_eq!(b.radius, SurfaceRadius::Uniform(R_LG));
}
#[test]
fn glass_apply_fill_is_translucent_and_elevated() {
for t in [Theme::light(), Theme::dark()] {
let s = Surface::Glass.bundle().apply(&t, Style::default());
let Some(Paint::Solid(c)) = s.fill else {
panic!("glass fill should resolve to a solid paint");
};
assert!(c.components[3] < 1.0, "translucent: {}", c.components[3]);
let [bl, bc, _] = crate::oklch_of(t.elevated_surface(2));
let [cl, cc, _] = crate::oklch_of(c);
assert!(
(cl - bl).abs() < 1e-3,
"lightness of Elevated(2) kept: {cl} vs {bl}"
);
assert!(cc >= bc - 1e-4, "chroma not reduced: {cc} vs {bc}");
let hl = s.highlight_top.expect("glass sets a top highlight");
let [r, g, b, a] = hl.components;
assert!(
r > 0.9 && (r - g).abs() < 1e-3 && (g - b).abs() < 1e-3,
"near-white sheen: {r} {g} {b}"
);
assert!((0.0..0.3).contains(&a), "low-alpha sheen: {a}");
}
}
#[test]
fn glass_apply_sets_specular_edge_and_sheen() {
let t = Theme::light();
let glass = Surface::Glass.bundle().apply(&t, Style::default());
assert_eq!(
glass.specular_edge,
Some(crate::style::SpecularEdge::glass()),
"glass sets the specular rim"
);
assert_eq!(
glass.sheen,
Some(crate::style::Sheen::glass()),
"glass sets the body sheen"
);
assert_eq!(
glass.adaptive_tint,
Some(crate::style::AdaptiveTint::glass()),
"glass sets the backdrop-adaptive vibrancy"
);
for role in [
Surface::Card,
Surface::Raised,
Surface::Popover,
Surface::Menu,
Surface::Modal,
Surface::Thumb,
Surface::Tooltip,
] {
let s = role.bundle().apply(&t, Style::default());
assert_eq!(s.specular_edge, None, "{role:?} has no specular rim");
assert_eq!(s.sheen, None, "{role:?} has no sheen");
assert_eq!(s.adaptive_tint, None, "{role:?} has no adaptive tint");
}
}
#[test]
fn glass_is_floating_satisfies_ordering() {
assert!(Surface::Glass.is_floating());
for r in [Surface::Card, Surface::Raised] {
assert!(
Surface::Glass.bundle().radius.outer() >= r.bundle().radius.outer(),
"Glass radius >= {r:?}"
);
assert!(
Surface::Glass.bundle().shadow >= r.bundle().shadow,
"Glass shadow >= {r:?}"
);
}
}
#[test]
fn opaque_roles_unchanged() {
for t in [Theme::light(), Theme::dark()] {
for role in [
Surface::Card,
Surface::Raised,
Surface::Popover,
Surface::Menu,
Surface::Modal,
Surface::Thumb,
Surface::Tooltip,
] {
assert!(role.bundle().material.is_none(), "{role:?} has no material");
let s = role.bundle().apply(&t, Style::default());
assert_eq!(
s.fill,
Some(Paint::Solid(role.bundle().fill.color(&t))),
"{role:?} fill is the unmodified solid role color"
);
}
}
}
#[test]
fn glass_text_stays_legible() {
const PRIMARY: f64 = 75.0;
const SECONDARY: f64 = 55.0;
for t in [Theme::light(), Theme::dark()] {
let tint = Material::popover().tint(t.elevated_surface(2));
for bg in [t.accents.step(7), t.accents.step(10)] {
let comp = composite_over_opaque(tint, bg);
let label = crate::lc_abs(t.text, comp);
let hint = crate::lc_abs(t.text_muted, comp);
assert!(
label >= PRIMARY,
"glass label (text) Lc {label:.1} < {PRIMARY} over {:?}",
bg.to_rgba8()
);
assert!(
hint >= SECONDARY,
"glass hint (text_muted) Lc {hint:.1} < {SECONDARY} over {:?}",
bg.to_rgba8()
);
}
}
}
}