Skip to main content

bern_theme/
lib.rs

1//! Bern Theme Algorithm v5.
2//!
3//! The theme kernel generates semantic colors and control recipes for
4//! targets whose UI framework has no complete light/dark system of its own
5//! (Web, GPUI) and for anything Bern draws itself. Native targets use their
6//! framework's colors and never call it.
7//!
8//! ```text
9//! Snapshot = generate(seed, mode, contrast policy, ALGORITHM_VERSION)
10//! ```
11//!
12//! Generation is deterministic and pure: it reads no platform state. Each
13//! role starts from a target lightness in OKLCH (seed hue for brand roles,
14//! a fixed neutral hue for surfaces and text, fixed hues for status), is
15//! mapped into sRGB by lowering chroma, and is then moved to the nearest
16//! lightness at which every pairing declared for it meets its contrast
17//! level, measured on the final 8-bit colors. A role no lightness can
18//! satisfy falls back to a neutral and is reported, never emitted silently.
19
20mod color;
21mod recipes;
22
23pub use color::{Oklch, Srgb, contrast};
24pub use recipes::{RECIPES, Recipe, RecipeState};
25
26/// Version of the generation rules; a change may alter emitted colors.
27pub const ALGORITHM_VERSION: u32 = 7;
28
29/// Version of the snapshot layout.
30pub const SCHEMA_VERSION: u32 = 1;
31
32/// Bern's built-in brand seed, used when an application declares none.
33pub 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/// Contrast policy, from the platform's contrast preference.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52pub enum ContrastPolicy {
53    Standard,
54    /// The user asked for more contrast.
55    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    /// Minimum ratios for text, for graphics needed to identify controls
67    /// and states, and for disabled controls, which stay recognizable but
68    /// are not operable.
69    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/// One emitted color. `alpha` is 255 except for overlays such as `scrim`.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct Token {
95    pub name: &'static str,
96    pub color: Srgb,
97    pub alpha: u8,
98}
99
100/// A declared foreground and background pairing and its measured ratio.
101#[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/// The resolved theme for one seed, mode and contrast policy.
116#[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    /// Sorted by name.
124    pub tokens: Vec<Token>,
125    pub checks: Vec<Check>,
126    /// Roles that fell back to a neutral pairing, with the reason.
127    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    /// Whether every declared pairing meets its level.
139    pub fn all_checks_pass(&self) -> bool {
140        self.checks.iter().all(Check::passed)
141    }
142}
143
144/// Hue and chroma of neutral surfaces and text, independent of the brand.
145/// The cool grey of neutral web interfaces (zinc): #09090B, #18181B and
146/// #27272A in the dark, #F4F4F5 and #E4E4E7 in the light.
147const NEUTRAL_HUE: f64 = 286.0;
148const SURFACE_CHROMA: f64 = 0.005;
149const TEXT_CHROMA: f64 = 0.013;
150/// Brand roles never exceed this chroma, so a vivid seed stays legible.
151const MAX_BRAND_CHROMA: f64 = 0.20;
152/// How far (in OKLCH lightness) an accent fill may darken to carry white
153/// text, as the platforms put white on their accent fills, before a dark
154/// foreground is used instead; a light accent such as a bright green keeps
155/// its color with dark text.
156const WHITE_ON_SHIFT: f64 = 0.12;
157
158/// Status hues and chroma; the brand seed never changes them.
159const 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    /// Lightness `light` or `dark` by mode.
190    fn pick(&self, light: f64, dark: f64) -> f64 {
191        match self.mode {
192            Mode::Light => light,
193            Mode::Dark => dark,
194        }
195    }
196
197    /// A color with no pairing, such as a surface.
198    fn fixed(&mut self, name: &'static str, color: Oklch) -> Srgb {
199        self.put(name, color.to_srgb())
200    }
201
202    /// Solve `name` near `target` so that it meets `level` against every
203    /// background, record the checks, and fall back to the neutral extreme
204    /// with the best worst-case ratio when no lightness works.
205    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    /// Solve a background `name` together with the foreground `on` that
234    /// sits on it: white, as the platforms put on their accent fills, when
235    /// the background reaches it within `WHITE_ON_SHIFT` of `target`'s
236    /// lightness; otherwise white or a dark neutral, whichever lets the
237    /// background stay closest to `target`. `level` applies to the pair,
238    /// `graphic` to the background against `surfaces`.
239    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                // Surfaces cannot all be met: keep the pair readable.
276                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    /// A state variant of `base` moved `step` in lightness away from the
297    /// foreground `on`, so the pair only gains contrast; it keeps the
298    /// pair's `level`.
299    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
332/// The color nearest to `target` in lightness, after gamut mapping, that
333/// satisfies `accept`. Lightness moves in steps of 0.0025, upwards first at
334/// equal distance; chroma is lowered in quarters when no lightness works.
335fn 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
372/// Luminance above which the native targets put near-black instead of
373/// white on an accent fill (SwiftUI, GTK, Compose and WinUI 3 alike).
374pub const DARK_ON_ACCENT_ABOVE: f64 = 0.4;
375
376/// An accent of `style!` as the native targets show it in `mode`, whose
377/// controls take it as their tint and put the foreground of
378/// `DARK_ON_ACCENT_ABOVE` on its fills. The chosen color is kept when it
379/// stands out from every surface of the mode at the graphic level and
380/// that foreground reads on it at the same level; otherwise it moves to
381/// the nearest lightness of its hue that does. A bright green or a pale
382/// pink darkens in light mode and stays as chosen in dark mode, carrying
383/// dark text; a red or a blue that already reads is never changed.
384pub 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
409/// Generate the snapshot for a brand seed (Bern's default when `None`), a
410/// resolved mode and a contrast policy.
411pub 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    // Surfaces. Light: a white page and cards, a faint grey for the
427    // sidebar, and white raised layers set apart by border and shadow.
428    // Dark: a near-black page, the sidebar a step above it, and raised
429    // layers on the page colour set apart by border and shadow too.
430    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    // The lightest fill under the pointer, and the next one while pressed.
439    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    // Decorative: card borders, the outline of standard buttons and fields,
445    // and dividers between content that is already identified by its text.
446    b.fixed("separator", neutral(fill_strong, SURFACE_CHROMA));
447    // Standard buttons sit on the page colour and take the fills.
448    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    // Text and icons must read on every surface and row state.
455    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    // Brand roles. The accent surface and its foreground are solved as a
496    // pair; text in the brand color must also read on every surface.
497    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    // The accent fill is the chosen color itself, as the platforms fill
500    // their buttons with the system accent: it starts from the seed and
501    // moves only as far as its title needs to read as a control's does
502    // (the graphic level, 3:1 by default, as white on a system red), and
503    // as far as it needs to stand out from every surface, where it also
504    // colors icons as hover feedback and fills selected navigation items.
505    b.solve_pair("accent", "on_accent", brand, SURFACES, levels.graphic);
506    // Text in the brand colour starts from the accent itself and moves
507    // only as far as reading on every surface requires, so plain buttons,
508    // links and selected tabs stay as vivid as the accent allows.
509    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    // Control graphics.
537    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    // Media progress: the played part is neutral, never the accent, so it
562    // does not compete with the picture; the rest of the track is a faint
563    // groove. The played part must stand out from both.
564    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    // Status colors use framework hues, never the brand seed.
578    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    // Disabled controls stay recognizable on their own surface and on the
613    // surfaces disabled text appears on directly.
614    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;