1use std::fmt;
25use std::sync::{Arc, LazyLock};
26
27use indexmap::IndexMap;
28use serde::ser::SerializeStruct;
29use serde::{Deserialize, Serialize, Serializer};
30use serde_json::{Map, Value};
31
32use crate::catalog::{parse_token_ref, CatalogView, TOKEN_GROUPS};
33use crate::json;
34use crate::recipes::{RecipeIndex, RECIPE_KEYS, RECIPE_STATES};
35use crate::types::{PartSpec, Props, UiNode};
36
37pub use crate::generated::themes::THEME_SCHEMA_ID;
38
39#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Mode {
46 Light,
47 Dark,
48}
49
50impl Mode {
51 pub const ALL: [Mode; 2] = [Mode::Light, Mode::Dark];
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Mode::Light => "light",
55 Mode::Dark => "dark",
56 }
57 }
58 pub fn parse(s: &str) -> Option<Mode> {
59 match s {
60 "light" => Some(Mode::Light),
61 "dark" => Some(Mode::Dark),
62 _ => None,
63 }
64 }
65}
66
67pub const MODES: [&str; 2] = ["light", "dark"];
69
70#[derive(Clone, PartialEq, Debug, Deserialize)]
72pub struct Shadow {
73 pub x: f64,
74 pub y: f64,
75 pub blur: f64,
76 pub spread: f64,
77 pub color: String,
78}
79
80impl Serialize for Shadow {
81 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
82 let mut st = s.serialize_struct("Shadow", 5)?;
83 st.serialize_field("x", &json::number(self.x))?;
84 st.serialize_field("y", &json::number(self.y))?;
85 st.serialize_field("blur", &json::number(self.blur))?;
86 st.serialize_field("spread", &json::number(self.spread))?;
87 st.serialize_field("color", &self.color)?;
88 st.end()
89 }
90}
91
92fn ser_nums<S: Serializer>(m: &IndexMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
93 s.collect_map(m.iter().map(|(k, v)| (k, json::number(*v))))
94}
95
96fn ser_curves<S: Serializer>(m: &IndexMap<String, Vec<f64>>, s: S) -> Result<S::Ok, S::Error> {
97 s.collect_map(m.iter().map(|(k, v)| (k, v.iter().map(|n| json::number(*n)).collect::<Vec<_>>())))
98}
99
100fn ser_opt_nums<S: Serializer>(v: &Option<Vec<f64>>, s: S) -> Result<S::Ok, S::Error> {
101 match v {
102 Some(list) => s.collect_seq(list.iter().map(|n| json::number(*n))),
103 None => s.serialize_none(),
104 }
105}
106
107#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
109pub struct ThemeMode {
110 pub color: IndexMap<String, String>,
111 pub shadow: IndexMap<String, Vec<Shadow>>,
112}
113
114#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
115pub struct Modes {
116 pub light: ThemeMode,
117 pub dark: ThemeMode,
118}
119
120impl Modes {
121 pub fn get(&self, mode: Mode) -> &ThemeMode {
122 match mode {
123 Mode::Light => &self.light,
124 Mode::Dark => &self.dark,
125 }
126 }
127 pub fn get_mut(&mut self, mode: Mode) -> &mut ThemeMode {
128 match mode {
129 Mode::Light => &mut self.light,
130 Mode::Dark => &mut self.dark,
131 }
132 }
133}
134
135#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
136pub struct TypeTokens {
137 #[serde(serialize_with = "ser_nums")]
138 pub size: IndexMap<String, f64>,
139 #[serde(rename = "lineHeight", serialize_with = "ser_nums")]
140 pub line_height: IndexMap<String, f64>,
141 #[serde(serialize_with = "ser_nums")]
142 pub weight: IndexMap<String, f64>,
143 pub family: IndexMap<String, String>,
145}
146
147impl TypeTokens {
148 fn numbers(&self, sub: &str) -> Option<&IndexMap<String, f64>> {
149 match sub {
150 "size" => Some(&self.size),
151 "lineHeight" => Some(&self.line_height),
152 "weight" => Some(&self.weight),
153 _ => None,
154 }
155 }
156 fn numbers_mut(&mut self, sub: &str) -> Option<&mut IndexMap<String, f64>> {
157 match sub {
158 "size" => Some(&mut self.size),
159 "lineHeight" => Some(&mut self.line_height),
160 "weight" => Some(&mut self.weight),
161 _ => None,
162 }
163 }
164}
165
166#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
167pub struct ThemeTokens {
168 #[serde(serialize_with = "ser_nums")]
169 pub spacing: IndexMap<String, f64>,
170 #[serde(serialize_with = "ser_nums")]
171 pub radius: IndexMap<String, f64>,
172 pub r#type: TypeTokens,
173 #[serde(serialize_with = "ser_nums")]
174 pub control: IndexMap<String, f64>,
175 #[serde(serialize_with = "ser_nums")]
176 pub opacity: IndexMap<String, f64>,
177 #[serde(serialize_with = "ser_nums")]
178 pub border: IndexMap<String, f64>,
179 #[serde(serialize_with = "ser_nums")]
181 pub motion: IndexMap<String, f64>,
182 #[serde(default, serialize_with = "ser_nums")]
184 pub breakpoint: IndexMap<String, f64>,
185 #[serde(default, serialize_with = "ser_curves")]
187 pub ease: IndexMap<String, Vec<f64>>,
188 #[serde(default, serialize_with = "ser_nums")]
190 pub density: IndexMap<String, f64>,
191 #[serde(default, serialize_with = "ser_nums")]
193 pub blur: IndexMap<String, f64>,
194 #[serde(default, serialize_with = "ser_nums")]
197 pub scale: IndexMap<String, f64>,
198}
199
200const NUMBER_GROUPS: [&str; 10] = ["spacing", "radius", "control", "opacity", "border", "motion", "breakpoint", "density", "blur", "scale"];
202const TUPLE_GROUPS: [&str; 1] = ["ease"];
204const TYPE_SUBS: [&str; 4] = ["size", "lineHeight", "weight", "family"];
205
206impl ThemeTokens {
207 pub fn numbers(&self, group: &str) -> Option<&IndexMap<String, f64>> {
209 match group {
210 "spacing" => Some(&self.spacing),
211 "radius" => Some(&self.radius),
212 "control" => Some(&self.control),
213 "opacity" => Some(&self.opacity),
214 "border" => Some(&self.border),
215 "motion" => Some(&self.motion),
216 "breakpoint" => Some(&self.breakpoint),
217 "density" => Some(&self.density),
218 "blur" => Some(&self.blur),
219 "scale" => Some(&self.scale),
220 g => g.strip_prefix("type.").and_then(|sub| self.r#type.numbers(sub)),
221 }
222 }
223 fn numbers_mut(&mut self, group: &str) -> Option<&mut IndexMap<String, f64>> {
224 match group {
225 "spacing" => Some(&mut self.spacing),
226 "radius" => Some(&mut self.radius),
227 "control" => Some(&mut self.control),
228 "opacity" => Some(&mut self.opacity),
229 "border" => Some(&mut self.border),
230 "motion" => Some(&mut self.motion),
231 "breakpoint" => Some(&mut self.breakpoint),
232 "density" => Some(&mut self.density),
233 "blur" => Some(&mut self.blur),
234 "scale" => Some(&mut self.scale),
235 g => g.strip_prefix("type.").and_then(|sub| self.r#type.numbers_mut(sub)),
236 }
237 }
238}
239
240#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
242pub struct FontSpec {
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub fallback: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "ser_opt_nums")]
246 pub weights: Option<Vec<f64>>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub source: Option<String>,
250}
251
252#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
254pub struct RecipeRule {
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub when: Option<Map<String, Value>>,
257 #[serde(default)]
258 pub style: Map<String, Value>,
259}
260
261pub type ThemeRecipes = IndexMap<String, IndexMap<String, Vec<RecipeRule>>>;
263
264#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
266pub struct ResolvedTheme {
267 pub id: String,
268 pub name: String,
269 pub chain: Vec<String>,
271 pub modes: Modes,
272 #[serde(default)]
274 pub contrast: Modes,
275 pub tokens: ThemeTokens,
276 pub fonts: IndexMap<String, FontSpec>,
277 pub recipes: ThemeRecipes,
278}
279
280#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
281pub struct ThemeIssue {
282 pub path: String,
283 pub message: String,
284}
285
286fn issue(path: impl Into<String>, message: impl Into<String>) -> ThemeIssue {
287 ThemeIssue { path: path.into(), message: message.into() }
288}
289
290#[derive(Clone, PartialEq, Eq, Debug)]
292pub struct ThemeError {
293 pub id: String,
294 pub issues: Vec<ThemeIssue>,
295}
296
297impl fmt::Display for ThemeError {
298 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299 write!(f, "Theme \"{}\" is invalid:", self.id)?;
300 for i in &self.issues {
301 write!(f, "\n {}: {}", i.path, i.message)?;
302 }
303 Ok(())
304 }
305}
306
307impl std::error::Error for ThemeError {}
308
309#[derive(Clone, Debug)]
311pub enum ThemeRef {
312 Source(Value),
313 Resolved(Arc<ResolvedTheme>),
314}
315
316impl ThemeRef {
317 pub fn id(&self) -> Option<&str> {
319 match self {
320 ThemeRef::Source(v) => v.get("id").and_then(Value::as_str),
321 ThemeRef::Resolved(t) => Some(&t.id),
322 }
323 }
324}
325
326static CORE_VIEW: LazyLock<Arc<CatalogView>> = LazyLock::new(CatalogView::core);
327
328#[derive(Clone, Copy)]
331pub struct ThemeOptions<'a> {
332 pub themes: &'a [ThemeRef],
333 pub view: &'a CatalogView,
334}
335
336impl<'a> ThemeOptions<'a> {
337 pub fn core(themes: &'a [ThemeRef]) -> ThemeOptions<'a> {
339 ThemeOptions { themes, view: &CORE_VIEW }
340 }
341}
342
343impl Default for ThemeOptions<'static> {
344 fn default() -> Self {
345 ThemeOptions { themes: &[], view: &CORE_VIEW }
346 }
347}
348
349#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
351pub struct RecipeQuery {
352 pub component: String,
353 pub part: String,
354 #[serde(default)]
355 pub props: Props,
356 #[serde(default)]
357 pub states: Vec<String>,
358}
359
360impl RecipeQuery {
361 pub fn new(component: impl Into<String>, part: impl Into<String>, props: Props, states: Vec<String>) -> RecipeQuery {
362 RecipeQuery { component: component.into(), part: part.into(), props, states }
363 }
364}
365
366const MODE_GROUPS: [&str; 2] = ["color", "shadow"];
372const STRING_GROUPS: [&str; 1] = ["type.family"];
373const KEY_GROUPS: &[(&str, &[&str])] = &[
375 ("backgroundColor", &["color"]),
376 ("color", &["color"]),
377 ("borderColor", &["color"]),
378 ("borderWidth", &["border", "control"]),
379 ("borderTopWidth", &["border", "control"]),
380 ("borderRightWidth", &["border", "control"]),
381 ("borderBottomWidth", &["border", "control"]),
382 ("borderLeftWidth", &["border", "control"]),
383 ("borderRadius", &["radius", "spacing"]),
384 ("borderStyle", &[]),
385 ("padding", &["spacing"]),
386 ("paddingHorizontal", &["spacing"]),
387 ("paddingVertical", &["spacing"]),
388 ("gap", &["spacing"]),
389 ("width", &["control", "spacing", "border"]),
390 ("height", &["control", "spacing", "border"]),
391 ("minWidth", &["control", "spacing"]),
392 ("minHeight", &["control", "spacing"]),
393 ("fontSize", &["type.size"]),
394 ("fontWeight", &["type.weight"]),
395 ("lineHeight", &["type.lineHeight"]),
396 ("fontFamily", &["type.family"]),
397 ("letterSpacing", &[]),
398 ("textDecoration", &[]),
399 ("textTransform", &[]),
400 ("fontStyle", &[]),
401 ("boxShadow", &["shadow"]),
402 ("opacity", &["opacity"]),
403 ("transition", &["motion"]),
404 ("transitionEasing", &["ease"]),
405 ("transform", &[]),
406 ("backdropBlur", &["blur"]),
407 ("animation", &[]),
408 ("marginTop", &["spacing"]),
410 ("marginBottom", &["spacing"]),
411];
412const COLOR_KEYS: [&str; 3] = ["backgroundColor", "color", "borderColor"];
413const TOKEN_ONLY_KEYS: [&str; 5] = ["fontFamily", "boxShadow", "transition", "transitionEasing", "backdropBlur"];
414const ENUM_KEYS: [&str; 5] = ["borderStyle", "textDecoration", "textTransform", "fontStyle", "animation"];
415const THEME_KEYS: [&str; 10] = ["$schema", "$comment", "id", "name", "extends", "modes", "contrast", "tokens", "fonts", "recipes"];
416
417fn style_key_enum(key: &str) -> Vec<Value> {
419 crate::style_check::STYLE_SPECS.get(key).and_then(|s| s.enum_.clone()).unwrap_or_default()
420}
421
422fn key_groups(key: &str) -> &'static [&'static str] {
423 KEY_GROUPS.iter().find(|(k, _)| *k == key).map(|(_, g)| *g).unwrap_or(&[])
424}
425
426fn known<S: AsRef<str>>(names: &[S]) -> String {
427 names.iter().map(AsRef::as_ref).collect::<Vec<_>>().join("|")
428}
429
430fn token_names(group: &str) -> Option<&'static Vec<String>> {
431 TOKEN_GROUPS.get(group)
432}
433
434fn token_groups_in_order() -> impl Iterator<Item = (&'static str, &'static Vec<String>)> {
438 crate::generated::catalog::TOKEN_GROUPS.iter().filter_map(|g| TOKEN_GROUPS.get(*g).map(|names| (*g, names)))
439}
440
441fn names_include(names: &[String], name: &str) -> bool {
442 names.iter().any(|n| n == name)
443}
444
445pub fn is_theme_hex(value: &Value) -> bool {
447 let Some(s) = value.as_str() else { return false };
448 let Some(hex) = s.strip_prefix('#') else { return false };
449 (hex.len() == 6 || hex.len() == 8) && hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
450}
451
452fn is_kebab_id(s: &str) -> bool {
453 let mut bytes = s.bytes();
454 matches!(bytes.next(), Some(b) if b.is_ascii_lowercase()) && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
455}
456
457fn is_finite_number(v: &Value) -> Option<f64> {
458 v.as_f64().filter(|n| n.is_finite())
459}
460
461fn check_number_map(value: &Value, path: &str, names: &[String], issues: &mut Vec<ThemeIssue>, range: Option<(f64, f64)>) {
466 let Some(obj) = value.as_object() else {
467 issues.push(issue(path, "expected an object of token values"));
468 return;
469 };
470 for (name, v) in obj {
471 let at = format!("{path}.{name}");
472 if !names_include(names, name) {
473 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
474 } else if let Some(n) = is_finite_number(v) {
475 if let Some((lo, hi)) = range {
476 if n < lo || n > hi {
477 issues.push(issue(at, format!("expected {}–{}", json::number_to_string(lo), json::number_to_string(hi))));
478 }
479 }
480 } else {
481 issues.push(issue(at, "expected a number"));
482 }
483 }
484}
485
486fn check_easing_map(value: &Value, path: &str, names: &[String], issues: &mut Vec<ThemeIssue>) {
487 let Some(obj) = value.as_object() else {
488 issues.push(issue(path, "expected an object of easing curves"));
489 return;
490 };
491 for (name, v) in obj {
492 let at = format!("{path}.{name}");
493 if !names_include(names, name) {
494 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
495 } else if !v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|n| is_finite_number(n).is_some())) {
496 issues.push(issue(at, "expected [x1, y1, x2, y2]"));
497 }
498 }
499}
500
501fn check_shadow(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
502 let Some(layers) = value.as_array() else {
503 issues.push(issue(path, "expected a list of shadow layers"));
504 return;
505 };
506 for (i, layer) in layers.iter().enumerate() {
507 let at = format!("{path}[{i}]");
508 let Some(layer) = layer.as_object() else {
509 issues.push(issue(at, "expected {x, y, blur, spread, color}"));
510 continue;
511 };
512 for k in ["x", "y", "blur", "spread"] {
513 if !layer.get(k).is_some_and(Value::is_number) {
514 issues.push(issue(format!("{at}.{k}"), "expected a number"));
515 }
516 }
517 if !layer.get("color").is_some_and(is_theme_hex) {
518 issues.push(issue(format!("{at}.color"), "expected #rrggbb or #rrggbbaa"));
519 }
520 }
521}
522
523fn check_mode(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
524 let Some(obj) = value.as_object() else {
525 issues.push(issue(path, "expected {color, shadow}"));
526 return;
527 };
528 for (group, entries) in obj {
529 if !MODE_GROUPS.contains(&group.as_str()) {
530 issues.push(issue(format!("{path}.{group}"), "unknown mode group; known: color|shadow"));
531 continue;
532 }
533 let Some(entries) = entries.as_object() else {
534 issues.push(issue(format!("{path}.{group}"), "expected an object of token values"));
535 continue;
536 };
537 let names = token_names(group).map(Vec::as_slice).unwrap_or(&[]);
538 for (name, v) in entries {
539 let at = format!("{path}.{group}.{name}");
540 if !names_include(names, name) {
541 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
542 } else if group == "color" && !is_theme_hex(v) {
543 issues.push(issue(at, "expected #rrggbb or #rrggbbaa (lowercase); the builder converts oklch/hsl on import"));
544 } else if group == "shadow" {
545 check_shadow(v, &at, issues);
546 }
547 }
548 }
549}
550
551fn check_modes(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
552 let Some(modes) = value.as_object() else {
553 issues.push(issue(path, "expected {light, dark}"));
554 return;
555 };
556 for (mode, v) in modes {
557 if Mode::parse(mode).is_none() {
558 issues.push(issue(format!("{path}.{mode}"), "unknown mode; known: light|dark"));
559 } else {
560 check_mode(v, &format!("{path}.{mode}"), issues);
561 }
562 }
563}
564
565fn check_tokens(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
566 let Some(obj) = value.as_object() else {
567 issues.push(issue(path, "expected the token groups"));
568 return;
569 };
570 for (group, entries) in obj {
571 let at = format!("{path}.{group}");
572 if group == "type" {
573 let Some(subs) = entries.as_object() else {
574 issues.push(issue(at, "expected {size, lineHeight, weight, family}"));
575 continue;
576 };
577 for (sub, map) in subs {
578 let g = format!("type.{sub}");
579 let Some(names) = token_names(&g) else {
580 issues.push(issue(format!("{at}.{sub}"), "unknown type group; known: size|lineHeight|weight|family"));
581 continue;
582 };
583 if STRING_GROUPS.contains(&g.as_str()) {
584 let Some(map) = map.as_object() else {
585 issues.push(issue(format!("{at}.{sub}"), "expected an object of family names"));
586 continue;
587 };
588 for (name, v) in map {
589 if !names_include(names, name) {
590 issues.push(issue(format!("{at}.{sub}.{name}"), format!("unknown token; known: {}", known(names))));
591 } else if v.as_str().is_none_or(str::is_empty) {
592 issues.push(issue(format!("{at}.{sub}.{name}"), "expected a font family name"));
593 }
594 }
595 } else {
596 check_number_map(map, &format!("{at}.{sub}"), names, issues, None);
597 }
598 }
599 continue;
600 }
601 if MODE_GROUPS.contains(&group.as_str()) {
602 issues.push(issue(at, format!("{group} values live under modes.light / modes.dark")));
603 continue;
604 }
605 let Some(names) = token_names(group) else {
606 let groups: Vec<&str> = token_groups_in_order()
607 .map(|(g, _)| g)
608 .filter(|g| !g.starts_with("type.") && !MODE_GROUPS.contains(g))
609 .collect();
610 issues.push(issue(at, format!("unknown token group; known: {}|type", known(&groups))));
611 continue;
612 };
613 if TUPLE_GROUPS.contains(&group.as_str()) {
614 check_easing_map(entries, &at, names, issues);
615 continue;
616 }
617 let range = match group.as_str() {
618 "opacity" => Some((0.0, 1.0)),
619 "density" => Some((0.5, 2.0)),
620 "scale" => Some((0.5, 1.5)),
621 _ => None,
622 };
623 check_number_map(entries, &at, names, issues, range);
624 }
625}
626
627fn check_fonts(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
628 let Some(obj) = value.as_object() else {
629 issues.push(issue(path, "expected an object keyed by family name"));
630 return;
631 };
632 for (family, spec) in obj {
633 let at = format!("{path}.{family}");
634 let Some(spec) = spec.as_object() else {
635 issues.push(issue(at, "expected {fallback?, weights?, source?}"));
636 continue;
637 };
638 for key in spec.keys() {
639 if !["fallback", "weights", "source"].contains(&key.as_str()) {
640 issues.push(issue(format!("{at}.{key}"), "unknown font key; known: fallback|weights|source"));
641 }
642 }
643 if spec.get("fallback").is_some_and(|v| !v.is_string()) {
644 issues.push(issue(format!("{at}.fallback"), "expected a string"));
645 }
646 if let Some(w) = spec.get("weights") {
647 if !w.as_array().is_some_and(|a| a.iter().all(Value::is_number)) {
648 issues.push(issue(format!("{at}.weights"), "expected a list of numbers"));
649 }
650 }
651 if let Some(s) = spec.get("source") {
652 if s.as_str() != Some("system") && s.as_str() != Some("host") {
653 issues.push(issue(format!("{at}.source"), "expected system|host"));
654 }
655 }
656 }
657}
658
659fn check_recipe_value(key: &str, value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
660 if key == "native" {
661 if !value.is_boolean() {
662 issues.push(issue(path, "expected true|false"));
663 }
664 return;
665 }
666 if ENUM_KEYS.contains(&key) {
667 let allowed = style_key_enum(key);
668 if !allowed.iter().any(|a| json::strict_eq(a, value)) {
669 let names: Vec<String> = allowed.iter().map(json::to_js_string).collect();
670 issues.push(issue(path, format!("expected {}", known(&names))));
671 }
672 return;
673 }
674 if key == "transform" {
675 if !value.as_str().is_some_and(crate::style_check::is_transform) {
676 issues.push(issue(path, "expected translate(Xpx, Ypx), scale(N) and/or rotate(Ndeg)"));
677 }
678 return;
679 }
680 let groups = key_groups(key);
681 if let Some((group, name)) = parse_token_ref(value) {
682 if !groups.contains(&group.as_str()) {
683 if groups.is_empty() {
684 issues.push(issue(path, "expected a number, not a token"));
685 } else {
686 issues.push(issue(path, format!("expected a ${} token, got ${group}", groups.join("|$"))));
687 }
688 } else if !token_names(&group).is_some_and(|names| names_include(names, &name)) {
689 let names = token_names(&group).map(Vec::as_slice).unwrap_or(&[]);
690 issues.push(issue(path, format!("unknown token {}; known: {}", value.as_str().unwrap_or(""), known(names))));
691 }
692 return;
693 }
694 if COLOR_KEYS.contains(&key) {
695 if !is_theme_hex(value) {
696 issues.push(issue(path, "expected #rrggbb, #rrggbbaa or $color.<name>"));
697 }
698 return;
699 }
700 if TOKEN_ONLY_KEYS.contains(&key) {
701 issues.push(issue(path, format!("expected a ${}.<name> token", groups.first().copied().unwrap_or(""))));
702 return;
703 }
704 let weights = style_key_enum("fontWeight");
705 match is_finite_number(value) {
706 None if groups.is_empty() => issues.push(issue(path, "expected a number")),
707 None => issues.push(issue(path, format!("expected a number or a ${} token", groups.join("|$")))),
708 Some(n) if key == "opacity" && !(0.0..=1.0).contains(&n) => issues.push(issue(path, "expected 0–1")),
709 Some(n) if key == "fontWeight" && !weights.iter().any(|w| w.as_f64() == Some(n)) => {
710 let names: Vec<String> = weights.iter().map(json::to_js_string).collect();
711 issues.push(issue(path, format!("expected {} or $type.weight.<name>", known(&names))))
712 }
713 Some(_) => {}
714 }
715}
716
717fn check_rule(rule: &Value, path: &str, spec: &PartSpec, issues: &mut Vec<ThemeIssue>) {
718 let Some(rule) = rule.as_object() else {
719 issues.push(issue(path, "expected {when?, style}"));
720 return;
721 };
722 for key in rule.keys() {
723 if key != "when" && key != "style" {
724 issues.push(issue(format!("{path}.{key}"), "unknown rule key; known: when|style"));
725 }
726 }
727 if let Some(when) = rule.get("when") {
728 match when.as_object() {
729 None => issues.push(issue(format!("{path}.when"), "expected an object of recipe props")),
730 Some(when) => {
731 for (k, v) in when {
732 let at = format!("{path}.when.{k}");
733 if k == "state" {
734 let states: Vec<&Value> = match v {
735 Value::Array(a) => a.iter().collect(),
736 other => vec![other],
737 };
738 for s in states {
739 if !s.as_str().is_some_and(|s| RECIPE_STATES.contains(&s)) {
740 issues.push(issue(at.clone(), format!("unknown state; known: {}", known(RECIPE_STATES))));
741 }
742 }
743 } else if !names_include(&spec.props, k) {
744 let mut names = vec!["state".to_string()];
745 names.extend(spec.props.iter().cloned());
746 issues.push(issue(at, format!("not a recipe prop of this part; known: {}", known(&names))));
747 } else {
748 let ok = |x: &Value| x.is_string() || x.is_number() || x.is_boolean();
749 if !(ok(v) || v.as_array().is_some_and(|a| a.iter().all(ok))) {
750 issues.push(issue(at, "expected a value or a list of values"));
751 }
752 }
753 }
754 }
755 }
756 }
757 let Some(style) = rule.get("style").and_then(Value::as_object) else {
758 issues.push(issue(format!("{path}.style"), "expected a style object"));
759 return;
760 };
761 for (key, value) in style {
762 let at = format!("{path}.style.{key}");
763 if !RECIPE_KEYS.contains(&key.as_str()) {
764 issues.push(issue(at, format!("not a recipe key; known: {}", known(RECIPE_KEYS))));
765 } else {
766 check_recipe_value(key, value, &at, issues);
767 }
768 }
769}
770
771fn check_recipes(value: &Value, path: &str, parts: &IndexMap<String, PartSpec>, issues: &mut Vec<ThemeIssue>) {
772 let Some(obj) = value.as_object() else {
773 issues.push(issue(path, "expected an object keyed by component"));
774 return;
775 };
776 for (component, by_part) in obj {
777 let at = format!("{path}.{component}");
778 let Some(spec) = parts.get(component) else {
779 issues.push(issue(at, "unknown component"));
780 continue;
781 };
782 let Some(by_part) = by_part.as_object() else {
783 issues.push(issue(at, format!("expected an object keyed by part; known: {}", known(&spec.parts))));
784 continue;
785 };
786 for (part, rules) in by_part {
787 let at_part = format!("{at}.{part}");
788 if !names_include(&spec.parts, part) {
789 issues.push(issue(at_part, format!("unknown part; known: {}", known(&spec.parts))));
790 continue;
791 }
792 let Some(rules) = rules.as_array() else {
793 issues.push(issue(at_part, "expected a list of rules [{when?, style}]"));
794 continue;
795 };
796 for (i, rule) in rules.iter().enumerate() {
797 check_rule(rule, &format!("{at_part}[{i}]"), spec, issues);
798 }
799 }
800 }
801}
802
803fn index_for(view: &CatalogView) -> Arc<RecipeIndex> {
806 if std::ptr::eq(view, &**CORE_VIEW) {
807 RecipeIndex::core()
808 } else {
809 Arc::new(RecipeIndex::new(view))
810 }
811}
812
813pub fn validate_theme(source: &Value, options: &ThemeOptions) -> Vec<ThemeIssue> {
816 let mut issues = Vec::new();
817 let Some(src) = source.as_object() else {
818 return vec![issue("theme", "expected a theme object {id, name, modes, tokens, recipes}")];
819 };
820 if src.get("$schema").and_then(Value::as_str) != Some(THEME_SCHEMA_ID) {
823 issues.push(issue("$schema", format!("expected \"{THEME_SCHEMA_ID}\"")));
824 }
825 if !src.get("id").and_then(Value::as_str).is_some_and(is_kebab_id) {
826 issues.push(issue("id", "expected a kebab-case id"));
827 }
828 if src.get("name").and_then(Value::as_str).is_none_or(str::is_empty) {
829 issues.push(issue("name", "expected a name"));
830 }
831 for key in src.keys() {
832 if !THEME_KEYS.contains(&key.as_str()) {
833 issues.push(issue(key.clone(), "unknown theme key; known: id|name|extends|modes|contrast|tokens|fonts|recipes"));
834 }
835 }
836 if let Some(ext) = src.get("extends") {
837 let parents: Vec<&str> = options.themes.iter().map(|t| t.id().unwrap_or("")).collect();
838 match ext.as_str() {
839 None => issues.push(issue("extends", "expected a theme id")),
840 Some(e) if !parents.contains(&e) => issues.push(issue("extends", format!("unknown theme \"{e}\"; known: {}", known(&parents)))),
841 Some(e) if src.get("id").and_then(Value::as_str) == Some(e) => issues.push(issue("extends", "a theme cannot extend itself")),
842 Some(_) => {}
843 }
844 }
845 if let Some(modes) = src.get("modes") {
846 check_modes(modes, "modes", &mut issues);
847 }
848 if let Some(contrast) = src.get("contrast") {
849 check_modes(contrast, "contrast", &mut issues);
850 }
851 if let Some(tokens) = src.get("tokens") {
852 check_tokens(tokens, "tokens", &mut issues);
853 }
854 if let Some(fonts) = src.get("fonts") {
855 check_fonts(fonts, "fonts", &mut issues);
856 }
857 if let Some(recipes) = src.get("recipes") {
858 check_recipes(recipes, "recipes", &index_for(options.view).parts, &mut issues);
859 }
860 issues
861}
862
863fn empty_theme(source: &Map<String, Value>) -> ResolvedTheme {
868 ResolvedTheme {
869 id: source.get("id").and_then(Value::as_str).unwrap_or_default().to_string(),
870 name: source.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
871 ..ResolvedTheme::default()
872 }
873}
874
875fn overlay_mode_source(tm: &mut ThemeMode, m: Option<&Value>) {
876 let Some(m) = m else { return };
877 for (name, v) in m.get("color").and_then(Value::as_object).into_iter().flatten() {
878 tm.color.insert(name.clone(), v.as_str().unwrap_or_default().to_string());
879 }
880 for (name, v) in m.get("shadow").and_then(Value::as_object).into_iter().flatten() {
881 let layers: Vec<Shadow> = serde_json::from_value(v.clone()).unwrap_or_default();
882 tm.shadow.insert(name.clone(), layers);
883 }
884}
885
886fn overlay_mode(tm: &mut ThemeMode, from: &ThemeMode) {
887 for (k, v) in &from.color {
888 tm.color.insert(k.clone(), v.clone());
889 }
890 for (k, v) in &from.shadow {
891 tm.shadow.insert(k.clone(), v.clone());
892 }
893}
894
895fn overlay(target: &mut ResolvedTheme, source: &Value) {
897 for mode in Mode::ALL {
898 overlay_mode_source(target.modes.get_mut(mode), source.get("modes").and_then(|ms| ms.get(mode.as_str())));
899 overlay_mode_source(target.contrast.get_mut(mode), source.get("contrast").and_then(|ms| ms.get(mode.as_str())));
900 }
901 if let Some(t) = source.get("tokens") {
902 for group in NUMBER_GROUPS {
903 let table = target.tokens.numbers_mut(group).expect("number group");
904 for (name, v) in t.get(group).and_then(Value::as_object).into_iter().flatten() {
905 table.insert(name.clone(), v.as_f64().unwrap_or_default());
906 }
907 }
908 for (name, v) in t.get("ease").and_then(Value::as_object).into_iter().flatten() {
909 let curve: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
910 target.tokens.ease.insert(name.clone(), curve);
911 }
912 for sub in TYPE_SUBS {
913 let entries = t.get("type").and_then(|ty| ty.get(sub)).and_then(Value::as_object);
914 for (name, v) in entries.into_iter().flatten() {
915 if sub == "family" {
916 target.tokens.r#type.family.insert(name.clone(), v.as_str().unwrap_or_default().to_string());
917 } else if let Some(table) = target.tokens.r#type.numbers_mut(sub) {
918 table.insert(name.clone(), v.as_f64().unwrap_or_default());
919 }
920 }
921 }
922 }
923 for (family, spec) in source.get("fonts").and_then(Value::as_object).into_iter().flatten() {
924 let spec: FontSpec = serde_json::from_value(spec.clone()).unwrap_or_default();
925 target.fonts.insert(family.clone(), spec);
926 }
927 for (component, by_part) in source.get("recipes").and_then(Value::as_object).into_iter().flatten() {
928 let parts = target.recipes.entry(component.clone()).or_default();
929 for (part, rules) in by_part.as_object().into_iter().flatten() {
930 let rules: Vec<RecipeRule> = serde_json::from_value(rules.clone()).unwrap_or_default();
931 parts.entry(part.clone()).or_default().extend(rules);
932 }
933 }
934}
935
936fn overlay_resolved(target: &mut ResolvedTheme, parent: &ResolvedTheme) {
937 for mode in Mode::ALL {
938 overlay_mode(target.modes.get_mut(mode), parent.modes.get(mode));
939 overlay_mode(target.contrast.get_mut(mode), parent.contrast.get(mode));
940 }
941 target.tokens = parent.tokens.clone();
942 target.fonts = parent.fonts.clone();
943 target.recipes = parent.recipes.clone();
944}
945
946fn completeness(theme: &ResolvedTheme) -> Vec<ThemeIssue> {
948 let mut issues = Vec::new();
949 for (group, names) in token_groups_in_order() {
950 for name in names {
951 if MODE_GROUPS.contains(&group) {
952 for mode in Mode::ALL {
953 let m = theme.modes.get(mode);
954 let present = if group == "color" { m.color.contains_key(name) } else { m.shadow.contains_key(name) };
955 if !present {
956 issues.push(issue(format!("modes.{}.{group}.{name}", mode.as_str()), format!("missing value for ${group}.{name}")));
957 }
958 }
959 continue;
960 }
961 let present = if group == "type.family" {
962 theme.tokens.r#type.family.contains_key(name)
963 } else if group == "ease" {
964 theme.tokens.ease.contains_key(name)
965 } else {
966 theme.tokens.numbers(group).is_some_and(|t| t.contains_key(name))
967 };
968 if !present {
969 issues.push(issue(format!("tokens.{group}.{name}"), format!("missing value for ${group}.{name}")));
970 }
971 }
972 }
973 for family in theme.tokens.r#type.family.values() {
974 if !theme.fonts.contains_key(family) {
975 issues.push(issue(format!("fonts.{family}"), "family named by tokens.type.family has no fonts entry"));
976 }
977 }
978 issues
979}
980
981enum Link<'a> {
982 Source(&'a Value),
983 Resolved(&'a ResolvedTheme),
984}
985
986fn chain_of<'a>(source: &'a Value, options: &ThemeOptions<'a>) -> Result<Vec<Link<'a>>, ThemeIssue> {
988 let id_of = |v: &Value| v.get("id").and_then(Value::as_str).unwrap_or("").to_string();
989 let mut chain = vec![Link::Source(source)];
990 let mut seen = vec![id_of(source)];
991 let mut current = source;
992 while let Some(parent_id) = current.get("extends").and_then(Value::as_str).filter(|s| !s.is_empty()) {
993 let Some(parent) = options.themes.iter().find(|t| t.id() == Some(parent_id)) else {
994 return Err(issue("extends", format!("unknown theme \"{parent_id}\"")));
995 };
996 let pid = parent.id().unwrap_or("").to_string();
997 if seen.contains(&pid) {
998 seen.push(pid);
999 return Err(issue("extends", format!("cycle: {}", seen.join(" → "))));
1000 }
1001 seen.push(pid);
1002 match parent {
1003 ThemeRef::Resolved(t) => {
1004 chain.insert(0, Link::Resolved(t));
1005 break;
1006 }
1007 ThemeRef::Source(v) => {
1008 chain.insert(0, Link::Source(v));
1009 current = v;
1010 }
1011 }
1012 }
1013 Ok(chain)
1014}
1015
1016pub fn try_load_theme(source: &Value, options: &ThemeOptions) -> Result<ResolvedTheme, Vec<ThemeIssue>> {
1018 let issues = validate_theme(source, options);
1019 if !issues.is_empty() {
1020 return Err(issues);
1021 }
1022 let src = source.as_object().expect("validated");
1023 let chain = chain_of(source, options).map_err(|i| vec![i])?;
1024 let mut theme = empty_theme(src);
1025 let last = chain.len() - 1;
1026 for (i, link) in chain.iter().enumerate() {
1027 match link {
1028 Link::Resolved(parent) => {
1029 overlay_resolved(&mut theme, parent);
1030 theme.chain.extend(parent.chain.iter().cloned());
1031 }
1032 Link::Source(v) => {
1033 let id = v.get("id").and_then(Value::as_str).unwrap_or("").to_string();
1034 if i != last {
1035 let parent_issues = validate_theme(v, options);
1036 if !parent_issues.is_empty() {
1037 return Err(parent_issues
1038 .into_iter()
1039 .map(|p| issue(format!("extends({id}).{}", p.path), p.message))
1040 .collect());
1041 }
1042 }
1043 overlay(&mut theme, v);
1044 theme.chain.push(id);
1045 }
1046 }
1047 }
1048 derive_colors(&mut theme);
1049 let missing = completeness(&theme);
1050 if !missing.is_empty() {
1051 return Err(missing);
1052 }
1053 Ok(theme)
1054}
1055
1056fn derive_colors(theme: &mut ResolvedTheme) {
1060 for mode in Mode::ALL {
1061 let m = theme.modes.get_mut(mode);
1062 if !m.color.contains_key("link") {
1063 if let Some(primary) = m.color.get("primary").cloned() {
1064 m.color.insert("link".to_string(), primary);
1065 }
1066 }
1067 }
1068}
1069
1070pub fn load_theme(source: &Value, options: &ThemeOptions) -> Result<ResolvedTheme, ThemeError> {
1072 try_load_theme(source, options).map_err(|issues| ThemeError {
1073 id: source.get("id").and_then(Value::as_str).unwrap_or("?").to_string(),
1074 issues,
1075 })
1076}
1077
1078fn numbers_value(m: &IndexMap<String, f64>) -> Value {
1083 Value::Object(m.iter().map(|(k, v)| (k.clone(), json::number(*v))).collect())
1084}
1085
1086pub fn resolve_token(theme: &ResolvedTheme, reference: &Value, mode: Mode) -> Option<Value> {
1090 let (group, name) = parse_token_ref(reference)?;
1091 let m = theme.modes.get(mode);
1092 match group.as_str() {
1093 "color" => m.color.get(&name).map(|c| Value::String(c.clone())),
1094 "shadow" => m.shadow.get(&name).map(|l| serde_json::to_value(l).expect("shadow")),
1095 "type.family" => theme.tokens.r#type.family.get(&name).map(|f| Value::String(f.clone())),
1096 "ease" => theme.tokens.ease.get(&name).map(|c| Value::Array(c.iter().map(|n| json::number(*n)).collect())),
1097 "type" => match name.as_str() {
1099 "family" => serde_json::to_value(&theme.tokens.r#type.family).ok(),
1100 sub => theme.tokens.r#type.numbers(sub).map(numbers_value),
1101 },
1102 g => theme.tokens.numbers(g).and_then(|t| t.get(&name)).map(|v| json::number(*v)),
1103 }
1104}
1105
1106pub fn resolve_condition_key(theme: &ResolvedTheme, key: &str) -> String {
1109 let Some(condition) = crate::conditions::parse_media_condition(key) else { return key.to_string() };
1110 let Some(name) = crate::conditions::breakpoint_ref(&condition.value) else { return key.to_string() };
1111 match theme.tokens.breakpoint.get(name) {
1112 Some(px) => key.replace(&condition.value, &format!("{}px", json::number_to_string(*px))),
1113 None => key.to_string(),
1114 }
1115}
1116
1117fn resolve_style_value(theme: &ResolvedTheme, value: &Value, mode: Mode) -> Value {
1118 match value {
1119 Value::Array(items) => Value::Array(items.iter().map(|v| resolve_style_value(theme, v, mode)).collect()),
1120 Value::Object(nested) => Value::Object(resolve_style_values(theme, nested, mode)),
1121 Value::String(s) if s.starts_with('$') => resolve_token(theme, value, mode).unwrap_or_else(|| value.clone()),
1122 other => other.clone(),
1123 }
1124}
1125
1126pub fn resolve_style_values(theme: &ResolvedTheme, style: &Props, mode: Mode) -> Props {
1130 let mut out = Props::new();
1131 for (key, value) in style {
1132 out.insert(resolve_condition_key(theme, key), resolve_style_value(theme, value, mode));
1133 }
1134 out
1135}
1136
1137#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, Serialize, Deserialize)]
1143#[serde(rename_all = "lowercase")]
1144pub enum ModeSetting {
1145 Light,
1146 Dark,
1147 #[default]
1148 System,
1149}
1150
1151impl ModeSetting {
1152 pub fn parse(s: &str) -> Option<ModeSetting> {
1153 match s {
1154 "light" => Some(ModeSetting::Light),
1155 "dark" => Some(ModeSetting::Dark),
1156 "system" => Some(ModeSetting::System),
1157 _ => None,
1158 }
1159 }
1160}
1161
1162pub fn resolve_mode(setting: ModeSetting, system_prefers_dark: bool) -> Mode {
1164 match setting {
1165 ModeSetting::Light => Mode::Light,
1166 ModeSetting::Dark => Mode::Dark,
1167 ModeSetting::System => {
1168 if system_prefers_dark {
1169 Mode::Dark
1170 } else {
1171 Mode::Light
1172 }
1173 }
1174 }
1175}
1176
1177#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, Serialize, Deserialize)]
1179#[serde(rename_all = "lowercase")]
1180pub enum Density {
1181 Compact,
1182 #[default]
1183 Default,
1184 Comfortable,
1185}
1186
1187impl Density {
1188 pub fn parse(s: &str) -> Option<Density> {
1189 match s {
1190 "compact" => Some(Density::Compact),
1191 "default" => Some(Density::Default),
1192 "comfortable" => Some(Density::Comfortable),
1193 _ => None,
1194 }
1195 }
1196 pub fn as_str(self) -> &'static str {
1197 match self {
1198 Density::Compact => "compact",
1199 Density::Default => "default",
1200 Density::Comfortable => "comfortable",
1201 }
1202 }
1203}
1204
1205fn scaled(table: &IndexMap<String, f64>, factor: f64) -> IndexMap<String, f64> {
1206 table.iter().map(|(k, v)| (k.clone(), crate::expr::js_round(v * factor))).collect()
1207}
1208
1209pub fn apply_density(theme: &ResolvedTheme, density: Density) -> ResolvedTheme {
1215 let Some(factor) = (density != Density::Default).then(|| theme.tokens.density.get(density.as_str()).copied()).flatten() else {
1216 return theme.clone();
1217 };
1218 let mut out = theme.clone();
1219 out.tokens.control = scaled(&theme.tokens.control, factor);
1220 out.tokens.spacing = scaled(&theme.tokens.spacing, factor);
1221 out
1222}
1223
1224pub fn apply_contrast(theme: &ResolvedTheme) -> ResolvedTheme {
1227 let mut out = theme.clone();
1228 for mode in Mode::ALL {
1229 overlay_mode(out.modes.get_mut(mode), theme.contrast.get(mode));
1230 }
1231 out
1232}
1233
1234pub fn easing_css(curve: &[f64]) -> String {
1236 format!("cubic-bezier({})", curve.iter().map(|n| json::number_to_string(*n)).collect::<Vec<_>>().join(", "))
1237}
1238
1239fn matches(rule: &RecipeRule, props: &Props, states: &[String]) -> bool {
1240 let Some(when) = &rule.when else { return true };
1241 for (key, want) in when {
1242 if key == "state" {
1243 let needed: Vec<&Value> = match want {
1244 Value::Array(a) => a.iter().collect(),
1245 other => vec![other],
1246 };
1247 if !needed.iter().all(|s| s.as_str().is_some_and(|s| states.iter().any(|x| x == s))) {
1248 return false;
1249 }
1250 continue;
1251 }
1252 let ok = match props.get(key) {
1253 None => false,
1254 Some(actual) => match want {
1255 Value::Array(list) => list.iter().any(|w| json::strict_eq(w, actual)),
1256 w => json::strict_eq(w, actual),
1257 },
1258 };
1259 if !ok {
1260 return false;
1261 }
1262 }
1263 true
1264}
1265
1266pub fn rule_specificity(rule: &RecipeRule) -> usize {
1275 let Some(when) = &rule.when else { return 0 };
1276 when.iter()
1277 .map(|(key, want)| match (key.as_str(), want) {
1278 ("state", Value::Array(list)) => list.len(),
1279 _ => 1,
1280 })
1281 .sum()
1282}
1283
1284pub fn recipe_style(theme: &ResolvedTheme, query: &RecipeQuery) -> Props {
1285 let mut out = Props::new();
1286 let Some(rules) = theme.recipes.get(&query.component).and_then(|p| p.get(&query.part)) else { return out };
1287 let mut ordered: Vec<(usize, &RecipeRule)> = rules.iter().enumerate().collect();
1288 ordered.sort_by_key(|(_, r)| rule_specificity(r));
1290 for (_, rule) in ordered {
1291 if matches(rule, &query.props, &query.states) {
1292 for (k, v) in &rule.style {
1293 out.insert(k.clone(), v.clone());
1294 }
1295 }
1296 }
1297 out
1298}
1299
1300pub fn recipe_reacts_to(theme: &ResolvedTheme, query: &RecipeQuery, state: &str) -> bool {
1306 let Some(rules) = theme.recipes.get(&query.component).and_then(|p| p.get(&query.part)) else { return false };
1307 rules.iter().any(|rule| {
1308 let Some(when) = &rule.when else { return false };
1309 let names_state = match when.get("state") {
1310 Some(Value::Array(list)) => list.iter().any(|s| s.as_str() == Some(state)),
1311 Some(v) => v.as_str() == Some(state),
1312 None => false,
1313 };
1314 names_state
1315 && when.iter().filter(|(k, _)| k.as_str() != "state").all(|(key, want)| match query.props.get(key) {
1316 None => false,
1317 Some(actual) => match want {
1318 Value::Array(list) => list.iter().any(|w| json::strict_eq(w, actual)),
1319 w => json::strict_eq(w, actual),
1320 },
1321 })
1322 })
1323}
1324
1325pub fn resolve_recipe(theme: &ResolvedTheme, query: &RecipeQuery, mode: Mode) -> Props {
1327 resolve_style_values(theme, &recipe_style(theme, query), mode)
1328}
1329
1330pub fn node_recipe_query(node: &UiNode, index: &RecipeIndex) -> RecipeQuery {
1333 if let Some(r) = &node.recipe {
1334 return RecipeQuery::new(r.macro_.clone(), r.part.clone(), r.props.clone(), Vec::new());
1335 }
1336 RecipeQuery::new(node.component.clone(), "root", index.native_recipe_props(&node.component, &node.props), Vec::new())
1337}
1338
1339pub fn resolve_node_style(theme: &ResolvedTheme, node: &UiNode, mode: Mode, states: &[String], index: &RecipeIndex) -> Props {
1343 let own_query = RecipeQuery::new(node.component.clone(), "root", index.native_recipe_props(&node.component, &node.props), states.to_vec());
1344 let mut out = resolve_recipe(theme, &own_query, mode);
1345 let style = node.style.as_ref().map(|s| resolve_style_values(theme, s, mode)).unwrap_or_default();
1346 out.extend(style.clone());
1347 if let Some(recipe) = &node.recipe {
1348 let mut q = node_recipe_query(node, index);
1349 q.states = states.to_vec();
1350 out.extend(resolve_recipe(theme, &q, mode));
1351 for authored in &recipe.authored {
1352 let key = resolve_condition_key(theme, authored);
1353 if let Some(v) = style.get(&key) {
1354 out.insert(key, v.clone());
1355 }
1356 }
1357 }
1358 out
1359}
1360
1361pub fn shadow_css(layers: &[Shadow]) -> String {
1363 if layers.is_empty() {
1364 return "none".into();
1365 }
1366 let n = json::number_to_string;
1367 layers
1368 .iter()
1369 .map(|l| format!("{}px {}px {}px {}px {}", n(l.x), n(l.y), n(l.blur), n(l.spread), l.color))
1370 .collect::<Vec<_>>()
1371 .join(", ")
1372}
1373
1374#[cfg(all(test, feature = "builtin-themes"))]
1378mod tests {
1379 #[test]
1383 fn recipe_rules_merge_by_specificity() {
1384 use serde_json::json;
1385 let theme = crate::themes::builtin_theme("exponential").unwrap();
1386 let props = |checked: bool| crate::types::Props::from_iter([("checked".to_string(), json!(checked)), ("disabled".to_string(), json!(false))]);
1387 let on = super::resolve_recipe(&theme, &super::RecipeQuery::new("Switch", "track", props(true), vec!["checked".into()]), super::Mode::Dark);
1388 let off = super::resolve_recipe(&theme, &super::RecipeQuery::new("Switch", "track", props(false), vec![]), super::Mode::Dark);
1389 let color = |name: &str| theme.modes.dark.color.get(name).cloned().map(serde_json::Value::String);
1390 assert_eq!(on.get("backgroundColor").cloned(), color("primary"));
1391 assert_eq!(off.get("backgroundColor").cloned(), color("input"));
1392 let playful = crate::themes::builtin_theme("playful").unwrap();
1393 let track = super::resolve_recipe(&playful, &super::RecipeQuery::new("Switch", "track", props(true), vec!["checked".into()]), super::Mode::Dark);
1394 assert_eq!(track.get("width").and_then(serde_json::Value::as_f64), Some(44.0));
1395 assert_eq!(super::rule_specificity(&super::RecipeRule { when: Some(serde_json::Map::from_iter([("state".to_string(), json!(["hover", "focus"])), ("variant".to_string(), json!("ghost"))])), style: Default::default() }), 3);
1396 }
1397
1398 use super::*;
1399 use crate::themes::{builtin_refs, builtin_theme, builtin_themes, BUILTIN_THEME_IDS};
1400 use serde_json::json;
1401
1402 fn q(component: &str, part: &str, props: Value, states: &[&str]) -> RecipeQuery {
1403 RecipeQuery::new(component, part, props.as_object().cloned().unwrap_or_default(), states.iter().map(|s| s.to_string()).collect())
1404 }
1405
1406 #[test]
1407 fn the_four_built_ins_load_neutral_is_the_root() {
1408 assert_eq!(BUILTIN_THEME_IDS, ["neutral", "exponential", "playful", "orange"]);
1409 for theme in builtin_themes() {
1410 assert_eq!(theme.chain[0], "neutral");
1411 assert_eq!(theme.modes.light.color.len(), theme.modes.dark.color.len());
1412 }
1413 assert_eq!(builtin_theme("playful").unwrap().chain, ["neutral", "playful"]);
1414 assert_eq!(builtin_theme("exponential").unwrap().chain, ["neutral", "exponential"]);
1415 assert_eq!(builtin_theme("orange").unwrap().chain, ["neutral", "exponential", "orange"]);
1416 }
1417
1418 #[test]
1419 fn the_exponential_theme_carries_the_apps_values() {
1420 let t = builtin_theme("exponential").unwrap();
1421 assert_eq!(t.tokens.spacing["md"], 12.0);
1422 assert_eq!(t.tokens.radius["lg"], 12.0);
1423 assert_eq!(t.tokens.r#type.family["sans"], "Inter");
1424 assert_eq!(t.tokens.control["buttonMd"], 32.0);
1425 assert_eq!(t.modes.dark.color["background"], "#0a0a0a");
1426 assert_eq!(t.modes.light.color["background"], "#ffffff");
1427 let card = &t.modes.dark.color["card"];
1428 assert!(card.starts_with("#ffffff") && card.len() == 9);
1429 }
1430
1431 #[test]
1432 fn playful_differs_from_neutral_where_it_says_so_and_inherits_the_rest() {
1433 let neutral = builtin_theme("neutral").unwrap();
1434 let playful = builtin_theme("playful").unwrap();
1435 assert_ne!(playful.tokens.radius["md"], neutral.tokens.radius["md"]);
1436 assert_eq!(playful.tokens.spacing, neutral.tokens.spacing);
1437 assert_eq!(playful.tokens.r#type.family["sans"], "Nunito");
1438 let button = |id: &str| resolve_recipe(&builtin_theme(id).unwrap(), &q("Button", "root", json!({"variant": "default", "size": "default"}), &[]), Mode::Light);
1439 assert_eq!(button("playful")["borderRadius"], json!(9999));
1440 assert_eq!(button("neutral")["borderRadius"], json!(8));
1441 assert_eq!(button("playful")["backgroundColor"], json!("#7c3aed"));
1442 }
1443
1444 #[test]
1445 fn tokens_resolve_per_mode() {
1446 let n = builtin_theme("neutral").unwrap();
1447 assert_eq!(resolve_token(&n, &json!("$color.primary"), Mode::Light), Some(json!("#171717")));
1448 assert_eq!(resolve_token(&n, &json!("$color.primary"), Mode::Dark), Some(json!("#e5e5e5")));
1449 assert_eq!(resolve_token(&n, &json!("$spacing.md"), Mode::Light), Some(json!(12)));
1450 assert_eq!(resolve_token(&n, &json!("$type.family.sans"), Mode::Light), Some(json!("Geist")));
1451 assert_eq!(resolve_token(&n, &json!("$shadow.none"), Mode::Light), Some(json!([])));
1452 assert_eq!(resolve_token(&n, &json!("nope"), Mode::Light), None);
1453 }
1454
1455 fn has(style: &Props, expected: Value) {
1456 for (k, v) in expected.as_object().unwrap() {
1457 assert!(style.get(k).is_some_and(|s| json::equal(s, v)), "{k}: {:?} != {v}", style.get(k));
1458 }
1459 }
1460
1461 #[test]
1462 fn rules_merge_in_order_variant_size_and_state_compose() {
1463 let n = builtin_theme("neutral").unwrap();
1464 let r = |props: Value, states: &[&str]| resolve_recipe(&n, &q("Button", "root", props, states), Mode::Light);
1465 has(&r(json!({"variant": "default", "size": "default"}), &[]), json!({"height": 36, "backgroundColor": "#171717", "color": "#fafafa", "borderRadius": 8}));
1466 has(&r(json!({"variant": "outline", "size": "sm"}), &[]), json!({"height": 32, "borderWidth": 1, "borderColor": "#e5e5e5", "backgroundColor": "#ffffff"}));
1467 has(&r(json!({"variant": "outline", "size": "sm"}), &["hover"]), json!({"backgroundColor": "#f5f5f5"}));
1468 has(&r(json!({"variant": "ghost", "size": "icon"}), &[]), json!({"width": 36, "height": 36, "backgroundColor": "#00000000", "boxShadow": []}));
1469 has(&r(json!({"variant": "default", "size": "default"}), &["disabled"]), json!({"opacity": 0.5}));
1470 has(&r(json!({"variant": "default", "size": "default"}), &["hover"]), json!({"opacity": 0.9}));
1471 }
1472
1473 #[test]
1474 fn a_state_rule_needs_every_listed_state_a_list_value_matches_any() {
1475 let n = builtin_theme("neutral").unwrap();
1476 let style = resolve_recipe(&n, &q("Badge", "icon", json!({"variant": "outline"}), &[]), Mode::Dark);
1477 assert_eq!(style["color"], json!("#fafafa"));
1478 }
1479
1480 #[test]
1481 fn node_precedence_native_recipe_then_node_style_then_macro_part_recipe() {
1482 let n = builtin_theme("neutral").unwrap();
1484 let index = RecipeIndex::core();
1485 let mut label = UiNode::new("b.label", "Text");
1486 label.props = json!({"text": "New", "variant": "caption"}).as_object().cloned().unwrap();
1487 label.recipe = Some(crate::types::Recipe { macro_: "Badge".into(), part: "label".into(), props: json!({"variant": "destructive"}).as_object().cloned().unwrap(), authored: Vec::new() });
1488 let query = node_recipe_query(&label, &index);
1489 assert_eq!(query, q("Badge", "label", json!({"variant": "destructive"}), &[]));
1490 let style = resolve_node_style(&n, &label, Mode::Light, &[], &index);
1491 assert_eq!(style["fontSize"], json!(12));
1492 assert_eq!(style["color"], json!(n.modes.light.color["destructiveForeground"]));
1493 assert_eq!(style["fontWeight"], json!(500));
1494 }
1495
1496 #[test]
1497 fn an_authors_style_on_a_plain_native_wins_over_its_recipe() {
1498 let n = builtin_theme("neutral").unwrap();
1499 let mut text = UiNode::new("t", "Text");
1500 text.props = json!({"text": "x", "variant": "muted"}).as_object().cloned().unwrap();
1501 text.style = json!({"color": "#ff0000"}).as_object().cloned();
1502 assert_eq!(resolve_node_style(&n, &text, Mode::Light, &[], &RecipeIndex::core())["color"], json!("#ff0000"));
1503 }
1504
1505 fn expand(node: Value) -> UiNode {
1506 use crate::catalog::CORE_CATALOG_ID;
1507 use crate::reducer::{reduce_nested, ReduceOptions};
1508 reduce_nested(&serde_json::from_value(node).unwrap(), &ReduceOptions::new(CORE_CATALOG_ID)).root
1509 }
1510
1511 #[test]
1512 fn vapp163_the_expander_lists_the_authors_keys_sorted() {
1513 let heading = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi", "level": "h1"}, "style": {"fontSize": 40, "color": "$color.primaryForeground"}}));
1514 assert_eq!(heading.recipe.as_ref().unwrap().authored, vec!["color".to_string(), "fontSize".to_string()]);
1515 let plain = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi"}}));
1516 assert!(plain.recipe.as_ref().unwrap().authored.is_empty());
1517 assert!(serde_json::to_value(plain.recipe.unwrap()).unwrap().get("authored").is_none());
1518 }
1519
1520 #[test]
1521 fn vapp163_an_authors_style_on_a_heading_wins_over_its_part_recipe() {
1522 let index = RecipeIndex::core();
1523 let heading = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi", "level": "h1"}, "style": {"fontSize": 40, "color": "$color.primaryForeground"}}));
1524 for id in ["playful", "neutral"] {
1525 let t = builtin_theme(id).unwrap();
1526 let style = resolve_node_style(&t, &heading, Mode::Light, &[], &index);
1527 assert_eq!(style["fontSize"], json!(40), "{id}");
1528 assert_eq!(style["color"], json!(t.modes.light.color["primaryForeground"]), "{id}");
1529 let recipe = resolve_recipe(&t, &q("Heading", "root", json!({"level": "h1"}), &[]), Mode::Light);
1530 assert_eq!(style["fontWeight"], recipe["fontWeight"], "{id}");
1531 }
1532 }
1533
1534 #[test]
1535 fn vapp163_badge_author_background_wins_template_structure_still_loses() {
1536 let index = RecipeIndex::core();
1537 let badge = expand(json!({"id": "b", "component": "Badge", "props": {"text": "New"}, "style": {"backgroundColor": "#123456"}}));
1538 assert_eq!(badge.recipe.as_ref().unwrap().authored, vec!["backgroundColor".to_string()]);
1539 let neutral = builtin_theme("neutral").unwrap();
1540 assert_eq!(resolve_node_style(&neutral, &badge, Mode::Light, &[], &index)["backgroundColor"], json!("#123456"));
1541 let playful = builtin_theme("playful").unwrap();
1542 let style = resolve_node_style(&playful, &badge, Mode::Light, &[], &index);
1543 assert_eq!(style["backgroundColor"], json!("#123456"));
1544 assert!(badge.style.as_ref().unwrap().contains_key("borderRadius"));
1545 let recipe = resolve_recipe(&playful, &q("Badge", "root", json!({"variant": "default"}), &[]), Mode::Light);
1546 assert_eq!(style["borderRadius"], recipe["borderRadius"]);
1547 }
1548
1549 #[test]
1550 fn vapp163_an_authored_condition_block_wins_with_its_media_key_resolved() {
1551 let playful = builtin_theme("playful").unwrap();
1552 let node = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi"}, "style": {"@media (min-width: $breakpoint.md)": {"color": "#00ff00"}}}));
1553 assert_eq!(node.recipe.as_ref().unwrap().authored, vec!["@media (min-width: $breakpoint.md)".to_string()]);
1554 let style = resolve_node_style(&playful, &node, Mode::Light, &[], &RecipeIndex::core());
1555 let key = format!("@media (min-width: {}px)", json::number_to_string(playful.tokens.breakpoint["md"]));
1556 assert_eq!(style[&key], json!({"color": "#00ff00"}));
1557 }
1558
1559 #[test]
1560 fn a_theme_that_changes_primary_and_the_button_radius_works_everywhere() {
1561 let refs = builtin_refs();
1562 let brand = load_theme(
1563 &json!({"$schema": THEME_SCHEMA_ID, "id": "brand", "name": "Brand", "extends": "neutral", "modes": {"light": {"color": {"primary": "#2563eb"}}}, "recipes": {"Button": {"root": [{"style": {"borderRadius": "$radius.full"}}]}}}),
1564 &ThemeOptions::core(&refs),
1565 )
1566 .unwrap();
1567 assert_eq!(brand.chain, ["neutral", "brand"]);
1568 assert_eq!(brand.modes.light.color["primary"], "#2563eb");
1569 assert_eq!(brand.modes.dark.color["primary"], "#e5e5e5");
1570 assert_eq!(brand.modes.light.color["secondary"], "#f5f5f5");
1571 has(&resolve_recipe(&brand, &q("Button", "root", json!({"variant": "default", "size": "sm"}), &[]), Mode::Light), json!({"backgroundColor": "#2563eb", "borderRadius": 9999, "height": 32}));
1572 assert_eq!(resolve_recipe(&brand, &q("Badge", "root", json!({"variant": "default"}), &[]), Mode::Light)["backgroundColor"], json!("#2563eb"));
1573 assert_eq!(resolve_recipe(&brand, &q("Text", "root", json!({"variant": "title", "tone": "primary"}), &[]), Mode::Light)["color"], json!("#2563eb"));
1574 }
1575
1576 #[test]
1577 fn chains_of_chains_and_resolved_parents() {
1578 let refs = [ThemeRef::Resolved(builtin_theme("playful").unwrap())];
1579 let child = load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "c", "name": "C", "extends": "playful", "tokens": {"spacing": {"md": 20}}}), &ThemeOptions::core(&refs)).unwrap();
1580 assert_eq!(child.chain, ["neutral", "playful", "c"]);
1581 assert_eq!(child.tokens.spacing["md"], 20.0);
1582 assert_eq!(child.tokens.radius["md"], 14.0);
1583 }
1584
1585 #[test]
1586 fn source_chains_and_cycles() {
1587 let refs = [
1588 ThemeRef::Source(json!({"$schema": THEME_SCHEMA_ID, "id": "a", "name": "A", "extends": "b"})),
1589 ThemeRef::Source(json!({"$schema": THEME_SCHEMA_ID, "id": "b", "name": "B", "extends": "a"})),
1590 ];
1591 let err = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "c", "name": "C", "extends": "a"}), &ThemeOptions::core(&refs)).unwrap_err();
1592 assert_eq!(err, [issue("extends", "cycle: c → a → b → a")]);
1593 }
1594
1595 #[test]
1596 fn an_invalid_theme_fails_with_every_issue_listed_never_a_crash() {
1597 let refs = builtin_refs();
1598 let opts = ThemeOptions::core(&refs);
1599 let e = load_theme(&Value::Null, &opts).unwrap_err();
1600 assert_eq!(e.id, "?");
1601 assert!(load_theme(&json!(42), &ThemeOptions::default()).unwrap_err().to_string().contains("expected a theme object"));
1602 let bad = json!({"$schema": THEME_SCHEMA_ID, "id": "bad", "name": "Bad", "extends": "neutral", "tokens": {"spacing": {"huge": 1}}, "recipes": {"Button": {"root": [{"style": {"display": "flex"}}]}}});
1603 let e = load_theme(&bad, &opts).unwrap_err();
1604 let paths: Vec<&str> = e.issues.iter().map(|i| i.path.as_str()).collect();
1605 assert_eq!(paths, ["tokens.spacing.huge", "recipes.Button.root[0].style.display"]);
1606 let msg = e.to_string();
1607 assert!(msg.contains("Theme \"bad\" is invalid:"));
1608 assert!(msg.contains("not a recipe key"));
1609 let err = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "x", "name": "X", "extends": "nope"}), &opts).unwrap_err();
1610 assert!(err[0].message.contains("unknown theme \"nope\""));
1611 assert_eq!(validate_theme(&json!("neutral"), &ThemeOptions::default()).len(), 1);
1612 }
1613
1614 #[test]
1615 fn a_root_theme_must_give_every_token_a_value() {
1616 let issues = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "bare", "name": "Bare", "modes": {}, "tokens": {}, "recipes": {}}), &ThemeOptions::default()).unwrap_err();
1617 assert!(issues.len() > 100);
1618 assert!(issues[0].message.starts_with("missing value for $color."));
1619 }
1620
1621 #[test]
1622 fn shadow_css_prints_js_numbers() {
1623 let n = builtin_theme("neutral").unwrap();
1624 assert_eq!(shadow_css(&n.modes.light.shadow["none"]), "none");
1625 assert_eq!(shadow_css(&n.modes.light.shadow["sm"]), "0px 1px 2px 0px #0000000d");
1626 }
1627
1628 #[test]
1629 fn hex_and_id_checks() {
1630 assert!(is_theme_hex(&json!("#a1b2c3")));
1631 assert!(is_theme_hex(&json!("#a1b2c3d4")));
1632 assert!(!is_theme_hex(&json!("#A1B2C3")));
1633 assert!(!is_theme_hex(&json!("#abc")));
1634 assert!(is_kebab_id("a-1"));
1635 assert!(!is_kebab_id("1a"));
1636 assert!(!is_kebab_id(""));
1637 }
1638}