refactor(k-ui): retire glass look (wallpaper, blur, shadow, gloss): flat surfaces; theme drops [wallpaper]/[glass] tokens + orb contrast checks; light accent back to #0b6bcb; settings/tally 3 MiB

This commit is contained in:
2026-10-28 15:09:38 +01:00
parent 7804934008
commit 8c518b84aa
15 changed files with 67 additions and 667 deletions

View File

@@ -1,9 +1,6 @@
//! Contrast as WCAG 2 measures it: the ratio of two colours' relative
//! luminance, from 1 (the same) to 21 (black on white). Text needs 4.5,
//! other things the eye must find (the focus ring, a toggle's track) 3.
//! What text sits on is not one colour: the wallpaper glows with the orbs,
//! and glass panels let it through, so each is checked over the plain
//! background and over every orb.
use crate::parse::Error;
use crate::{Rgba, Theme};
@@ -25,48 +22,25 @@ pub fn ratio(a: Rgba, b: Rgba) -> f32 {
const TEXT: f32 = 4.5;
const SHAPE: f32 = 3.0;
/// Pairings drawn on solid colours, each with what it must reach.
fn solid(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 4] {
/// Every pairing K-UI draws, each with what it must reach.
fn pairings(t: &Theme) -> [(&'static str, Rgba, Rgba, f32); 10] {
[
("text_primary on background", t.text_primary, t.background, TEXT),
("text_primary on surface", t.text_primary, t.surface, TEXT),
("text_primary on surface_raised", t.text_primary, t.surface_raised, TEXT),
("text_secondary on background", t.text_secondary, t.background, TEXT),
("text_secondary on surface", t.text_secondary, t.surface, TEXT),
("accent on background", t.accent, t.background, TEXT),
("on_accent on accent", t.on_accent, t.accent, TEXT),
("on_destructive on destructive", t.on_destructive, t.destructive, TEXT),
("accent on surface", t.accent, t.surface, SHAPE),
("focus on background", t.focus, t.background, SHAPE),
]
}
/// Pairings drawn on the wallpaper (`w`) and on glass over it (`g`).
fn over(t: &Theme, w: Rgba, g: Rgba) -> [(&'static str, Rgba, Rgba, f32); 6] {
[
("text_primary on the wallpaper", t.text_primary, w, TEXT),
("text_secondary on the wallpaper", t.text_secondary, w, TEXT),
("accent on the wallpaper", t.accent, w, TEXT),
("focus on the wallpaper", t.focus, w, SHAPE),
("text_primary on glass", t.text_primary, g, TEXT),
("text_secondary on glass", t.text_secondary, g, TEXT),
]
}
fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
Rgba(m(a.0, b.0), m(a.1, b.1), m(a.2, b.2), 255)
}
/// The wallpaper where an orb is strongest.
pub fn wallpaper(t: &Theme, orb: Rgba) -> Rgba {
mix(t.background, orb, crate::ORB_STRENGTH)
}
/// A glass panel over `under`.
pub fn glass(t: &Theme, under: Rgba) -> Rgba {
mix(under, t.surface, t.panel_opacity as f32 / 100.0)
}
/// The theme, if everything drawn with it can be read.
pub fn check(t: Theme) -> Result<Theme, Error> {
let walls = [t.background].into_iter().chain(t.orbs.iter().map(|o| wallpaper(&t, *o)));
let pairs = walls.flat_map(|w| over(&t, w, glass(&t, w))).chain(solid(&t));
for (pair, fg, bg, min) in pairs {
for (pair, fg, bg, min) in pairings(&t) {
let r = ratio(fg, bg);
if r < min {
return Err(Error::Contrast { pair, ratio_x100: (r * 100.0) as u32 });

View File

@@ -17,7 +17,7 @@ pub mod wire;
mod tests;
pub use accent::with_accent;
pub use contrast::{check, glass, ratio, wallpaper};
pub use contrast::{check, ratio};
pub use parse::{Error, parse};
/// A colour as the theme names it: straight (not premultiplied) RGBA.
@@ -83,9 +83,6 @@ pub enum Weight {
SemiBold,
}
/// How strongly the orbs show through the background at their hearts.
pub const ORB_STRENGTH: f32 = 0.5;
/// A theme's name: what Settings lists and a user's choice records.
pub const NAME_BYTES: usize = types::name::BYTES;
@@ -103,8 +100,6 @@ pub struct Theme {
pub destructive: Rgba,
pub on_destructive: Rgba,
pub focus: Rgba,
/// Soft colours the wallpaper glows with, behind the glass.
pub orbs: [Rgba; 3],
pub corner_radius: f32,
/// The spacing scale's unit, in pixels.
pub spacing_base: f32,
@@ -113,11 +108,6 @@ pub struct Theme {
pub duration_normal: u16,
pub spring_stiffness: u16,
pub spring_damping: u16,
/// Pixels.
pub blur_radius: u8,
/// Percent.
pub panel_opacity: u8,
pub highlight: u8,
}
pub(crate) const fn name_bytes(s: &[u8]) -> ([u8; NAME_BYTES], usize) {
@@ -147,16 +137,12 @@ impl Theme {
destructive: Rgba::rgb(0xef5350),
on_destructive: Rgba::rgb(0x1a0a0a),
focus: Rgba::rgb(0xffffff),
orbs: [Rgba::rgb(0x3a1c71), Rgba::rgb(0x0f4c5c), Rgba::rgb(0x5b1a4a)],
corner_radius: 16.0,
spacing_base: 4.0,
duration_fast: 150,
duration_normal: 280,
spring_stiffness: 300,
spring_damping: 30,
blur_radius: 12,
panel_opacity: 72,
highlight: 18,
};
pub fn name(&self) -> &[u8] {

View File

@@ -29,16 +29,13 @@ const COLOURS: [&str; 10] = [
];
/// The whole numbers: table, key, and the range allowed.
const NUMBERS: [(&str, &str, u32, u32); 9] = [
const NUMBERS: [(&str, &str, u32, u32); 6] = [
("shape", "corner_radius", 0, 32),
("spacing", "base", 2, 8),
("motion", "duration_fast", 0, 2000),
("motion", "duration_normal", 0, 2000),
("motion", "spring_stiffness", 1, 2000),
("motion", "spring_damping", 1, 200),
("glass", "blur_radius", 0, 32),
("glass", "panel_opacity", 0, 100),
("glass", "highlight", 0, 100),
];
fn colour(s: &str) -> Option<Rgba> {
@@ -57,8 +54,7 @@ fn once<T>(slot: &mut Option<T>, value: Option<T>, line: usize) -> Result<(), To
pub fn parse(text: &str) -> Result<Theme, Error> {
let mut name: Option<([u8; NAME_BYTES], usize)> = None;
let mut colours: [Option<Rgba>; 10] = [None; 10];
let mut orbs: Option<[Rgba; 3]> = None;
let mut numbers: [Option<u32>; 9] = [None; 9];
let mut numbers: [Option<u32>; 6] = [None; 6];
toml::parse(text, |e| {
let line = e.line;
match (e.table, e.key, e.value) {
@@ -70,21 +66,12 @@ pub fn parse(text: &str) -> Result<Theme, Error> {
let i = COLOURS.iter().position(|k| *k == key).ok_or(Toml::Unknown { line })?;
once(&mut colours[i], colour(s), line)
}
("wallpaper", "orbs", Value::List(list)) => {
let mut out = [Rgba::rgb(0); 3];
let mut n = 0;
for s in list.iter() {
*out.get_mut(n).ok_or(Toml::Invalid { line })? = colour(s).ok_or(Toml::Invalid { line })?;
n += 1;
}
once(&mut orbs, (n == 3).then_some(out), line)
}
(table, key, Value::Int(v)) => {
let i = NUMBERS.iter().position(|(t, k, ..)| *t == table && *k == key).ok_or(Toml::Unknown { line })?;
let (_, _, lo, hi) = NUMBERS[i];
once(&mut numbers[i], (lo..=hi).contains(&v).then_some(v), line)
}
("meta" | "colour" | "wallpaper" | "shape" | "spacing" | "motion" | "glass", _, _) => Err(Toml::Invalid { line }),
("meta" | "colour" | "shape" | "spacing" | "motion", _, _) => Err(Toml::Invalid { line }),
_ => Err(Toml::Unknown { line }),
}
})
@@ -94,7 +81,7 @@ pub fn parse(text: &str) -> Result<Theme, Error> {
for (i, slot) in colours.iter().enumerate() {
c[i] = slot.ok_or(Error::Toml(Toml::Missing(COLOURS[i])))?;
}
let mut n = [0u32; 9];
let mut n = [0u32; 6];
for (i, slot) in numbers.iter().enumerate() {
n[i] = slot.ok_or(Error::Toml(Toml::Missing(NUMBERS[i].1)))?;
}
@@ -111,7 +98,6 @@ pub fn parse(text: &str) -> Result<Theme, Error> {
destructive: c[7],
on_destructive: c[8],
focus: c[9],
orbs: orbs.ok_or(Error::Toml(Toml::Missing("orbs")))?,
corner_radius: n[0] as f32,
spacing_base: n[1] as f32,
// In range, so these fit.
@@ -119,8 +105,5 @@ pub fn parse(text: &str) -> Result<Theme, Error> {
duration_normal: n[3] as u16,
spring_stiffness: n[4] as u16,
spring_damping: n[5] as u16,
blur_radius: n[6] as u8,
panel_opacity: n[7] as u8,
highlight: n[8] as u8,
})
}

View File

@@ -31,10 +31,7 @@ fn a_theme_survives_the_wire_but_its_name() {
#[test]
fn unreadable_pairings_are_refused() {
let grey = DARK.replace("text_secondary = \"#9e9eb8\"", "text_secondary = \"#3a3a4a\"");
assert!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on the wallpaper", .. })));
// An orb too bright for the text over it.
let glare = DARK.replace("\"#0f4c5c\"", "\"#f0f0f0\"");
assert!(matches!(check(parse(&glare).unwrap()), Err(Error::Contrast { .. })));
assert!(matches!(check(parse(&grey).unwrap()), Err(Error::Contrast { pair: "text_secondary on background", .. })));
assert!((ratio(Rgba::rgb(0), Rgba::rgb(0xffffff)) - 21.0).abs() < 0.01);
}
@@ -47,12 +44,11 @@ fn every_token_is_needed_and_checked() {
("corner_radius = 16", "corner_radius = 99"),
("base = 4", "base = \"4\""),
("\"#ffffff\"", "\"white\""),
("orbs = [\"#3a1c71\", \"#0f4c5c\", \"#5b1a4a\"]", "orbs = [\"#3a1c71\"]"),
] {
assert!(matches!(parse(&DARK.replace(from, to)), Err(Error::Toml(T::Invalid { .. }))), "{to}");
}
assert!(matches!(parse(&DARK.replace("highlight = 18", "glow = 18")), Err(Error::Toml(T::Unknown { .. }))));
assert!(matches!(parse(&std::format!("{DARK}\n[glass]\n")), Err(Error::Toml(T::Duplicate { .. }))));
assert!(matches!(parse(&DARK.replace("spring_damping = 30", "bounce = 30")), Err(Error::Toml(T::Unknown { .. }))));
assert!(matches!(parse(&std::format!("{DARK}\n[motion]\n")), Err(Error::Toml(T::Duplicate { .. }))));
}
#[test]

View File

@@ -5,7 +5,7 @@ use crate::{Rgba, Theme};
pub const WORDS: usize = types::display::window::THEME_WORDS;
fn colours(t: &Theme) -> [Rgba; 12] {
fn colours(t: &Theme) -> [Rgba; 10] {
[
t.background,
t.surface,
@@ -17,34 +17,27 @@ fn colours(t: &Theme) -> [Rgba; 12] {
t.destructive,
t.on_destructive,
t.focus,
t.orbs[0],
t.orbs[1],
]
}
pub fn encode(t: &Theme) -> [usize; WORDS] {
let c = colours(t);
let mut w = [0usize; WORDS];
for i in 0..6 {
for i in 0..5 {
w[i] = c[2 * i].to_rgb() as usize | (c[2 * i + 1].to_rgb() as usize) << 32;
}
w[6] = t.corner_radius as usize
// In range (see `parse`): radius and base a byte each, damping too.
w[5] = t.corner_radius as usize
| (t.spacing_base as usize) << 8
| (t.blur_radius as usize) << 16
| (t.panel_opacity as usize) << 24
| (t.highlight as usize) << 32
| (t.duration_fast as usize) << 40;
w[7] = t.duration_normal as usize
| (t.spring_stiffness as usize) << 16
| (t.spring_damping as usize & 0xff) << 32
| (t.orbs[2].to_rgb() as usize & 0xff_ffff) << 40;
| (t.spring_damping as usize & 0xff) << 16
| (t.duration_fast as usize) << 24
| (t.duration_normal as usize) << 40;
w[6] = t.spring_stiffness as usize;
w
}
pub fn decode(w: [usize; WORDS]) -> Theme {
let c = |i: usize| Rgba::rgb((w[i / 2] >> (32 * (i % 2))) as u32 & 0xff_ffff);
let byte = |word: usize, at: u32| (w[word] >> at) as u8;
let half = |word: usize, at: u32| (w[word] >> at) as u16;
Theme {
name: [0; crate::NAME_BYTES],
name_len: 0,
@@ -58,16 +51,11 @@ pub fn decode(w: [usize; WORDS]) -> Theme {
destructive: c(7),
on_destructive: c(8),
focus: c(9),
orbs: [c(10), c(11), Rgba::rgb((w[7] >> 40) as u32 & 0xff_ffff)],
corner_radius: byte(6, 0) as f32,
spacing_base: byte(6, 8) as f32,
blur_radius: byte(6, 16),
panel_opacity: byte(6, 24),
highlight: byte(6, 32),
duration_fast: half(6, 40),
duration_normal: half(7, 0),
spring_stiffness: half(7, 16),
// At most 200 (see `parse`): a byte.
spring_damping: byte(7, 32) as u16,
corner_radius: (w[5] & 0xff) as f32,
spacing_base: ((w[5] >> 8) & 0xff) as f32,
spring_damping: ((w[5] >> 16) & 0xff) as u16,
duration_fast: (w[5] >> 24) as u16,
duration_normal: (w[5] >> 40) as u16,
spring_stiffness: w[6] as u16,
}
}