Respect motion settings and improve accessible theme transitions
This commit is contained in:
1 parent
53bad8c672
commit
7a9fd5c864
13 files changed
+390
-67
No files matched your search
@@ -1027,6 +1027,7 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
|
||||
}
|
||||
if accessibility.increase_contrast {
|
||||
t.colors.border = hex(readable(rgba(&t.colors.border).unwrap(), surface, 3.));
|
||||
resolve_material_contrast(t, minimum);
|
||||
}
|
||||
if accessibility.reduce_transparency {
|
||||
t.material.style = "solid".into();
|
||||
@@ -1035,6 +1036,44 @@ fn transform(t: &mut Tokens, accessibility: &Accessibility, warnings: &mut Vec<S
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_material_contrast(tokens: &mut Tokens, minimum: f64) {
|
||||
if tokens.material.style == "solid" {
|
||||
return;
|
||||
}
|
||||
let surface = rgba(&tokens.colors.surface_base).unwrap();
|
||||
let bar = rgba(&tokens.surface.bar.background).unwrap();
|
||||
let raised = rgba(&tokens.colors.surface_raised).unwrap();
|
||||
let app = rgba(&tokens.colors.application_background).unwrap();
|
||||
let initial = tokens.material.opacity * tokens.material.tint_strength;
|
||||
let readable = |backgrounds: &[[f64; 4]]| {
|
||||
[[0., 0., 0., 1.], [1.; 4]].into_iter().any(|text| {
|
||||
backgrounds
|
||||
.iter()
|
||||
.all(|background| contrast(text, *background) >= minimum)
|
||||
})
|
||||
};
|
||||
let opacity = (0..=100)
|
||||
.map(|step| initial + (1. - initial) * f64::from(step) / 100.)
|
||||
.find(|opacity| {
|
||||
let bounds = material_bounds(surface, *opacity);
|
||||
readable(&[bounds[0], bounds[1], raised, app]) && readable(&material_bounds(bar, *opacity))
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
tokens.material.opacity = tokens.material.opacity.max(opacity);
|
||||
tokens.material.tint_strength = opacity / tokens.material.opacity.max(f64::EPSILON);
|
||||
let bounds = material_bounds(surface, opacity);
|
||||
let body = [bounds[0], bounds[1], raised, app];
|
||||
let bar = material_bounds(bar, opacity);
|
||||
for (color, backgrounds) in [
|
||||
(&mut tokens.colors.text_primary, body.as_slice()),
|
||||
(&mut tokens.colors.text_muted, body.as_slice()),
|
||||
(&mut tokens.surface.bar.text_primary, bar.as_slice()),
|
||||
(&mut tokens.surface.bar.text_muted, bar.as_slice()),
|
||||
] {
|
||||
*color = hex(readable_across(rgba(color).unwrap(), backgrounds, minimum));
|
||||
}
|
||||
}
|
||||
|
||||
fn material_bounds(mut surface: [f64; 4], opacity: f64) -> [[f64; 4]; 2] {
|
||||
surface[3] = opacity;
|
||||
[composite(surface, [0., 0., 0., 1.]), composite(surface, [1.; 4])]
|
||||
@@ -1064,9 +1103,12 @@ fn material_foreground(preferred: [f64; 4], backgrounds: [[f64; 4]; 2]) -> [f64;
|
||||
impl ResolvedTheme {
|
||||
/// Bound contrast across all backdrops instead of interpolating foregrounds.
|
||||
pub fn transition(&self, to: &Self, progress: f64) -> Self {
|
||||
if to.reduced_motion {
|
||||
if to.reduced_motion || self.accessibility != to.accessibility || progress >= 1.0 {
|
||||
return to.clone();
|
||||
}
|
||||
if progress <= 0.0 || self == to {
|
||||
return self.clone();
|
||||
}
|
||||
let p = progress.clamp(0., 1.);
|
||||
let p = p * p * (3. - 2. * p);
|
||||
let mut frame = to.clone();
|
||||
@@ -1089,6 +1131,42 @@ impl ResolvedTheme {
|
||||
frame.tokens.shadow.soft.opacity =
|
||||
self.tokens.shadow.soft.opacity + (to.tokens.shadow.soft.opacity - self.tokens.shadow.soft.opacity) * p;
|
||||
let preferred = if p < 0.5 { self } else { to };
|
||||
frame.appearance = preferred.appearance;
|
||||
if self.appearance == to.appearance {
|
||||
let body = [
|
||||
rgba(&frame.tokens.colors.surface_base).unwrap(),
|
||||
rgba(&frame.tokens.colors.surface_raised).unwrap(),
|
||||
rgba(&frame.tokens.colors.application_background).unwrap(),
|
||||
];
|
||||
let minimum = if to.accessibility.increase_contrast { 7.0 } else { 4.5 };
|
||||
frame.tokens.colors.text_primary = hex(readable_across(
|
||||
rgba(&preferred.tokens.colors.text_primary).unwrap(),
|
||||
&body,
|
||||
minimum,
|
||||
));
|
||||
frame.tokens.colors.text_muted = hex(readable_across(
|
||||
rgba(&preferred.tokens.colors.text_muted).unwrap(),
|
||||
&body,
|
||||
minimum,
|
||||
));
|
||||
let bar = rgba(&frame.tokens.surface.bar.background).unwrap();
|
||||
frame.tokens.surface.bar.text_primary = hex(readable(
|
||||
rgba(&preferred.tokens.surface.bar.text_primary).unwrap(),
|
||||
bar,
|
||||
minimum,
|
||||
));
|
||||
frame.tokens.surface.bar.text_muted = hex(readable(
|
||||
rgba(&preferred.tokens.surface.bar.text_muted).unwrap(),
|
||||
bar,
|
||||
minimum,
|
||||
));
|
||||
frame.tokens.colors.on_accent = hex(readable(
|
||||
rgba(&preferred.tokens.colors.on_accent).unwrap(),
|
||||
rgba(&frame.tokens.colors.accent).unwrap(),
|
||||
4.5,
|
||||
));
|
||||
return frame;
|
||||
}
|
||||
let base = rgba(&frame.tokens.colors.surface_base).unwrap();
|
||||
let raised = rgba(&frame.tokens.colors.surface_raised).unwrap();
|
||||
let app = rgba(&frame.tokens.colors.application_background).unwrap();
|
||||
@@ -1108,7 +1186,6 @@ impl ResolvedTheme {
|
||||
} else {
|
||||
frame.tokens.material.opacity * frame.tokens.material.tint_strength
|
||||
};
|
||||
// Glass spanning the neutral midpoint needs more tint to keep text readable.
|
||||
for step in 0..=100 {
|
||||
let candidate = opacity + (1. - opacity) * f64::from(step) / 100.;
|
||||
let backgrounds = material_bounds(surface, candidate);
|
||||
@@ -1502,7 +1579,7 @@ mod tests {
|
||||
.theme;
|
||||
for (from, to) in [(&dark, &light), (&light, &dark)] {
|
||||
for backdrop in [[0., 0., 0., 1.], [1., 1., 1., 1.], [0.2, 0.4, 0.7, 1.]] {
|
||||
for step in 0..=20 {
|
||||
for step in 1..20 {
|
||||
let frame = from.transition(to, f64::from(step) / 20.);
|
||||
let mut surface = rgba(&frame.tokens.colors.surface_base).unwrap();
|
||||
surface[3] = frame.tokens.material.opacity * frame.tokens.material.tint_strength;
|
||||
@@ -1546,6 +1623,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_contrast_covers_translucent_surfaces() {
|
||||
for mode in ["light", "dark"] {
|
||||
let resolved = candidate(&format!(r#"theme {{ family "tokyo-night"; mode "{mode}"; material {{ style "translucent"; opacity 0.52; }}; accessibility {{ increase-contrast #true; }}; }}"#)).unwrap().theme;
|
||||
let opacity = resolved.tokens.material.opacity * resolved.tokens.material.tint_strength;
|
||||
for (background, primary, muted) in [
|
||||
(
|
||||
&resolved.tokens.colors.surface_base,
|
||||
&resolved.tokens.colors.text_primary,
|
||||
&resolved.tokens.colors.text_muted,
|
||||
),
|
||||
(
|
||||
&resolved.tokens.surface.bar.background,
|
||||
&resolved.tokens.surface.bar.text_primary,
|
||||
&resolved.tokens.surface.bar.text_muted,
|
||||
),
|
||||
] {
|
||||
for backdrop in material_bounds(rgba(background).unwrap(), opacity) {
|
||||
for text in [primary, muted] {
|
||||
assert!(contrast(rgba(text).unwrap(), backdrop) >= 7.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transitions_preserve_endpoints_and_never_restyle_an_unchanged_theme() {
|
||||
let from = candidate(
|
||||
r#"theme { family "tokyo-night"; mode "dark"; material { style "translucent"; opacity 0.52; }; }"#,
|
||||
)
|
||||
.unwrap()
|
||||
.theme;
|
||||
let to =
|
||||
candidate(r#"theme { family "gruvbox"; mode "dark"; material { style "translucent"; opacity 0.52; }; }"#)
|
||||
.unwrap()
|
||||
.theme;
|
||||
assert_eq!(from.transition(&to, 0.0), from);
|
||||
assert_eq!(from.transition(&to, 1.0), to);
|
||||
for step in 0..=20 {
|
||||
let p = f64::from(step) / 20.0;
|
||||
assert_eq!(from.transition(&from, p), from);
|
||||
let frame = from.transition(&to, p);
|
||||
assert_eq!(frame.tokens.material.opacity, from.tokens.material.opacity);
|
||||
let low = from.tokens.material.tint_strength.min(to.tokens.material.tint_strength);
|
||||
let high = from.tokens.material.tint_strength.max(to.tokens.material.tint_strength);
|
||||
assert!((low..=high).contains(&frame.tokens.material.tint_strength));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_changes_publish_the_resolved_theme_without_a_transient_palette() {
|
||||
let from = candidate(r#"theme { family "tokyo-night"; mode "dark"; }"#)
|
||||
.unwrap()
|
||||
.theme;
|
||||
let to =
|
||||
candidate(r#"theme { family "tokyo-night"; mode "dark"; accessibility { increase-contrast #true; }; }"#)
|
||||
.unwrap()
|
||||
.theme;
|
||||
for step in 0..=20 {
|
||||
assert_eq!(from.transition(&to, f64::from(step) / 20.0), to);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduced_motion_cuts_and_policy_does_not_follow_brightness() {
|
||||
let mut light = candidate("theme { mode \"light\"; colors { surface-base \"#111111\"; }; }")
|
||||
|
||||
@@ -302,6 +302,7 @@ mod tests {
|
||||
|
||||
let palette = Palette {
|
||||
appearance: ferese_config::theme::Appearance::Dark,
|
||||
high_contrast: false,
|
||||
background: Color::from_rgb8(17, 24, 33),
|
||||
sidebar: Color::from_rgb8(17, 24, 33),
|
||||
card: Color::from_rgb8(17, 24, 33),
|
||||
|
||||
@@ -3,6 +3,7 @@ use cosmic::iced::Color;
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Palette {
|
||||
pub appearance: ferese_config::theme::Appearance,
|
||||
pub high_contrast: bool,
|
||||
pub background: Color,
|
||||
pub sidebar: Color,
|
||||
pub card: Color,
|
||||
@@ -19,6 +20,7 @@ impl Palette {
|
||||
let color = |value: &str| parse_color(value).expect("validated resolved theme color");
|
||||
Self {
|
||||
appearance: theme.appearance,
|
||||
high_contrast: theme.accessibility.increase_contrast,
|
||||
background: color(&theme.tokens.colors.application_background),
|
||||
sidebar: color(&theme.tokens.colors.surface_base),
|
||||
card: color(&theme.tokens.colors.surface_raised),
|
||||
@@ -43,10 +45,13 @@ impl Palette {
|
||||
|
||||
pub fn native_theme(self) -> cosmic::Theme {
|
||||
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
|
||||
let builder = if self.appearance == ferese_config::theme::Appearance::Light {
|
||||
cosmic::cosmic_theme::ThemeBuilder::light()
|
||||
} else {
|
||||
cosmic::cosmic_theme::ThemeBuilder::dark()
|
||||
use cosmic::cosmic_theme::ThemeBuilder;
|
||||
use ferese_config::theme::Appearance;
|
||||
let builder = match (self.appearance, self.high_contrast) {
|
||||
(Appearance::Light, false) => ThemeBuilder::light(),
|
||||
(Appearance::Dark, false) => ThemeBuilder::dark(),
|
||||
(Appearance::Light, true) => ThemeBuilder::light_high_contrast(),
|
||||
(Appearance::Dark, true) => ThemeBuilder::dark_high_contrast(),
|
||||
};
|
||||
let corners = cosmic::cosmic_theme::CornerRadii {
|
||||
radius_xs: [self.radius.min(4.); 4],
|
||||
@@ -108,6 +113,23 @@ pub fn surface_shade(base: Color) -> Color {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_theme_preserves_the_resolved_accessibility_mode() {
|
||||
for appearance in [
|
||||
ferese_config::theme::Appearance::Light,
|
||||
ferese_config::theme::Appearance::Dark,
|
||||
] {
|
||||
for high_contrast in [false, true] {
|
||||
let mut resolved = ferese_config::theme::ResolvedTheme::default();
|
||||
resolved.appearance = appearance;
|
||||
resolved.accessibility.increase_contrast = high_contrast;
|
||||
let palette = Palette::from_resolved(&resolved);
|
||||
assert_eq!(palette.high_contrast, high_contrast);
|
||||
assert_eq!(palette.native_theme().cosmic().is_high_contrast, high_contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
|
||||
for preset in crate::PRESETS {
|
||||
|
||||
Reference in new issue
Block a user