Skip to main content

zenith_core/theme/
synth.rs

1//! Palette synthesis from brand seed colors.
2//!
3//! Given a primary (plus optional role overrides) and a light/dark scheme, this
4//! derives the full theme colour contract — surfaces, every role with a readable
5//! `.content` foreground, and status colours — picking foregrounds by APCA
6//! (WCAG 3) contrast so text on any role is legible by construction.
7
8use crate::color::best_text_color;
9
10/// An sRGB colour triple.
11pub type Rgb = (u8, u8, u8);
12
13/// Light or dark base scheme.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Scheme {
16    /// Near-white surfaces, dark text.
17    Light,
18    /// Near-black surfaces, light text.
19    Dark,
20}
21
22/// Brand input: a primary plus optional overrides. Unset roles are derived.
23#[derive(Debug, Clone)]
24pub struct PaletteSpec {
25    /// Light or dark base.
26    pub scheme: Scheme,
27    /// The brand's primary colour (required).
28    pub primary: Rgb,
29    /// Secondary colour; defaults to the primary when absent.
30    pub secondary: Option<Rgb>,
31    /// Accent colour; defaults to the secondary (or primary) when absent.
32    pub accent: Option<Rgb>,
33    /// Neutral colour; derived from a tinted grey when absent.
34    pub neutral: Option<Rgb>,
35    /// Status colours; sensible universal hues are used when absent.
36    pub info: Option<Rgb>,
37    /// See [`PaletteSpec::info`].
38    pub success: Option<Rgb>,
39    /// See [`PaletteSpec::info`].
40    pub warning: Option<Rgb>,
41    /// See [`PaletteSpec::info`].
42    pub error: Option<Rgb>,
43}
44
45/// The two foreground candidates every `.content` colour is chosen between.
46const DARK_FG: Rgb = (24, 26, 30);
47const LIGHT_FG: Rgb = (247, 248, 250);
48
49// Universal, accessible status hues used unless the brand overrides them.
50const INFO: Rgb = (14, 165, 233);
51const SUCCESS: Rgb = (22, 163, 74);
52const WARNING: Rgb = (245, 158, 11);
53const ERROR: Rgb = (239, 68, 68);
54
55/// The ordered token-id suffixes every synthesized palette emits. Each entry is
56/// the part after `color.` — e.g. `"base.100"`, `"primary.content"`.
57pub const PALETTE_ORDER: [&str; 20] = [
58    "base.100",
59    "base.200",
60    "base.300",
61    "base.content",
62    "primary",
63    "primary.content",
64    "secondary",
65    "secondary.content",
66    "accent",
67    "accent.content",
68    "neutral",
69    "neutral.content",
70    "info",
71    "info.content",
72    "success",
73    "success.content",
74    "warning",
75    "warning.content",
76    "error",
77    "error.content",
78];
79
80/// Linearly mix two colours; `t = 0.0` is `a`, `t = 1.0` is `b`.
81fn mix(a: Rgb, b: Rgb, t: f64) -> Rgb {
82    let m = |x: u8, y: u8| -> u8 {
83        let v = x as f64 * (1.0 - t) + y as f64 * t;
84        v.round().clamp(0.0, 255.0) as u8
85    };
86    (m(a.0, b.0), m(a.1, b.1), m(a.2, b.2))
87}
88
89/// A readable foreground for text on `bg` (near-black or near-white), by APCA.
90fn content(bg: Rgb) -> Rgb {
91    best_text_color(bg, DARK_FG, LIGHT_FG)
92}
93
94/// Derive the full ordered palette as `(id-suffix, rgb)` pairs, in
95/// [`PALETTE_ORDER`]. The id suffix is the part after `color.`.
96pub fn synth_palette(spec: &PaletteSpec) -> Vec<(&'static str, Rgb)> {
97    let primary = spec.primary;
98    let secondary = spec.secondary.unwrap_or(primary);
99    let accent = spec.accent.unwrap_or(secondary);
100
101    // Surfaces: near-white (light) / near-black (dark), faintly tinted toward
102    // the brand hue for cohesion. Base text is the APCA-best foreground.
103    let (b100, b200, b300, neutral_default) = match spec.scheme {
104        Scheme::Light => (
105            mix((248, 248, 249), primary, 0.02),
106            mix((241, 242, 244), primary, 0.03),
107            mix((227, 229, 233), primary, 0.05),
108            mix((72, 78, 90), primary, 0.10),
109        ),
110        Scheme::Dark => (
111            mix((12, 14, 18), primary, 0.05),
112            mix((22, 24, 29), primary, 0.05),
113            mix((35, 39, 48), primary, 0.05),
114            mix((68, 74, 86), primary, 0.10),
115        ),
116    };
117    let neutral = spec.neutral.unwrap_or(neutral_default);
118    let info = spec.info.unwrap_or(INFO);
119    let success = spec.success.unwrap_or(SUCCESS);
120    let warning = spec.warning.unwrap_or(WARNING);
121    let error = spec.error.unwrap_or(ERROR);
122
123    vec![
124        ("base.100", b100),
125        ("base.200", b200),
126        ("base.300", b300),
127        ("base.content", content(b100)),
128        ("primary", primary),
129        ("primary.content", content(primary)),
130        ("secondary", secondary),
131        ("secondary.content", content(secondary)),
132        ("accent", accent),
133        ("accent.content", content(accent)),
134        ("neutral", neutral),
135        ("neutral.content", content(neutral)),
136        ("info", info),
137        ("info.content", content(info)),
138        ("success", success),
139        ("success.content", content(success)),
140        ("warning", warning),
141        ("warning.content", content(warning)),
142        ("error", error),
143        ("error.content", content(error)),
144    ]
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::color::apca_lc;
151
152    fn spec(scheme: Scheme, primary: Rgb) -> PaletteSpec {
153        PaletteSpec {
154            scheme,
155            primary,
156            secondary: None,
157            accent: None,
158            neutral: None,
159            info: None,
160            success: None,
161            warning: None,
162            error: None,
163        }
164    }
165
166    fn get(p: &[(&'static str, Rgb)], id: &str) -> Rgb {
167        p.iter().find(|(k, _)| *k == id).expect("role present").1
168    }
169
170    /// Every `<role>` / `<role>.content` pair must be legible by APCA in BOTH
171    /// schemes: body text on the base surface ≥ 75 Lc, role labels ≥ 45 Lc.
172    #[test]
173    fn content_pairs_meet_apca_in_light_and_dark() {
174        for scheme in [Scheme::Light, Scheme::Dark] {
175            for primary in [(124, 58, 237), (34, 197, 94), (252, 183, 0), (248, 40, 52)] {
176                let p = synth_palette(&spec(scheme, primary));
177                let base_lc = apca_lc(get(&p, "base.content"), get(&p, "base.100")).abs();
178                assert!(
179                    base_lc >= 75.0,
180                    "{scheme:?} primary {primary:?}: base text Lc {base_lc:.1} < 75"
181                );
182                for role in [
183                    "primary",
184                    "secondary",
185                    "accent",
186                    "neutral",
187                    "info",
188                    "success",
189                    "warning",
190                    "error",
191                ] {
192                    let lc = apca_lc(get(&p, &format!("{role}.content")), get(&p, role)).abs();
193                    assert!(
194                        lc >= 45.0,
195                        "{scheme:?} {primary:?}: {role} label Lc {lc:.1} < 45"
196                    );
197                }
198            }
199        }
200    }
201
202    #[test]
203    fn synthesis_is_deterministic() {
204        let a = synth_palette(&spec(Scheme::Light, (97, 93, 255)));
205        let b = synth_palette(&spec(Scheme::Light, (97, 93, 255)));
206        assert_eq!(a, b);
207    }
208
209    #[test]
210    fn overrides_are_respected() {
211        let mut s = spec(Scheme::Dark, (10, 20, 30));
212        s.accent = Some((255, 0, 128));
213        let p = synth_palette(&s);
214        assert_eq!(get(&p, "accent"), (255, 0, 128));
215    }
216
217    #[test]
218    fn order_is_the_contract() {
219        let p = synth_palette(&spec(Scheme::Light, (100, 100, 100)));
220        let ids: Vec<&str> = p.iter().map(|(k, _)| *k).collect();
221        assert_eq!(ids, PALETTE_ORDER.to_vec());
222    }
223}