mod color;
mod recipes;
pub use color::{Oklch, Srgb, contrast};
pub use recipes::{RECIPES, Recipe, RecipeState};
pub const ALGORITHM_VERSION: u32 = 7;
pub const SCHEMA_VERSION: u32 = 1;
pub const DEFAULT_SEED: Srgb = Srgb::new(0x2f, 0x6f, 0xe4);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Mode {
Light,
Dark,
}
impl Mode {
pub fn name(self) -> &'static str {
match self {
Mode::Light => "light",
Mode::Dark => "dark",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ContrastPolicy {
Standard,
More,
}
impl ContrastPolicy {
pub fn name(self) -> &'static str {
match self {
ContrastPolicy::Standard => "standard",
ContrastPolicy::More => "more",
}
}
fn levels(self) -> Levels {
match self {
ContrastPolicy::Standard => Levels {
text: 4.5,
graphic: 3.0,
disabled: 3.0,
},
ContrastPolicy::More => Levels {
text: 7.0,
graphic: 4.5,
disabled: 4.5,
},
}
}
}
#[derive(Clone, Copy)]
struct Levels {
text: f64,
graphic: f64,
disabled: f64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Token {
pub name: &'static str,
pub color: Srgb,
pub alpha: u8,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Check {
pub foreground: &'static str,
pub background: &'static str,
pub ratio: f64,
pub required: f64,
}
impl Check {
pub fn passed(&self) -> bool {
self.ratio + 1e-9 >= self.required
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Snapshot {
pub schema_version: u32,
pub algorithm_version: u32,
pub seed: Srgb,
pub mode: Mode,
pub contrast: ContrastPolicy,
pub tokens: Vec<Token>,
pub checks: Vec<Check>,
pub fallbacks: Vec<String>,
}
impl Snapshot {
pub fn token(&self, name: &str) -> Option<Srgb> {
self.tokens
.iter()
.find(|token| token.name == name)
.map(|token| token.color)
}
pub fn all_checks_pass(&self) -> bool {
self.checks.iter().all(Check::passed)
}
}
const NEUTRAL_HUE: f64 = 286.0;
const SURFACE_CHROMA: f64 = 0.005;
const TEXT_CHROMA: f64 = 0.013;
const MAX_BRAND_CHROMA: f64 = 0.20;
const WHITE_ON_SHIFT: f64 = 0.12;
const SUCCESS: (f64, f64) = (145.0, 0.15);
const WARNING: (f64, f64) = (70.0, 0.16);
const ERROR: (f64, f64) = (25.0, 0.20);
struct Builder {
mode: Mode,
levels: Levels,
tokens: Vec<Token>,
checks: Vec<Check>,
fallbacks: Vec<String>,
}
impl Builder {
fn get(&self, name: &str) -> Srgb {
self.tokens
.iter()
.find(|token| token.name == name)
.unwrap_or_else(|| panic!("token `{name}` is solved before use"))
.color
}
fn put(&mut self, name: &'static str, color: Srgb) -> Srgb {
self.tokens.push(Token {
name,
color,
alpha: 255,
});
color
}
fn pick(&self, light: f64, dark: f64) -> f64 {
match self.mode {
Mode::Light => light,
Mode::Dark => dark,
}
}
fn fixed(&mut self, name: &'static str, color: Oklch) -> Srgb {
self.put(name, color.to_srgb())
}
fn solve(
&mut self,
name: &'static str,
target: Oklch,
level: f64,
backgrounds: &[&'static str],
) -> Srgb {
let against: Vec<Srgb> = backgrounds.iter().map(|bg| self.get(bg)).collect();
let color = match nearest(target, |color| {
against.iter().all(|bg| contrast(color, *bg) >= level)
}) {
Some(color) => color,
None => {
let fallback = neutral_extreme(&against);
self.fallbacks.push(format!(
"`{name}` cannot reach {level}:1 against {}; using {fallback}",
backgrounds.join(", ")
));
fallback
}
};
self.put(name, color);
for background in backgrounds {
self.check(name, background, level);
}
color
}
fn solve_pair(
&mut self,
name: &'static str,
on: &'static str,
target: Oklch,
surfaces: &[&'static str],
level: f64,
) {
let levels = Levels {
text: level,
..self.levels
};
let surface_colors: Vec<Srgb> = surfaces.iter().map(|s| self.get(s)).collect();
let dark = Oklch::new(0.18, TEXT_CHROMA, NEUTRAL_HUE).to_srgb();
let mut best: Option<(f64, Srgb, Srgb)> = None;
for foreground in [Srgb::WHITE, dark] {
let found = nearest(target, |color| {
contrast(color, foreground) >= levels.text
&& surface_colors
.iter()
.all(|surface| contrast(color, *surface) >= levels.graphic)
});
if let Some(color) = found {
let distance = (color.oklch().l - target.l).abs();
if foreground == Srgb::WHITE && distance <= WHITE_ON_SHIFT {
best = Some((distance, color, foreground));
break;
}
if best.is_none_or(|(d, _, _)| distance < d - 1e-9) {
best = Some((distance, color, foreground));
}
}
}
let (background, foreground) = match best {
Some((_, background, foreground)) => (background, foreground),
None => {
let foreground = Srgb::WHITE;
let background =
nearest(target, |color| contrast(color, foreground) >= levels.text)
.unwrap_or(Srgb::BLACK);
self.fallbacks.push(format!(
"`{name}` cannot reach {}:1 against {} while keeping `{on}` readable",
levels.graphic,
surfaces.join(", ")
));
(background, foreground)
}
};
self.put(name, background);
self.put(on, foreground);
self.check(on, name, levels.text);
for surface in surfaces {
self.check(name, surface, levels.graphic);
}
}
fn variant(
&mut self,
name: &'static str,
base: &str,
on: &'static str,
step: f64,
level: f64,
) -> Srgb {
let base_color = self.get(base);
let foreground = self.get(on);
let lch = base_color.oklch();
let direction = if foreground.luminance() > base_color.luminance() {
-1.0
} else {
1.0
};
let color = Oklch::new(lch.l + direction * step, lch.c, lch.h).to_srgb();
self.put(name, color);
self.check(on, name, level);
color
}
fn check(&mut self, foreground: &'static str, background: &'static str, required: f64) {
let ratio = contrast(self.get(foreground), self.get(background));
self.checks.push(Check {
foreground,
background,
ratio,
required,
});
}
}
fn nearest(target: Oklch, accept: impl Fn(Srgb) -> bool) -> Option<Srgb> {
const STEPS: i32 = 400;
for factor in [1.0, 0.75, 0.5, 0.25, 0.0] {
let chroma = target.c * factor;
for step in 0..=STEPS {
for sign in [1.0, -1.0] {
if step == 0 && sign < 0.0 {
continue;
}
let l = target.l + sign * f64::from(step) / f64::from(STEPS);
if !(0.0..=1.0).contains(&l) {
continue;
}
let color = Oklch::new(l, chroma, target.h).to_srgb();
if accept(color) {
return Some(color);
}
}
}
}
None
}
fn neutral_extreme(against: &[Srgb]) -> Srgb {
let worst = |color: Srgb| {
against
.iter()
.map(|bg| contrast(color, *bg))
.fold(f64::INFINITY, f64::min)
};
if worst(Srgb::BLACK) >= worst(Srgb::WHITE) {
Srgb::BLACK
} else {
Srgb::WHITE
}
}
pub const DARK_ON_ACCENT_ABOVE: f64 = 0.4;
pub fn native_accent(color: Srgb, mode: Mode) -> Srgb {
let level = ContrastPolicy::Standard.levels().graphic;
let neutral = generate(None, mode, ContrastPolicy::Standard);
let surfaces: Vec<Srgb> = ["surface.canvas", "surface.panel", "surface.raised"]
.into_iter()
.filter_map(|name| neutral.token(name))
.collect();
let near_black = Srgb::new(0x11, 0x11, 0x14);
let reads = |fill: Srgb| {
let on = if fill.luminance() > DARK_ON_ACCENT_ABOVE {
near_black
} else {
Srgb::WHITE
};
contrast(on, fill) >= level
&& surfaces
.iter()
.all(|surface| contrast(fill, *surface) >= level)
};
if reads(color) {
return color;
}
nearest(color.oklch(), reads).unwrap_or(color)
}
pub fn generate(seed: Option<Srgb>, mode: Mode, contrast_policy: ContrastPolicy) -> Snapshot {
let seed = seed.unwrap_or(DEFAULT_SEED);
let mut b = Builder {
mode,
levels: contrast_policy.levels(),
tokens: Vec::new(),
checks: Vec::new(),
fallbacks: Vec::new(),
};
let levels = b.levels;
let neutral = |l: f64, c: f64| Oklch::new(l, c, NEUTRAL_HUE);
let brand = seed.oklch();
let brand_chroma = brand.c.min(MAX_BRAND_CHROMA);
let brand_at = |l: f64, c: f64| Oklch::new(l, c, brand.h);
b.fixed("surface.host", neutral(b.pick(0.967, 0.11), SURFACE_CHROMA));
let canvas = b.pick(1.0, 0.141);
b.fixed("surface.canvas", neutral(canvas, SURFACE_CHROMA));
b.fixed(
"surface.panel",
neutral(b.pick(0.985, 0.169), SURFACE_CHROMA),
);
b.fixed("surface.raised", neutral(canvas, SURFACE_CHROMA));
let fill = b.pick(0.967, 0.21);
let fill_strong = b.pick(0.92, 0.274);
b.fixed("hover.overlay", neutral(fill, SURFACE_CHROMA));
b.fixed("pressed.overlay", neutral(fill_strong, SURFACE_CHROMA));
b.fixed("disabled.surface", neutral(fill, SURFACE_CHROMA));
b.fixed("separator", neutral(fill_strong, SURFACE_CHROMA));
b.fixed("button.standard.hover", neutral(fill, SURFACE_CHROMA));
b.fixed(
"button.standard.pressed",
neutral(fill_strong, SURFACE_CHROMA),
);
const READS_ON: &[&str] = &[
"surface.canvas",
"surface.panel",
"surface.raised",
"hover.overlay",
"pressed.overlay",
"button.standard.hover",
"button.standard.pressed",
];
b.solve(
"text.primary",
neutral(b.pick(0.141, 0.985), TEXT_CHROMA),
levels.text,
READS_ON,
);
b.solve(
"text.secondary",
neutral(b.pick(0.552, 0.712), TEXT_CHROMA),
levels.text,
READS_ON,
);
b.solve(
"text.placeholder",
neutral(b.pick(0.56, 0.68), TEXT_CHROMA),
levels.text,
READS_ON,
);
b.solve(
"icon.primary",
neutral(b.pick(0.141, 0.985), TEXT_CHROMA),
levels.graphic,
READS_ON,
);
b.solve(
"icon.secondary",
neutral(b.pick(0.552, 0.712), TEXT_CHROMA),
levels.graphic,
READS_ON,
);
const SURFACES: &[&str] = &["surface.canvas", "surface.panel", "surface.raised"];
let accent_l = brand.l.clamp(b.pick(0.45, 0.55), b.pick(0.62, 0.72));
b.solve_pair("accent", "on_accent", brand, SURFACES, levels.graphic);
b.solve(
"accent.text",
brand_at(accent_l, brand_chroma),
levels.text,
READS_ON,
);
b.solve(
"focus.indicator",
brand_at(b.pick(0.52, 0.74), brand_chroma),
levels.graphic,
SURFACES,
);
b.variant(
"button.prominent.hover",
"accent",
"on_accent",
b.pick(0.04, 0.04),
levels.graphic,
);
b.variant(
"button.prominent.pressed",
"accent",
"on_accent",
b.pick(0.08, 0.08),
levels.graphic,
);
b.solve(
"control.border",
neutral(b.pick(0.62, 0.52), SURFACE_CHROMA),
levels.graphic,
SURFACES,
);
b.solve(
"control.thumb",
neutral(b.pick(1.0, 0.98), SURFACE_CHROMA),
levels.graphic,
&["accent"],
);
b.solve(
"control.track",
neutral(b.pick(0.68, 0.58), SURFACE_CHROMA),
levels.graphic,
&[
"surface.canvas",
"surface.panel",
"surface.raised",
"control.thumb",
],
);
b.fixed("media.track", neutral(b.pick(0.9, 0.36), SURFACE_CHROMA));
b.solve(
"media.played",
neutral(b.pick(0.3, 0.9), TEXT_CHROMA),
levels.graphic,
&[
"media.track",
"surface.canvas",
"surface.panel",
"surface.raised",
],
);
for (name, (hue, chroma), light, dark) in [
("status.success", SUCCESS, 0.5, 0.78),
("status.warning", WARNING, 0.5, 0.8),
("status.error", ERROR, 0.5, 0.74),
] {
b.solve(
name,
Oklch::new(b.pick(light, dark), chroma, hue),
levels.text,
READS_ON,
);
}
b.solve_pair(
"status.error.surface",
"on_error",
Oklch::new(b.pick(0.55, 0.62), ERROR.1, ERROR.0),
&["surface.canvas", "surface.panel"],
levels.text,
);
b.variant(
"button.destructive.hover",
"status.error.surface",
"on_error",
0.04,
levels.text,
);
b.variant(
"button.destructive.pressed",
"status.error.surface",
"on_error",
0.08,
levels.text,
);
b.solve(
"disabled.foreground",
neutral(b.pick(0.66, 0.56), TEXT_CHROMA),
levels.disabled,
&[
"disabled.surface",
"surface.canvas",
"surface.panel",
"surface.raised",
],
);
b.tokens.push(Token {
name: "scrim",
color: Srgb::BLACK,
alpha: match mode {
Mode::Light => 0x59,
Mode::Dark => 0x80,
},
});
b.tokens.sort_by(|x, y| x.name.cmp(y.name));
Snapshot {
schema_version: SCHEMA_VERSION,
algorithm_version: ALGORITHM_VERSION,
seed,
mode,
contrast: contrast_policy,
tokens: b.tokens,
checks: b.checks,
fallbacks: b.fallbacks,
}
}
#[cfg(test)]
mod tests;