1mod color;
21mod recipes;
22
23pub use color::{Oklch, Srgb, contrast};
24pub use recipes::{RECIPES, Recipe, RecipeState};
25
26pub const ALGORITHM_VERSION: u32 = 7;
28
29pub const SCHEMA_VERSION: u32 = 1;
31
32pub const DEFAULT_SEED: Srgb = Srgb::new(0x2f, 0x6f, 0xe4);
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub enum Mode {
37 Light,
38 Dark,
39}
40
41impl Mode {
42 pub fn name(self) -> &'static str {
43 match self {
44 Mode::Light => "light",
45 Mode::Dark => "dark",
46 }
47 }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub enum ContrastPolicy {
53 Standard,
54 More,
56}
57
58impl ContrastPolicy {
59 pub fn name(self) -> &'static str {
60 match self {
61 ContrastPolicy::Standard => "standard",
62 ContrastPolicy::More => "more",
63 }
64 }
65
66 fn levels(self) -> Levels {
70 match self {
71 ContrastPolicy::Standard => Levels {
72 text: 4.5,
73 graphic: 3.0,
74 disabled: 3.0,
75 },
76 ContrastPolicy::More => Levels {
77 text: 7.0,
78 graphic: 4.5,
79 disabled: 4.5,
80 },
81 }
82 }
83}
84
85#[derive(Clone, Copy)]
86struct Levels {
87 text: f64,
88 graphic: f64,
89 disabled: f64,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct Token {
95 pub name: &'static str,
96 pub color: Srgb,
97 pub alpha: u8,
98}
99
100#[derive(Clone, Debug, PartialEq)]
102pub struct Check {
103 pub foreground: &'static str,
104 pub background: &'static str,
105 pub ratio: f64,
106 pub required: f64,
107}
108
109impl Check {
110 pub fn passed(&self) -> bool {
111 self.ratio + 1e-9 >= self.required
112 }
113}
114
115#[derive(Clone, Debug, PartialEq)]
117pub struct Snapshot {
118 pub schema_version: u32,
119 pub algorithm_version: u32,
120 pub seed: Srgb,
121 pub mode: Mode,
122 pub contrast: ContrastPolicy,
123 pub tokens: Vec<Token>,
125 pub checks: Vec<Check>,
126 pub fallbacks: Vec<String>,
128}
129
130impl Snapshot {
131 pub fn token(&self, name: &str) -> Option<Srgb> {
132 self.tokens
133 .iter()
134 .find(|token| token.name == name)
135 .map(|token| token.color)
136 }
137
138 pub fn all_checks_pass(&self) -> bool {
140 self.checks.iter().all(Check::passed)
141 }
142}
143
144const NEUTRAL_HUE: f64 = 286.0;
148const SURFACE_CHROMA: f64 = 0.005;
149const TEXT_CHROMA: f64 = 0.013;
150const MAX_BRAND_CHROMA: f64 = 0.20;
152const WHITE_ON_SHIFT: f64 = 0.12;
157
158const SUCCESS: (f64, f64) = (145.0, 0.15);
160const WARNING: (f64, f64) = (70.0, 0.16);
161const ERROR: (f64, f64) = (25.0, 0.20);
162
163struct Builder {
164 mode: Mode,
165 levels: Levels,
166 tokens: Vec<Token>,
167 checks: Vec<Check>,
168 fallbacks: Vec<String>,
169}
170
171impl Builder {
172 fn get(&self, name: &str) -> Srgb {
173 self.tokens
174 .iter()
175 .find(|token| token.name == name)
176 .unwrap_or_else(|| panic!("token `{name}` is solved before use"))
177 .color
178 }
179
180 fn put(&mut self, name: &'static str, color: Srgb) -> Srgb {
181 self.tokens.push(Token {
182 name,
183 color,
184 alpha: 255,
185 });
186 color
187 }
188
189 fn pick(&self, light: f64, dark: f64) -> f64 {
191 match self.mode {
192 Mode::Light => light,
193 Mode::Dark => dark,
194 }
195 }
196
197 fn fixed(&mut self, name: &'static str, color: Oklch) -> Srgb {
199 self.put(name, color.to_srgb())
200 }
201
202 fn solve(
206 &mut self,
207 name: &'static str,
208 target: Oklch,
209 level: f64,
210 backgrounds: &[&'static str],
211 ) -> Srgb {
212 let against: Vec<Srgb> = backgrounds.iter().map(|bg| self.get(bg)).collect();
213 let color = match nearest(target, |color| {
214 against.iter().all(|bg| contrast(color, *bg) >= level)
215 }) {
216 Some(color) => color,
217 None => {
218 let fallback = neutral_extreme(&against);
219 self.fallbacks.push(format!(
220 "`{name}` cannot reach {level}:1 against {}; using {fallback}",
221 backgrounds.join(", ")
222 ));
223 fallback
224 }
225 };
226 self.put(name, color);
227 for background in backgrounds {
228 self.check(name, background, level);
229 }
230 color
231 }
232
233 fn solve_pair(
240 &mut self,
241 name: &'static str,
242 on: &'static str,
243 target: Oklch,
244 surfaces: &[&'static str],
245 level: f64,
246 ) {
247 let levels = Levels {
248 text: level,
249 ..self.levels
250 };
251 let surface_colors: Vec<Srgb> = surfaces.iter().map(|s| self.get(s)).collect();
252 let dark = Oklch::new(0.18, TEXT_CHROMA, NEUTRAL_HUE).to_srgb();
253 let mut best: Option<(f64, Srgb, Srgb)> = None;
254 for foreground in [Srgb::WHITE, dark] {
255 let found = nearest(target, |color| {
256 contrast(color, foreground) >= levels.text
257 && surface_colors
258 .iter()
259 .all(|surface| contrast(color, *surface) >= levels.graphic)
260 });
261 if let Some(color) = found {
262 let distance = (color.oklch().l - target.l).abs();
263 if foreground == Srgb::WHITE && distance <= WHITE_ON_SHIFT {
264 best = Some((distance, color, foreground));
265 break;
266 }
267 if best.is_none_or(|(d, _, _)| distance < d - 1e-9) {
268 best = Some((distance, color, foreground));
269 }
270 }
271 }
272 let (background, foreground) = match best {
273 Some((_, background, foreground)) => (background, foreground),
274 None => {
275 let foreground = Srgb::WHITE;
277 let background =
278 nearest(target, |color| contrast(color, foreground) >= levels.text)
279 .unwrap_or(Srgb::BLACK);
280 self.fallbacks.push(format!(
281 "`{name}` cannot reach {}:1 against {} while keeping `{on}` readable",
282 levels.graphic,
283 surfaces.join(", ")
284 ));
285 (background, foreground)
286 }
287 };
288 self.put(name, background);
289 self.put(on, foreground);
290 self.check(on, name, levels.text);
291 for surface in surfaces {
292 self.check(name, surface, levels.graphic);
293 }
294 }
295
296 fn variant(
300 &mut self,
301 name: &'static str,
302 base: &str,
303 on: &'static str,
304 step: f64,
305 level: f64,
306 ) -> Srgb {
307 let base_color = self.get(base);
308 let foreground = self.get(on);
309 let lch = base_color.oklch();
310 let direction = if foreground.luminance() > base_color.luminance() {
311 -1.0
312 } else {
313 1.0
314 };
315 let color = Oklch::new(lch.l + direction * step, lch.c, lch.h).to_srgb();
316 self.put(name, color);
317 self.check(on, name, level);
318 color
319 }
320
321 fn check(&mut self, foreground: &'static str, background: &'static str, required: f64) {
322 let ratio = contrast(self.get(foreground), self.get(background));
323 self.checks.push(Check {
324 foreground,
325 background,
326 ratio,
327 required,
328 });
329 }
330}
331
332fn nearest(target: Oklch, accept: impl Fn(Srgb) -> bool) -> Option<Srgb> {
336 const STEPS: i32 = 400;
337 for factor in [1.0, 0.75, 0.5, 0.25, 0.0] {
338 let chroma = target.c * factor;
339 for step in 0..=STEPS {
340 for sign in [1.0, -1.0] {
341 if step == 0 && sign < 0.0 {
342 continue;
343 }
344 let l = target.l + sign * f64::from(step) / f64::from(STEPS);
345 if !(0.0..=1.0).contains(&l) {
346 continue;
347 }
348 let color = Oklch::new(l, chroma, target.h).to_srgb();
349 if accept(color) {
350 return Some(color);
351 }
352 }
353 }
354 }
355 None
356}
357
358fn neutral_extreme(against: &[Srgb]) -> Srgb {
359 let worst = |color: Srgb| {
360 against
361 .iter()
362 .map(|bg| contrast(color, *bg))
363 .fold(f64::INFINITY, f64::min)
364 };
365 if worst(Srgb::BLACK) >= worst(Srgb::WHITE) {
366 Srgb::BLACK
367 } else {
368 Srgb::WHITE
369 }
370}
371
372pub const DARK_ON_ACCENT_ABOVE: f64 = 0.4;
375
376pub fn native_accent(color: Srgb, mode: Mode) -> Srgb {
385 let level = ContrastPolicy::Standard.levels().graphic;
386 let neutral = generate(None, mode, ContrastPolicy::Standard);
387 let surfaces: Vec<Srgb> = ["surface.canvas", "surface.panel", "surface.raised"]
388 .into_iter()
389 .filter_map(|name| neutral.token(name))
390 .collect();
391 let near_black = Srgb::new(0x11, 0x11, 0x14);
392 let reads = |fill: Srgb| {
393 let on = if fill.luminance() > DARK_ON_ACCENT_ABOVE {
394 near_black
395 } else {
396 Srgb::WHITE
397 };
398 contrast(on, fill) >= level
399 && surfaces
400 .iter()
401 .all(|surface| contrast(fill, *surface) >= level)
402 };
403 if reads(color) {
404 return color;
405 }
406 nearest(color.oklch(), reads).unwrap_or(color)
407}
408
409pub fn generate(seed: Option<Srgb>, mode: Mode, contrast_policy: ContrastPolicy) -> Snapshot {
412 let seed = seed.unwrap_or(DEFAULT_SEED);
413 let mut b = Builder {
414 mode,
415 levels: contrast_policy.levels(),
416 tokens: Vec::new(),
417 checks: Vec::new(),
418 fallbacks: Vec::new(),
419 };
420 let levels = b.levels;
421 let neutral = |l: f64, c: f64| Oklch::new(l, c, NEUTRAL_HUE);
422 let brand = seed.oklch();
423 let brand_chroma = brand.c.min(MAX_BRAND_CHROMA);
424 let brand_at = |l: f64, c: f64| Oklch::new(l, c, brand.h);
425
426 b.fixed("surface.host", neutral(b.pick(0.967, 0.11), SURFACE_CHROMA));
431 let canvas = b.pick(1.0, 0.141);
432 b.fixed("surface.canvas", neutral(canvas, SURFACE_CHROMA));
433 b.fixed(
434 "surface.panel",
435 neutral(b.pick(0.985, 0.169), SURFACE_CHROMA),
436 );
437 b.fixed("surface.raised", neutral(canvas, SURFACE_CHROMA));
438 let fill = b.pick(0.967, 0.21);
440 let fill_strong = b.pick(0.92, 0.274);
441 b.fixed("hover.overlay", neutral(fill, SURFACE_CHROMA));
442 b.fixed("pressed.overlay", neutral(fill_strong, SURFACE_CHROMA));
443 b.fixed("disabled.surface", neutral(fill, SURFACE_CHROMA));
444 b.fixed("separator", neutral(fill_strong, SURFACE_CHROMA));
447 b.fixed("button.standard.hover", neutral(fill, SURFACE_CHROMA));
449 b.fixed(
450 "button.standard.pressed",
451 neutral(fill_strong, SURFACE_CHROMA),
452 );
453
454 const READS_ON: &[&str] = &[
456 "surface.canvas",
457 "surface.panel",
458 "surface.raised",
459 "hover.overlay",
460 "pressed.overlay",
461 "button.standard.hover",
462 "button.standard.pressed",
463 ];
464 b.solve(
465 "text.primary",
466 neutral(b.pick(0.141, 0.985), TEXT_CHROMA),
467 levels.text,
468 READS_ON,
469 );
470 b.solve(
471 "text.secondary",
472 neutral(b.pick(0.552, 0.712), TEXT_CHROMA),
473 levels.text,
474 READS_ON,
475 );
476 b.solve(
477 "text.placeholder",
478 neutral(b.pick(0.56, 0.68), TEXT_CHROMA),
479 levels.text,
480 READS_ON,
481 );
482 b.solve(
483 "icon.primary",
484 neutral(b.pick(0.141, 0.985), TEXT_CHROMA),
485 levels.graphic,
486 READS_ON,
487 );
488 b.solve(
489 "icon.secondary",
490 neutral(b.pick(0.552, 0.712), TEXT_CHROMA),
491 levels.graphic,
492 READS_ON,
493 );
494
495 const SURFACES: &[&str] = &["surface.canvas", "surface.panel", "surface.raised"];
498 let accent_l = brand.l.clamp(b.pick(0.45, 0.55), b.pick(0.62, 0.72));
499 b.solve_pair("accent", "on_accent", brand, SURFACES, levels.graphic);
506 b.solve(
510 "accent.text",
511 brand_at(accent_l, brand_chroma),
512 levels.text,
513 READS_ON,
514 );
515 b.solve(
516 "focus.indicator",
517 brand_at(b.pick(0.52, 0.74), brand_chroma),
518 levels.graphic,
519 SURFACES,
520 );
521 b.variant(
522 "button.prominent.hover",
523 "accent",
524 "on_accent",
525 b.pick(0.04, 0.04),
526 levels.graphic,
527 );
528 b.variant(
529 "button.prominent.pressed",
530 "accent",
531 "on_accent",
532 b.pick(0.08, 0.08),
533 levels.graphic,
534 );
535
536 b.solve(
538 "control.border",
539 neutral(b.pick(0.62, 0.52), SURFACE_CHROMA),
540 levels.graphic,
541 SURFACES,
542 );
543 b.solve(
544 "control.thumb",
545 neutral(b.pick(1.0, 0.98), SURFACE_CHROMA),
546 levels.graphic,
547 &["accent"],
548 );
549 b.solve(
550 "control.track",
551 neutral(b.pick(0.68, 0.58), SURFACE_CHROMA),
552 levels.graphic,
553 &[
554 "surface.canvas",
555 "surface.panel",
556 "surface.raised",
557 "control.thumb",
558 ],
559 );
560
561 b.fixed("media.track", neutral(b.pick(0.9, 0.36), SURFACE_CHROMA));
565 b.solve(
566 "media.played",
567 neutral(b.pick(0.3, 0.9), TEXT_CHROMA),
568 levels.graphic,
569 &[
570 "media.track",
571 "surface.canvas",
572 "surface.panel",
573 "surface.raised",
574 ],
575 );
576
577 for (name, (hue, chroma), light, dark) in [
579 ("status.success", SUCCESS, 0.5, 0.78),
580 ("status.warning", WARNING, 0.5, 0.8),
581 ("status.error", ERROR, 0.5, 0.74),
582 ] {
583 b.solve(
584 name,
585 Oklch::new(b.pick(light, dark), chroma, hue),
586 levels.text,
587 READS_ON,
588 );
589 }
590 b.solve_pair(
591 "status.error.surface",
592 "on_error",
593 Oklch::new(b.pick(0.55, 0.62), ERROR.1, ERROR.0),
594 &["surface.canvas", "surface.panel"],
595 levels.text,
596 );
597 b.variant(
598 "button.destructive.hover",
599 "status.error.surface",
600 "on_error",
601 0.04,
602 levels.text,
603 );
604 b.variant(
605 "button.destructive.pressed",
606 "status.error.surface",
607 "on_error",
608 0.08,
609 levels.text,
610 );
611
612 b.solve(
615 "disabled.foreground",
616 neutral(b.pick(0.66, 0.56), TEXT_CHROMA),
617 levels.disabled,
618 &[
619 "disabled.surface",
620 "surface.canvas",
621 "surface.panel",
622 "surface.raised",
623 ],
624 );
625
626 b.tokens.push(Token {
627 name: "scrim",
628 color: Srgb::BLACK,
629 alpha: match mode {
630 Mode::Light => 0x59,
631 Mode::Dark => 0x80,
632 },
633 });
634 b.tokens.sort_by(|x, y| x.name.cmp(y.name));
635 Snapshot {
636 schema_version: SCHEMA_VERSION,
637 algorithm_version: ALGORITHM_VERSION,
638 seed,
639 mode,
640 contrast: contrast_policy,
641 tokens: b.tokens,
642 checks: b.checks,
643 fallbacks: b.fallbacks,
644 }
645}
646
647#[cfg(test)]
648mod tests;