atproto blogging
1//! Colour generation for notebook themes.
2//!
3//! Generates a full 16-colour palette from 4 user-specified colours
4//! (background, text, primary, link) using perceptual colour operations.
5
6use palette::{Clamp, FromColor, IntoColor, LinSrgb, Oklch, Srgb};
7use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours;
8
9use crate::themes::BUILTIN_COLOUR_SCHEMES;
10
11/// Parse any CSS colour string to Oklch.
12///
13/// Supports: hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), hsla(),
14/// hwb(), lab(), lch(), oklab(), oklch(), and named colours.
15pub fn css_to_oklch(css: &str) -> Result<Oklch, ColourGenError> {
16 let css = css.trim();
17 let css = if !css.starts_with('#')
18 && !css.contains('(')
19 && css.chars().all(|c| c.is_ascii_hexdigit())
20 {
21 format!("#{}", css)
22 } else {
23 css.to_string()
24 };
25
26 let color = csscolorparser::parse(&css)
27 .map_err(|e| ColourGenError::InvalidColour(format!("{}: {}", css, e)))?;
28
29 let [r, g, b, _a] = color.to_array();
30 let srgb = Srgb::new(r as f32, g as f32, b as f32);
31 Ok(Oklch::from_color(srgb.into_linear()))
32}
33
34/// Convert Oklch to hex string (without #, uppercase).
35pub fn oklch_to_hex(oklch: Oklch) -> String {
36 let linear: LinSrgb = oklch.into_color();
37 let clamped = linear.clamp();
38 let rgb: Srgb<u8> = Srgb::from_linear(clamped);
39 format!("{:02X}{:02X}{:02X}", rgb.red, rgb.green, rgb.blue)
40}
41
42/// Determine if a colour is "dark" based on Oklch lightness.
43pub fn is_dark(oklch: &Oklch) -> bool {
44 oklch.l < 0.5
45}
46
47/// Apply the hue and chroma from `source` to the lightness of `target`.
48///
49/// The chroma is scaled proportionally to maintain the relative colourfulness
50/// while respecting the target lightness.
51pub fn transfer_hue_chroma(source: Oklch, target: Oklch) -> Oklch {
52 Oklch::new(target.l, source.chroma, source.hue)
53}
54
55/// Apply hue/chroma with lightness clamping for dark themes.
56/// Ensures text is lightest, base is darkest.
57pub fn transfer_for_dark_theme(
58 source: Oklch,
59 target: Oklch,
60 is_text: bool,
61 is_base: bool,
62) -> Oklch {
63 let mut result = transfer_hue_chroma(source, target);
64
65 if is_base {
66 result.l = result.l.min(0.25);
67 } else if is_text {
68 result.l = result.l.max(0.85);
69 }
70
71 result
72}
73
74/// Apply hue/chroma with lightness clamping for light themes.
75/// Ensures text is darkest, base is lightest.
76pub fn transfer_for_light_theme(
77 source: Oklch,
78 target: Oklch,
79 is_text: bool,
80 is_base: bool,
81) -> Oklch {
82 let mut result = transfer_hue_chroma(source, target);
83
84 if is_base {
85 result.l = result.l.max(0.92);
86 } else if is_text {
87 result.l = result.l.min(0.25);
88 }
89
90 result
91}
92
93/// Semantic colour configuration.
94mod semantic {
95 /// Hues in Oklch degrees.
96 pub mod hue {
97 pub const ERROR: f32 = 30.0; // Red
98 pub const WARNING: f32 = 80.0; // Gold-amber with high saturation
99 pub const SUCCESS: f32 = 165.0; // Teal-green (shifted from pure green to allow more cyan)
100 }
101
102 /// Minimum chroma values to keep semantic colours recognizable.
103 pub mod min_chroma {
104 pub const ERROR: f32 = 0.16; // Must stay clearly red
105 pub const WARNING: f32 = 0.19; // Needs high saturation to avoid looking muddy
106 pub const SUCCESS: f32 = 0.08; // Can be quite muted, teal reads well
107 }
108
109 /// Lightness values per variant. Warning needs to be brighter to avoid going brown.
110 pub mod lightness {
111 pub const ERROR_DARK: f32 = 0.70;
112 pub const ERROR_LIGHT: f32 = 0.55;
113 pub const WARNING_DARK: f32 = 0.78; // Brighter to stay amber
114 pub const WARNING_LIGHT: f32 = 0.78; // Brighter to avoid brown
115 pub const SUCCESS_DARK: f32 = 0.70;
116 pub const SUCCESS_LIGHT: f32 = 0.50; // Can be slightly darker, teal handles it
117 }
118}
119
120/// Generate a semantic colour derived from user's primary.
121///
122/// Takes the user's chroma (clamped to minimum) and applies a fixed semantic hue.
123fn generate_semantic_colour(
124 user_primary: Oklch,
125 hue: f32,
126 min_chroma: f32,
127 lightness: f32,
128) -> Oklch {
129 let chroma = user_primary.chroma.max(min_chroma);
130 Oklch::new(lightness, chroma, hue)
131}
132
133#[derive(Debug, Clone, thiserror::Error)]
134pub enum ColourGenError {
135 #[error("invalid colour: {0}")]
136 InvalidColour(String),
137}
138
139/// The 4 user-provided colours for theme generation.
140#[derive(Debug, Clone)]
141pub struct ThemeInputs {
142 pub background: String,
143 pub text: String,
144 pub primary: String,
145 pub link: String,
146}
147
148/// Which variant to generate.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ThemeVariant {
151 Light,
152 Dark,
153}
154
155/// Find the most neutral base theme for a variant.
156fn get_neutral_base(variant: ThemeVariant) -> &'static ColourSchemeColours<'static> {
157 let target_variant = match variant {
158 ThemeVariant::Light => "light",
159 ThemeVariant::Dark => "dark",
160 };
161
162 BUILTIN_COLOUR_SCHEMES
163 .iter()
164 .find(|s| s.variant == target_variant)
165 .map(|s| &s.colours)
166 .expect("should have at least one builtin theme per variant")
167}
168
169/// Determine theme variant from background colour luminance.
170pub fn detect_variant(background: &str) -> Result<ThemeVariant, ColourGenError> {
171 let oklch = css_to_oklch(background)?;
172 Ok(if is_dark(&oklch) {
173 ThemeVariant::Dark
174 } else {
175 ThemeVariant::Light
176 })
177}
178
179/// Generate a full 16-colour palette from 4 user inputs.
180pub fn generate_palette(
181 inputs: &ThemeInputs,
182 variant: ThemeVariant,
183) -> Result<ColourSchemeColours<'static>, ColourGenError> {
184 let user_base = css_to_oklch(&inputs.background)?;
185 let user_text = css_to_oklch(&inputs.text)?;
186 let user_primary = css_to_oklch(&inputs.primary)?;
187 let user_link = css_to_oklch(&inputs.link)?;
188
189 let base_theme = get_neutral_base(variant);
190
191 let base_base = css_to_oklch(base_theme.base.as_ref())?;
192 let base_surface = css_to_oklch(base_theme.surface.as_ref())?;
193 let base_overlay = css_to_oklch(base_theme.overlay.as_ref())?;
194 let base_text = css_to_oklch(base_theme.text.as_ref())?;
195 let base_muted = css_to_oklch(base_theme.muted.as_ref())?;
196 let base_subtle = css_to_oklch(base_theme.subtle.as_ref())?;
197 let base_emphasis = css_to_oklch(base_theme.emphasis.as_ref())?;
198 let base_primary = css_to_oklch(base_theme.primary.as_ref())?;
199 let base_secondary = css_to_oklch(base_theme.secondary.as_ref())?;
200 let base_tertiary = css_to_oklch(base_theme.tertiary.as_ref())?;
201 let base_border = css_to_oklch(base_theme.border.as_ref())?;
202 let base_link = css_to_oklch(base_theme.link.as_ref())?;
203 let base_highlight = css_to_oklch(base_theme.highlight.as_ref())?;
204
205 let transfer: fn(Oklch, Oklch, bool, bool) -> Oklch = match variant {
206 ThemeVariant::Dark => transfer_for_dark_theme,
207 ThemeVariant::Light => transfer_for_light_theme,
208 };
209
210 // Generate semantic colours derived from user's primary with fixed hues.
211 let error = generate_semantic_colour(
212 user_primary,
213 semantic::hue::ERROR,
214 semantic::min_chroma::ERROR,
215 match variant {
216 ThemeVariant::Dark => semantic::lightness::ERROR_DARK,
217 ThemeVariant::Light => semantic::lightness::ERROR_LIGHT,
218 },
219 );
220 let warning = generate_semantic_colour(
221 user_primary,
222 semantic::hue::WARNING,
223 semantic::min_chroma::WARNING,
224 match variant {
225 ThemeVariant::Dark => semantic::lightness::WARNING_DARK,
226 ThemeVariant::Light => semantic::lightness::WARNING_LIGHT,
227 },
228 );
229 let success = generate_semantic_colour(
230 user_primary,
231 semantic::hue::SUCCESS,
232 semantic::min_chroma::SUCCESS,
233 match variant {
234 ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK,
235 ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT,
236 },
237 );
238
239 Ok(ColourSchemeColours {
240 base: oklch_to_hex(transfer(user_base, base_base, false, true)).into(),
241 surface: oklch_to_hex(transfer(user_base, base_surface, false, false)).into(),
242 overlay: oklch_to_hex(transfer(user_base, base_overlay, false, false)).into(),
243 text: oklch_to_hex(transfer(user_text, base_text, true, false)).into(),
244 muted: oklch_to_hex(transfer(user_text, base_muted, false, false)).into(),
245 subtle: oklch_to_hex(transfer(user_text, base_subtle, false, false)).into(),
246 emphasis: oklch_to_hex(transfer(user_text, base_emphasis, false, false)).into(),
247 primary: oklch_to_hex(transfer(user_primary, base_primary, false, false)).into(),
248 secondary: oklch_to_hex(transfer(user_primary, base_secondary, false, false)).into(),
249 tertiary: oklch_to_hex(transfer(user_primary, base_tertiary, false, false)).into(),
250 error: oklch_to_hex(error).into(),
251 warning: oklch_to_hex(warning).into(),
252 success: oklch_to_hex(success).into(),
253 border: oklch_to_hex(transfer(user_base, base_border, false, false)).into(),
254 link: oklch_to_hex(transfer(user_link, base_link, false, false)).into(),
255 highlight: oklch_to_hex(transfer(user_primary, base_highlight, false, false)).into(),
256 extra_data: None,
257 })
258}
259
260/// Generate the counterpart palette (light for dark, dark for light).
261/// Uses neutral base for the counterpart and applies user hue/chroma.
262pub fn generate_counterpart_palette(
263 inputs: &ThemeInputs,
264 primary_variant: ThemeVariant,
265) -> Result<ColourSchemeColours<'static>, ColourGenError> {
266 let counterpart_variant = match primary_variant {
267 ThemeVariant::Light => ThemeVariant::Dark,
268 ThemeVariant::Dark => ThemeVariant::Light,
269 };
270
271 let neutral_base = get_neutral_base(counterpart_variant);
272
273 let user_primary = css_to_oklch(&inputs.primary)?;
274 let user_link = css_to_oklch(&inputs.link)?;
275
276 let base_primary = css_to_oklch(neutral_base.primary.as_ref())?;
277 let base_secondary = css_to_oklch(neutral_base.secondary.as_ref())?;
278 let base_tertiary = css_to_oklch(neutral_base.tertiary.as_ref())?;
279 let base_link = css_to_oklch(neutral_base.link.as_ref())?;
280 let base_highlight = css_to_oklch(neutral_base.highlight.as_ref())?;
281
282 // Generate semantic colours for the counterpart variant.
283 let error = generate_semantic_colour(
284 user_primary,
285 semantic::hue::ERROR,
286 semantic::min_chroma::ERROR,
287 match counterpart_variant {
288 ThemeVariant::Dark => semantic::lightness::ERROR_DARK,
289 ThemeVariant::Light => semantic::lightness::ERROR_LIGHT,
290 },
291 );
292 let warning = generate_semantic_colour(
293 user_primary,
294 semantic::hue::WARNING,
295 semantic::min_chroma::WARNING,
296 match counterpart_variant {
297 ThemeVariant::Dark => semantic::lightness::WARNING_DARK,
298 ThemeVariant::Light => semantic::lightness::WARNING_LIGHT,
299 },
300 );
301 let success = generate_semantic_colour(
302 user_primary,
303 semantic::hue::SUCCESS,
304 semantic::min_chroma::SUCCESS,
305 match counterpart_variant {
306 ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK,
307 ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT,
308 },
309 );
310
311 Ok(ColourSchemeColours {
312 base: neutral_base.base.clone(),
313 surface: neutral_base.surface.clone(),
314 overlay: neutral_base.overlay.clone(),
315 text: neutral_base.text.clone(),
316 muted: neutral_base.muted.clone(),
317 subtle: neutral_base.subtle.clone(),
318 emphasis: neutral_base.emphasis.clone(),
319 border: neutral_base.border.clone(),
320 error: oklch_to_hex(error).into(),
321 warning: oklch_to_hex(warning).into(),
322 success: oklch_to_hex(success).into(),
323 primary: oklch_to_hex(transfer_hue_chroma(user_primary, base_primary)).into(),
324 secondary: oklch_to_hex(transfer_hue_chroma(user_primary, base_secondary)).into(),
325 tertiary: oklch_to_hex(transfer_hue_chroma(user_primary, base_tertiary)).into(),
326 link: oklch_to_hex(transfer_hue_chroma(user_link, base_link)).into(),
327 highlight: oklch_to_hex(transfer_hue_chroma(user_primary, base_highlight)).into(),
328 extra_data: None,
329 })
330}
331
332#[cfg(test)]
333mod tests;