1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ThemeColors {
12 pub theme_name: String,
13 pub primary: String,
14 pub primary_content: String,
15 pub secondary: String,
16 pub secondary_content: String,
17 pub accent: String,
18 pub accent_content: String,
19 pub neutral: String,
20 pub neutral_content: String,
21 pub base_100: String,
22 pub base_200: String,
23 pub base_300: String,
24 pub base_content: String,
25 pub info: String,
26 pub info_content: String,
27 pub success: String,
28 pub success_content: String,
29 pub warning: String,
30 pub warning_content: String,
31 pub error: String,
32 pub error_content: String,
33}
34
35impl Default for ThemeColors {
36 fn default() -> Self {
37 Self::dark_default()
38 }
39}
40
41impl ThemeColors {
42 pub fn dark_default() -> Self {
44 Self {
45 theme_name: "business".to_string(),
46 primary: "#1c4f82".to_string(),
47 primary_content: "#d6e4f0".to_string(),
48 secondary: "#7c3aed".to_string(),
49 secondary_content: "#e4d8fd".to_string(),
50 accent: "#e68a00".to_string(),
51 accent_content: "#140c00".to_string(),
52 neutral: "#23282f".to_string(),
53 neutral_content: "#a6adba".to_string(),
54 base_100: "#1f2937".to_string(),
55 base_200: "#111827".to_string(),
56 base_300: "#0f1623".to_string(),
57 base_content: "#d5d9de".to_string(),
58 info: "#3abff8".to_string(),
59 info_content: "#002b3d".to_string(),
60 success: "#36d399".to_string(),
61 success_content: "#003320".to_string(),
62 warning: "#fbbd23".to_string(),
63 warning_content: "#382800".to_string(),
64 error: "#f87272".to_string(),
65 error_content: "#470000".to_string(),
66 }
67 }
68
69 pub fn light_default() -> Self {
71 Self {
72 theme_name: "bumblebee".to_string(),
73 primary: "#e0a82e".to_string(),
74 primary_content: "#181400".to_string(),
75 secondary: "#f9d72f".to_string(),
76 secondary_content: "#181400".to_string(),
77 accent: "#e0a82e".to_string(),
78 accent_content: "#181400".to_string(),
79 neutral: "#1f2937".to_string(),
80 neutral_content: "#d5d9de".to_string(),
81 base_100: "#ffffff".to_string(),
82 base_200: "#f2f2f2".to_string(),
83 base_300: "#e5e6e6".to_string(),
84 base_content: "#1f2937".to_string(),
85 info: "#3abff8".to_string(),
86 info_content: "#002b3d".to_string(),
87 success: "#36d399".to_string(),
88 success_content: "#003320".to_string(),
89 warning: "#fbbd23".to_string(),
90 warning_content: "#382800".to_string(),
91 error: "#f87272".to_string(),
92 error_content: "#470000".to_string(),
93 }
94 }
95
96 pub fn to_viewer_css(&self) -> String {
98 format!(
99 "\
100--vs-primary: {primary};\n \
101--vs-primary-content: {primary_content};\n \
102--vs-secondary: {secondary};\n \
103--vs-accent: {accent};\n \
104--vs-neutral: {neutral};\n \
105--vs-neutral-content: {neutral_content};\n \
106--vs-base-100: {base_100};\n \
107--vs-base-200: {base_200};\n \
108--vs-base-300: {base_300};\n \
109--vs-base-content: {base_content};\n \
110--vs-error: {error};",
111 primary = self.primary,
112 primary_content = self.primary_content,
113 secondary = self.secondary,
114 accent = self.accent,
115 neutral = self.neutral,
116 neutral_content = self.neutral_content,
117 base_100 = self.base_100,
118 base_200 = self.base_200,
119 base_300 = self.base_300,
120 base_content = self.base_content,
121 error = self.error,
122 )
123 }
124}
125
126pub fn oklch_to_hex(s: &str) -> Result<String, String> {
133 let s = s.trim();
134
135 if s.starts_with('#') && (s.len() == 7 || s.len() == 4) {
137 return Ok(s.to_string());
138 }
139
140 if s.starts_with("rgb(") {
142 return rgb_str_to_hex(s);
143 }
144
145 let inner = s
146 .strip_prefix("oklch(")
147 .and_then(|s| s.strip_suffix(')'))
148 .ok_or_else(|| format!("not an oklch() value: {s}"))?
149 .trim();
150
151 let parts: Vec<&str> = inner.split(&[' ', ',', '/'] as &[char])
153 .map(str::trim)
154 .filter(|p| !p.is_empty())
155 .collect();
156
157 if parts.len() < 3 {
159 return Err(format!("oklch needs 3 components, got {}: {s}", parts.len()));
160 }
161
162 let l = parse_lightness(parts[0])?;
163 let c: f64 = parts[1].parse().map_err(|e| format!("bad chroma: {e}"))?;
164 let h_deg: f64 = parts[2]
165 .strip_suffix("deg")
166 .unwrap_or(parts[2])
167 .parse()
168 .map_err(|e| format!("bad hue: {e}"))?;
169
170 let (r, g, b) = oklch_to_srgb(l, c, h_deg);
171 Ok(format!("#{:02x}{:02x}{:02x}", r, g, b))
172}
173
174fn parse_lightness(s: &str) -> Result<f64, String> {
175 if let Some(pct) = s.strip_suffix('%') {
176 let v: f64 = pct.parse().map_err(|e| format!("bad lightness: {e}"))?;
177 Ok(v / 100.0)
178 } else {
179 let v: f64 = s.parse().map_err(|e| format!("bad lightness: {e}"))?;
180 if v > 1.0 { Ok(v / 100.0) } else { Ok(v) }
182 }
183}
184
185fn rgb_str_to_hex(s: &str) -> Result<String, String> {
186 let inner = s
187 .strip_prefix("rgb(")
188 .and_then(|s| s.strip_suffix(')'))
189 .ok_or_else(|| format!("not an rgb() value: {s}"))?
190 .trim();
191 let parts: Vec<&str> = inner.split(&[' ', ','] as &[char])
192 .map(str::trim)
193 .filter(|p| !p.is_empty())
194 .collect();
195 if parts.len() < 3 {
196 return Err(format!("rgb needs 3 components: {s}"));
197 }
198 let r: u8 = parts[0].parse().map_err(|e| format!("bad r: {e}"))?;
199 let g: u8 = parts[1].parse().map_err(|e| format!("bad g: {e}"))?;
200 let b: u8 = parts[2].parse().map_err(|e| format!("bad b: {e}"))?;
201 Ok(format!("#{r:02x}{g:02x}{b:02x}"))
202}
203
204fn oklch_to_srgb(l: f64, c: f64, h_deg: f64) -> (u8, u8, u8) {
206 let h_rad = h_deg.to_radians();
207 let a = c * h_rad.cos();
208 let b = c * h_rad.sin();
209 let (lr, lg, lb) = oklab_to_linear_srgb(l, a, b);
210 let r = linear_to_srgb(lr);
211 let g = linear_to_srgb(lg);
212 let b = linear_to_srgb(lb);
213 (r, g, b)
214}
215
216fn oklab_to_linear_srgb(l: f64, a: f64, b: f64) -> (f64, f64, f64) {
218 let l_ = l + 0.396_337_792_3 * a + 0.215_803_758_1 * b;
220 let m_ = l - 0.105_561_346_2 * a - 0.063_854_174_8 * b;
221 let s_ = l - 0.089_484_178_1 * a - 1.291_485_548_0 * b;
222
223 let l3 = l_ * l_ * l_;
225 let m3 = m_ * m_ * m_;
226 let s3 = s_ * s_ * s_;
227
228 let r = 4.076_741_662_0 * l3 - 3.307_711_590_4 * m3 + 0.230_969_928_4 * s3;
230 let g = -1.268_438_005_0 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_1 * s3;
231 let b = -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3;
232
233 (r, g, b)
234}
235
236fn linear_to_srgb(c: f64) -> u8 {
238 let c = c.clamp(0.0, 1.0);
239 let s = if c <= 0.003_130_8 {
240 12.92 * c
241 } else {
242 1.055 * c.powf(1.0 / 2.4) - 0.055
243 };
244 (s * 255.0).round().clamp(0.0, 255.0) as u8
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn black_oklch() {
253 assert_eq!(oklch_to_hex("oklch(0 0 0)").unwrap(), "#000000");
254 }
255
256 #[test]
257 fn white_oklch() {
258 assert_eq!(oklch_to_hex("oklch(1 0 0)").unwrap(), "#ffffff");
259 }
260
261 #[test]
262 fn percentage_lightness() {
263 assert_eq!(oklch_to_hex("oklch(0% 0 0)").unwrap(), "#000000");
264 assert_eq!(oklch_to_hex("oklch(100% 0 0)").unwrap(), "#ffffff");
265 }
266
267 #[test]
268 fn known_blue() {
269 let hex = oklch_to_hex("oklch(0.5 0.2 260)").unwrap();
271 assert!(hex.starts_with('#'));
273 assert_eq!(hex.len(), 7);
274 let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
275 let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
276 assert!(b > r, "expected blue > red for hue 260, got {hex}");
277 }
278
279 #[test]
280 fn known_red() {
281 let hex = oklch_to_hex("oklch(0.6 0.25 25)").unwrap();
283 let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
284 let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
285 assert!(r > b, "expected red > blue for hue 25, got {hex}");
286 }
287
288 #[test]
289 fn hex_passthrough() {
290 assert_eq!(oklch_to_hex("#ff00aa").unwrap(), "#ff00aa");
291 }
292
293 #[test]
294 fn rgb_passthrough() {
295 assert_eq!(oklch_to_hex("rgb(255, 0, 128)").unwrap(), "#ff0080");
296 }
297
298 #[test]
299 fn with_deg_suffix() {
300 let hex = oklch_to_hex("oklch(0.7 0.15 210deg)").unwrap();
301 assert!(hex.starts_with('#'));
302 assert_eq!(hex.len(), 7);
303 }
304
305 #[test]
306 fn with_alpha_ignored() {
307 let hex = oklch_to_hex("oklch(0.5 0.2 260 / 0.8)").unwrap();
309 assert!(hex.starts_with('#'));
310 assert_eq!(hex.len(), 7);
311 }
312
313 #[test]
314 fn viewer_css_contains_properties() {
315 let theme = ThemeColors::dark_default();
316 let css = theme.to_viewer_css();
317 assert!(css.contains("--vs-primary:"));
318 assert!(css.contains("--vs-base-100:"));
319 assert!(css.contains("--vs-base-content:"));
320 assert!(css.contains("--vs-error:"));
321 }
322}