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];
409const COLOR_KEYS: [&str; 3] = ["backgroundColor", "color", "borderColor"];
410const TOKEN_ONLY_KEYS: [&str; 5] = ["fontFamily", "boxShadow", "transition", "transitionEasing", "backdropBlur"];
411const ENUM_KEYS: [&str; 5] = ["borderStyle", "textDecoration", "textTransform", "fontStyle", "animation"];
412const THEME_KEYS: [&str; 10] = ["$schema", "$comment", "id", "name", "extends", "modes", "contrast", "tokens", "fonts", "recipes"];
413
414fn style_key_enum(key: &str) -> Vec<Value> {
416 crate::style_check::STYLE_SPECS.get(key).and_then(|s| s.enum_.clone()).unwrap_or_default()
417}
418
419fn key_groups(key: &str) -> &'static [&'static str] {
420 KEY_GROUPS.iter().find(|(k, _)| *k == key).map(|(_, g)| *g).unwrap_or(&[])
421}
422
423fn known<S: AsRef<str>>(names: &[S]) -> String {
424 names.iter().map(AsRef::as_ref).collect::<Vec<_>>().join("|")
425}
426
427fn token_names(group: &str) -> Option<&'static Vec<String>> {
428 TOKEN_GROUPS.get(group)
429}
430
431fn token_groups_in_order() -> impl Iterator<Item = (&'static str, &'static Vec<String>)> {
435 crate::generated::catalog::TOKEN_GROUPS.iter().filter_map(|g| TOKEN_GROUPS.get(*g).map(|names| (*g, names)))
436}
437
438fn names_include(names: &[String], name: &str) -> bool {
439 names.iter().any(|n| n == name)
440}
441
442pub fn is_theme_hex(value: &Value) -> bool {
444 let Some(s) = value.as_str() else { return false };
445 let Some(hex) = s.strip_prefix('#') else { return false };
446 (hex.len() == 6 || hex.len() == 8) && hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
447}
448
449fn is_kebab_id(s: &str) -> bool {
450 let mut bytes = s.bytes();
451 matches!(bytes.next(), Some(b) if b.is_ascii_lowercase()) && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
452}
453
454fn is_finite_number(v: &Value) -> Option<f64> {
455 v.as_f64().filter(|n| n.is_finite())
456}
457
458fn check_number_map(value: &Value, path: &str, names: &[String], issues: &mut Vec<ThemeIssue>, range: Option<(f64, f64)>) {
463 let Some(obj) = value.as_object() else {
464 issues.push(issue(path, "expected an object of token values"));
465 return;
466 };
467 for (name, v) in obj {
468 let at = format!("{path}.{name}");
469 if !names_include(names, name) {
470 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
471 } else if let Some(n) = is_finite_number(v) {
472 if let Some((lo, hi)) = range {
473 if n < lo || n > hi {
474 issues.push(issue(at, format!("expected {}–{}", json::number_to_string(lo), json::number_to_string(hi))));
475 }
476 }
477 } else {
478 issues.push(issue(at, "expected a number"));
479 }
480 }
481}
482
483fn check_easing_map(value: &Value, path: &str, names: &[String], issues: &mut Vec<ThemeIssue>) {
484 let Some(obj) = value.as_object() else {
485 issues.push(issue(path, "expected an object of easing curves"));
486 return;
487 };
488 for (name, v) in obj {
489 let at = format!("{path}.{name}");
490 if !names_include(names, name) {
491 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
492 } else if !v.as_array().is_some_and(|a| a.len() == 4 && a.iter().all(|n| is_finite_number(n).is_some())) {
493 issues.push(issue(at, "expected [x1, y1, x2, y2]"));
494 }
495 }
496}
497
498fn check_shadow(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
499 let Some(layers) = value.as_array() else {
500 issues.push(issue(path, "expected a list of shadow layers"));
501 return;
502 };
503 for (i, layer) in layers.iter().enumerate() {
504 let at = format!("{path}[{i}]");
505 let Some(layer) = layer.as_object() else {
506 issues.push(issue(at, "expected {x, y, blur, spread, color}"));
507 continue;
508 };
509 for k in ["x", "y", "blur", "spread"] {
510 if !layer.get(k).is_some_and(Value::is_number) {
511 issues.push(issue(format!("{at}.{k}"), "expected a number"));
512 }
513 }
514 if !layer.get("color").is_some_and(is_theme_hex) {
515 issues.push(issue(format!("{at}.color"), "expected #rrggbb or #rrggbbaa"));
516 }
517 }
518}
519
520fn check_mode(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
521 let Some(obj) = value.as_object() else {
522 issues.push(issue(path, "expected {color, shadow}"));
523 return;
524 };
525 for (group, entries) in obj {
526 if !MODE_GROUPS.contains(&group.as_str()) {
527 issues.push(issue(format!("{path}.{group}"), "unknown mode group; known: color|shadow"));
528 continue;
529 }
530 let Some(entries) = entries.as_object() else {
531 issues.push(issue(format!("{path}.{group}"), "expected an object of token values"));
532 continue;
533 };
534 let names = token_names(group).map(Vec::as_slice).unwrap_or(&[]);
535 for (name, v) in entries {
536 let at = format!("{path}.{group}.{name}");
537 if !names_include(names, name) {
538 issues.push(issue(at, format!("unknown token; known: {}", known(names))));
539 } else if group == "color" && !is_theme_hex(v) {
540 issues.push(issue(at, "expected #rrggbb or #rrggbbaa (lowercase); the builder converts oklch/hsl on import"));
541 } else if group == "shadow" {
542 check_shadow(v, &at, issues);
543 }
544 }
545 }
546}
547
548fn check_modes(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
549 let Some(modes) = value.as_object() else {
550 issues.push(issue(path, "expected {light, dark}"));
551 return;
552 };
553 for (mode, v) in modes {
554 if Mode::parse(mode).is_none() {
555 issues.push(issue(format!("{path}.{mode}"), "unknown mode; known: light|dark"));
556 } else {
557 check_mode(v, &format!("{path}.{mode}"), issues);
558 }
559 }
560}
561
562fn check_tokens(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
563 let Some(obj) = value.as_object() else {
564 issues.push(issue(path, "expected the token groups"));
565 return;
566 };
567 for (group, entries) in obj {
568 let at = format!("{path}.{group}");
569 if group == "type" {
570 let Some(subs) = entries.as_object() else {
571 issues.push(issue(at, "expected {size, lineHeight, weight, family}"));
572 continue;
573 };
574 for (sub, map) in subs {
575 let g = format!("type.{sub}");
576 let Some(names) = token_names(&g) else {
577 issues.push(issue(format!("{at}.{sub}"), "unknown type group; known: size|lineHeight|weight|family"));
578 continue;
579 };
580 if STRING_GROUPS.contains(&g.as_str()) {
581 let Some(map) = map.as_object() else {
582 issues.push(issue(format!("{at}.{sub}"), "expected an object of family names"));
583 continue;
584 };
585 for (name, v) in map {
586 if !names_include(names, name) {
587 issues.push(issue(format!("{at}.{sub}.{name}"), format!("unknown token; known: {}", known(names))));
588 } else if v.as_str().is_none_or(str::is_empty) {
589 issues.push(issue(format!("{at}.{sub}.{name}"), "expected a font family name"));
590 }
591 }
592 } else {
593 check_number_map(map, &format!("{at}.{sub}"), names, issues, None);
594 }
595 }
596 continue;
597 }
598 if MODE_GROUPS.contains(&group.as_str()) {
599 issues.push(issue(at, format!("{group} values live under modes.light / modes.dark")));
600 continue;
601 }
602 let Some(names) = token_names(group) else {
603 let groups: Vec<&str> = token_groups_in_order()
604 .map(|(g, _)| g)
605 .filter(|g| !g.starts_with("type.") && !MODE_GROUPS.contains(g))
606 .collect();
607 issues.push(issue(at, format!("unknown token group; known: {}|type", known(&groups))));
608 continue;
609 };
610 if TUPLE_GROUPS.contains(&group.as_str()) {
611 check_easing_map(entries, &at, names, issues);
612 continue;
613 }
614 let range = match group.as_str() {
615 "opacity" => Some((0.0, 1.0)),
616 "density" => Some((0.5, 2.0)),
617 "scale" => Some((0.5, 1.5)),
618 _ => None,
619 };
620 check_number_map(entries, &at, names, issues, range);
621 }
622}
623
624fn check_fonts(value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
625 let Some(obj) = value.as_object() else {
626 issues.push(issue(path, "expected an object keyed by family name"));
627 return;
628 };
629 for (family, spec) in obj {
630 let at = format!("{path}.{family}");
631 let Some(spec) = spec.as_object() else {
632 issues.push(issue(at, "expected {fallback?, weights?, source?}"));
633 continue;
634 };
635 for key in spec.keys() {
636 if !["fallback", "weights", "source"].contains(&key.as_str()) {
637 issues.push(issue(format!("{at}.{key}"), "unknown font key; known: fallback|weights|source"));
638 }
639 }
640 if spec.get("fallback").is_some_and(|v| !v.is_string()) {
641 issues.push(issue(format!("{at}.fallback"), "expected a string"));
642 }
643 if let Some(w) = spec.get("weights") {
644 if !w.as_array().is_some_and(|a| a.iter().all(Value::is_number)) {
645 issues.push(issue(format!("{at}.weights"), "expected a list of numbers"));
646 }
647 }
648 if let Some(s) = spec.get("source") {
649 if s.as_str() != Some("system") && s.as_str() != Some("host") {
650 issues.push(issue(format!("{at}.source"), "expected system|host"));
651 }
652 }
653 }
654}
655
656fn check_recipe_value(key: &str, value: &Value, path: &str, issues: &mut Vec<ThemeIssue>) {
657 if key == "native" {
658 if !value.is_boolean() {
659 issues.push(issue(path, "expected true|false"));
660 }
661 return;
662 }
663 if ENUM_KEYS.contains(&key) {
664 let allowed = style_key_enum(key);
665 if !allowed.iter().any(|a| json::strict_eq(a, value)) {
666 let names: Vec<String> = allowed.iter().map(json::to_js_string).collect();
667 issues.push(issue(path, format!("expected {}", known(&names))));
668 }
669 return;
670 }
671 if key == "transform" {
672 if !value.as_str().is_some_and(crate::style_check::is_transform) {
673 issues.push(issue(path, "expected translate(Xpx, Ypx), scale(N) and/or rotate(Ndeg)"));
674 }
675 return;
676 }
677 let groups = key_groups(key);
678 if let Some((group, name)) = parse_token_ref(value) {
679 if !groups.contains(&group.as_str()) {
680 if groups.is_empty() {
681 issues.push(issue(path, "expected a number, not a token"));
682 } else {
683 issues.push(issue(path, format!("expected a ${} token, got ${group}", groups.join("|$"))));
684 }
685 } else if !token_names(&group).is_some_and(|names| names_include(names, &name)) {
686 let names = token_names(&group).map(Vec::as_slice).unwrap_or(&[]);
687 issues.push(issue(path, format!("unknown token {}; known: {}", value.as_str().unwrap_or(""), known(names))));
688 }
689 return;
690 }
691 if COLOR_KEYS.contains(&key) {
692 if !is_theme_hex(value) {
693 issues.push(issue(path, "expected #rrggbb, #rrggbbaa or $color.<name>"));
694 }
695 return;
696 }
697 if TOKEN_ONLY_KEYS.contains(&key) {
698 issues.push(issue(path, format!("expected a ${}.<name> token", groups.first().copied().unwrap_or(""))));
699 return;
700 }
701 let weights = style_key_enum("fontWeight");
702 match is_finite_number(value) {
703 None if groups.is_empty() => issues.push(issue(path, "expected a number")),
704 None => issues.push(issue(path, format!("expected a number or a ${} token", groups.join("|$")))),
705 Some(n) if key == "opacity" && !(0.0..=1.0).contains(&n) => issues.push(issue(path, "expected 0–1")),
706 Some(n) if key == "fontWeight" && !weights.iter().any(|w| w.as_f64() == Some(n)) => {
707 let names: Vec<String> = weights.iter().map(json::to_js_string).collect();
708 issues.push(issue(path, format!("expected {} or $type.weight.<name>", known(&names))))
709 }
710 Some(_) => {}
711 }
712}
713
714fn check_rule(rule: &Value, path: &str, spec: &PartSpec, issues: &mut Vec<ThemeIssue>) {
715 let Some(rule) = rule.as_object() else {
716 issues.push(issue(path, "expected {when?, style}"));
717 return;
718 };
719 for key in rule.keys() {
720 if key != "when" && key != "style" {
721 issues.push(issue(format!("{path}.{key}"), "unknown rule key; known: when|style"));
722 }
723 }
724 if let Some(when) = rule.get("when") {
725 match when.as_object() {
726 None => issues.push(issue(format!("{path}.when"), "expected an object of recipe props")),
727 Some(when) => {
728 for (k, v) in when {
729 let at = format!("{path}.when.{k}");
730 if k == "state" {
731 let states: Vec<&Value> = match v {
732 Value::Array(a) => a.iter().collect(),
733 other => vec![other],
734 };
735 for s in states {
736 if !s.as_str().is_some_and(|s| RECIPE_STATES.contains(&s)) {
737 issues.push(issue(at.clone(), format!("unknown state; known: {}", known(RECIPE_STATES))));
738 }
739 }
740 } else if !names_include(&spec.props, k) {
741 let mut names = vec!["state".to_string()];
742 names.extend(spec.props.iter().cloned());
743 issues.push(issue(at, format!("not a recipe prop of this part; known: {}", known(&names))));
744 } else {
745 let ok = |x: &Value| x.is_string() || x.is_number() || x.is_boolean();
746 if !(ok(v) || v.as_array().is_some_and(|a| a.iter().all(ok))) {
747 issues.push(issue(at, "expected a value or a list of values"));
748 }
749 }
750 }
751 }
752 }
753 }
754 let Some(style) = rule.get("style").and_then(Value::as_object) else {
755 issues.push(issue(format!("{path}.style"), "expected a style object"));
756 return;
757 };
758 for (key, value) in style {
759 let at = format!("{path}.style.{key}");
760 if !RECIPE_KEYS.contains(&key.as_str()) {
761 issues.push(issue(at, format!("not a recipe key; known: {}", known(RECIPE_KEYS))));
762 } else {
763 check_recipe_value(key, value, &at, issues);
764 }
765 }
766}
767
768fn check_recipes(value: &Value, path: &str, parts: &IndexMap<String, PartSpec>, issues: &mut Vec<ThemeIssue>) {
769 let Some(obj) = value.as_object() else {
770 issues.push(issue(path, "expected an object keyed by component"));
771 return;
772 };
773 for (component, by_part) in obj {
774 let at = format!("{path}.{component}");
775 let Some(spec) = parts.get(component) else {
776 issues.push(issue(at, "unknown component"));
777 continue;
778 };
779 let Some(by_part) = by_part.as_object() else {
780 issues.push(issue(at, format!("expected an object keyed by part; known: {}", known(&spec.parts))));
781 continue;
782 };
783 for (part, rules) in by_part {
784 let at_part = format!("{at}.{part}");
785 if !names_include(&spec.parts, part) {
786 issues.push(issue(at_part, format!("unknown part; known: {}", known(&spec.parts))));
787 continue;
788 }
789 let Some(rules) = rules.as_array() else {
790 issues.push(issue(at_part, "expected a list of rules [{when?, style}]"));
791 continue;
792 };
793 for (i, rule) in rules.iter().enumerate() {
794 check_rule(rule, &format!("{at_part}[{i}]"), spec, issues);
795 }
796 }
797 }
798}
799
800fn index_for(view: &CatalogView) -> Arc<RecipeIndex> {
803 if std::ptr::eq(view, &**CORE_VIEW) {
804 RecipeIndex::core()
805 } else {
806 Arc::new(RecipeIndex::new(view))
807 }
808}
809
810pub fn validate_theme(source: &Value, options: &ThemeOptions) -> Vec<ThemeIssue> {
813 let mut issues = Vec::new();
814 let Some(src) = source.as_object() else {
815 return vec![issue("theme", "expected a theme object {id, name, modes, tokens, recipes}")];
816 };
817 if src.get("$schema").and_then(Value::as_str) != Some(THEME_SCHEMA_ID) {
820 issues.push(issue("$schema", format!("expected \"{THEME_SCHEMA_ID}\"")));
821 }
822 if !src.get("id").and_then(Value::as_str).is_some_and(is_kebab_id) {
823 issues.push(issue("id", "expected a kebab-case id"));
824 }
825 if src.get("name").and_then(Value::as_str).is_none_or(str::is_empty) {
826 issues.push(issue("name", "expected a name"));
827 }
828 for key in src.keys() {
829 if !THEME_KEYS.contains(&key.as_str()) {
830 issues.push(issue(key.clone(), "unknown theme key; known: id|name|extends|modes|contrast|tokens|fonts|recipes"));
831 }
832 }
833 if let Some(ext) = src.get("extends") {
834 let parents: Vec<&str> = options.themes.iter().map(|t| t.id().unwrap_or("")).collect();
835 match ext.as_str() {
836 None => issues.push(issue("extends", "expected a theme id")),
837 Some(e) if !parents.contains(&e) => issues.push(issue("extends", format!("unknown theme \"{e}\"; known: {}", known(&parents)))),
838 Some(e) if src.get("id").and_then(Value::as_str) == Some(e) => issues.push(issue("extends", "a theme cannot extend itself")),
839 Some(_) => {}
840 }
841 }
842 if let Some(modes) = src.get("modes") {
843 check_modes(modes, "modes", &mut issues);
844 }
845 if let Some(contrast) = src.get("contrast") {
846 check_modes(contrast, "contrast", &mut issues);
847 }
848 if let Some(tokens) = src.get("tokens") {
849 check_tokens(tokens, "tokens", &mut issues);
850 }
851 if let Some(fonts) = src.get("fonts") {
852 check_fonts(fonts, "fonts", &mut issues);
853 }
854 if let Some(recipes) = src.get("recipes") {
855 check_recipes(recipes, "recipes", &index_for(options.view).parts, &mut issues);
856 }
857 issues
858}
859
860fn empty_theme(source: &Map<String, Value>) -> ResolvedTheme {
865 ResolvedTheme {
866 id: source.get("id").and_then(Value::as_str).unwrap_or_default().to_string(),
867 name: source.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
868 ..ResolvedTheme::default()
869 }
870}
871
872fn overlay_mode_source(tm: &mut ThemeMode, m: Option<&Value>) {
873 let Some(m) = m else { return };
874 for (name, v) in m.get("color").and_then(Value::as_object).into_iter().flatten() {
875 tm.color.insert(name.clone(), v.as_str().unwrap_or_default().to_string());
876 }
877 for (name, v) in m.get("shadow").and_then(Value::as_object).into_iter().flatten() {
878 let layers: Vec<Shadow> = serde_json::from_value(v.clone()).unwrap_or_default();
879 tm.shadow.insert(name.clone(), layers);
880 }
881}
882
883fn overlay_mode(tm: &mut ThemeMode, from: &ThemeMode) {
884 for (k, v) in &from.color {
885 tm.color.insert(k.clone(), v.clone());
886 }
887 for (k, v) in &from.shadow {
888 tm.shadow.insert(k.clone(), v.clone());
889 }
890}
891
892fn overlay(target: &mut ResolvedTheme, source: &Value) {
894 for mode in Mode::ALL {
895 overlay_mode_source(target.modes.get_mut(mode), source.get("modes").and_then(|ms| ms.get(mode.as_str())));
896 overlay_mode_source(target.contrast.get_mut(mode), source.get("contrast").and_then(|ms| ms.get(mode.as_str())));
897 }
898 if let Some(t) = source.get("tokens") {
899 for group in NUMBER_GROUPS {
900 let table = target.tokens.numbers_mut(group).expect("number group");
901 for (name, v) in t.get(group).and_then(Value::as_object).into_iter().flatten() {
902 table.insert(name.clone(), v.as_f64().unwrap_or_default());
903 }
904 }
905 for (name, v) in t.get("ease").and_then(Value::as_object).into_iter().flatten() {
906 let curve: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
907 target.tokens.ease.insert(name.clone(), curve);
908 }
909 for sub in TYPE_SUBS {
910 let entries = t.get("type").and_then(|ty| ty.get(sub)).and_then(Value::as_object);
911 for (name, v) in entries.into_iter().flatten() {
912 if sub == "family" {
913 target.tokens.r#type.family.insert(name.clone(), v.as_str().unwrap_or_default().to_string());
914 } else if let Some(table) = target.tokens.r#type.numbers_mut(sub) {
915 table.insert(name.clone(), v.as_f64().unwrap_or_default());
916 }
917 }
918 }
919 }
920 for (family, spec) in source.get("fonts").and_then(Value::as_object).into_iter().flatten() {
921 let spec: FontSpec = serde_json::from_value(spec.clone()).unwrap_or_default();
922 target.fonts.insert(family.clone(), spec);
923 }
924 for (component, by_part) in source.get("recipes").and_then(Value::as_object).into_iter().flatten() {
925 let parts = target.recipes.entry(component.clone()).or_default();
926 for (part, rules) in by_part.as_object().into_iter().flatten() {
927 let rules: Vec<RecipeRule> = serde_json::from_value(rules.clone()).unwrap_or_default();
928 parts.entry(part.clone()).or_default().extend(rules);
929 }
930 }
931}
932
933fn overlay_resolved(target: &mut ResolvedTheme, parent: &ResolvedTheme) {
934 for mode in Mode::ALL {
935 overlay_mode(target.modes.get_mut(mode), parent.modes.get(mode));
936 overlay_mode(target.contrast.get_mut(mode), parent.contrast.get(mode));
937 }
938 target.tokens = parent.tokens.clone();
939 target.fonts = parent.fonts.clone();
940 target.recipes = parent.recipes.clone();
941}
942
943fn completeness(theme: &ResolvedTheme) -> Vec<ThemeIssue> {
945 let mut issues = Vec::new();
946 for (group, names) in token_groups_in_order() {
947 for name in names {
948 if MODE_GROUPS.contains(&group) {
949 for mode in Mode::ALL {
950 let m = theme.modes.get(mode);
951 let present = if group == "color" { m.color.contains_key(name) } else { m.shadow.contains_key(name) };
952 if !present {
953 issues.push(issue(format!("modes.{}.{group}.{name}", mode.as_str()), format!("missing value for ${group}.{name}")));
954 }
955 }
956 continue;
957 }
958 let present = if group == "type.family" {
959 theme.tokens.r#type.family.contains_key(name)
960 } else if group == "ease" {
961 theme.tokens.ease.contains_key(name)
962 } else {
963 theme.tokens.numbers(group).is_some_and(|t| t.contains_key(name))
964 };
965 if !present {
966 issues.push(issue(format!("tokens.{group}.{name}"), format!("missing value for ${group}.{name}")));
967 }
968 }
969 }
970 for family in theme.tokens.r#type.family.values() {
971 if !theme.fonts.contains_key(family) {
972 issues.push(issue(format!("fonts.{family}"), "family named by tokens.type.family has no fonts entry"));
973 }
974 }
975 issues
976}
977
978enum Link<'a> {
979 Source(&'a Value),
980 Resolved(&'a ResolvedTheme),
981}
982
983fn chain_of<'a>(source: &'a Value, options: &ThemeOptions<'a>) -> Result<Vec<Link<'a>>, ThemeIssue> {
985 let id_of = |v: &Value| v.get("id").and_then(Value::as_str).unwrap_or("").to_string();
986 let mut chain = vec![Link::Source(source)];
987 let mut seen = vec![id_of(source)];
988 let mut current = source;
989 while let Some(parent_id) = current.get("extends").and_then(Value::as_str).filter(|s| !s.is_empty()) {
990 let Some(parent) = options.themes.iter().find(|t| t.id() == Some(parent_id)) else {
991 return Err(issue("extends", format!("unknown theme \"{parent_id}\"")));
992 };
993 let pid = parent.id().unwrap_or("").to_string();
994 if seen.contains(&pid) {
995 seen.push(pid);
996 return Err(issue("extends", format!("cycle: {}", seen.join(" → "))));
997 }
998 seen.push(pid);
999 match parent {
1000 ThemeRef::Resolved(t) => {
1001 chain.insert(0, Link::Resolved(t));
1002 break;
1003 }
1004 ThemeRef::Source(v) => {
1005 chain.insert(0, Link::Source(v));
1006 current = v;
1007 }
1008 }
1009 }
1010 Ok(chain)
1011}
1012
1013pub fn try_load_theme(source: &Value, options: &ThemeOptions) -> Result<ResolvedTheme, Vec<ThemeIssue>> {
1015 let issues = validate_theme(source, options);
1016 if !issues.is_empty() {
1017 return Err(issues);
1018 }
1019 let src = source.as_object().expect("validated");
1020 let chain = chain_of(source, options).map_err(|i| vec![i])?;
1021 let mut theme = empty_theme(src);
1022 let last = chain.len() - 1;
1023 for (i, link) in chain.iter().enumerate() {
1024 match link {
1025 Link::Resolved(parent) => {
1026 overlay_resolved(&mut theme, parent);
1027 theme.chain.extend(parent.chain.iter().cloned());
1028 }
1029 Link::Source(v) => {
1030 let id = v.get("id").and_then(Value::as_str).unwrap_or("").to_string();
1031 if i != last {
1032 let parent_issues = validate_theme(v, options);
1033 if !parent_issues.is_empty() {
1034 return Err(parent_issues
1035 .into_iter()
1036 .map(|p| issue(format!("extends({id}).{}", p.path), p.message))
1037 .collect());
1038 }
1039 }
1040 overlay(&mut theme, v);
1041 theme.chain.push(id);
1042 }
1043 }
1044 }
1045 let missing = completeness(&theme);
1046 if !missing.is_empty() {
1047 return Err(missing);
1048 }
1049 Ok(theme)
1050}
1051
1052pub fn load_theme(source: &Value, options: &ThemeOptions) -> Result<ResolvedTheme, ThemeError> {
1054 try_load_theme(source, options).map_err(|issues| ThemeError {
1055 id: source.get("id").and_then(Value::as_str).unwrap_or("?").to_string(),
1056 issues,
1057 })
1058}
1059
1060fn numbers_value(m: &IndexMap<String, f64>) -> Value {
1065 Value::Object(m.iter().map(|(k, v)| (k.clone(), json::number(*v))).collect())
1066}
1067
1068pub fn resolve_token(theme: &ResolvedTheme, reference: &Value, mode: Mode) -> Option<Value> {
1072 let (group, name) = parse_token_ref(reference)?;
1073 let m = theme.modes.get(mode);
1074 match group.as_str() {
1075 "color" => m.color.get(&name).map(|c| Value::String(c.clone())),
1076 "shadow" => m.shadow.get(&name).map(|l| serde_json::to_value(l).expect("shadow")),
1077 "type.family" => theme.tokens.r#type.family.get(&name).map(|f| Value::String(f.clone())),
1078 "ease" => theme.tokens.ease.get(&name).map(|c| Value::Array(c.iter().map(|n| json::number(*n)).collect())),
1079 "type" => match name.as_str() {
1081 "family" => serde_json::to_value(&theme.tokens.r#type.family).ok(),
1082 sub => theme.tokens.r#type.numbers(sub).map(numbers_value),
1083 },
1084 g => theme.tokens.numbers(g).and_then(|t| t.get(&name)).map(|v| json::number(*v)),
1085 }
1086}
1087
1088pub fn resolve_condition_key(theme: &ResolvedTheme, key: &str) -> String {
1091 let Some(condition) = crate::conditions::parse_media_condition(key) else { return key.to_string() };
1092 let Some(name) = crate::conditions::breakpoint_ref(&condition.value) else { return key.to_string() };
1093 match theme.tokens.breakpoint.get(name) {
1094 Some(px) => key.replace(&condition.value, &format!("{}px", json::number_to_string(*px))),
1095 None => key.to_string(),
1096 }
1097}
1098
1099fn resolve_style_value(theme: &ResolvedTheme, value: &Value, mode: Mode) -> Value {
1100 match value {
1101 Value::Array(items) => Value::Array(items.iter().map(|v| resolve_style_value(theme, v, mode)).collect()),
1102 Value::Object(nested) => Value::Object(resolve_style_values(theme, nested, mode)),
1103 Value::String(s) if s.starts_with('$') => resolve_token(theme, value, mode).unwrap_or_else(|| value.clone()),
1104 other => other.clone(),
1105 }
1106}
1107
1108pub fn resolve_style_values(theme: &ResolvedTheme, style: &Props, mode: Mode) -> Props {
1112 let mut out = Props::new();
1113 for (key, value) in style {
1114 out.insert(resolve_condition_key(theme, key), resolve_style_value(theme, value, mode));
1115 }
1116 out
1117}
1118
1119#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, Serialize, Deserialize)]
1125#[serde(rename_all = "lowercase")]
1126pub enum ModeSetting {
1127 Light,
1128 Dark,
1129 #[default]
1130 System,
1131}
1132
1133impl ModeSetting {
1134 pub fn parse(s: &str) -> Option<ModeSetting> {
1135 match s {
1136 "light" => Some(ModeSetting::Light),
1137 "dark" => Some(ModeSetting::Dark),
1138 "system" => Some(ModeSetting::System),
1139 _ => None,
1140 }
1141 }
1142}
1143
1144pub fn resolve_mode(setting: ModeSetting, system_prefers_dark: bool) -> Mode {
1146 match setting {
1147 ModeSetting::Light => Mode::Light,
1148 ModeSetting::Dark => Mode::Dark,
1149 ModeSetting::System => {
1150 if system_prefers_dark {
1151 Mode::Dark
1152 } else {
1153 Mode::Light
1154 }
1155 }
1156 }
1157}
1158
1159#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, Serialize, Deserialize)]
1161#[serde(rename_all = "lowercase")]
1162pub enum Density {
1163 Compact,
1164 #[default]
1165 Default,
1166 Comfortable,
1167}
1168
1169impl Density {
1170 pub fn parse(s: &str) -> Option<Density> {
1171 match s {
1172 "compact" => Some(Density::Compact),
1173 "default" => Some(Density::Default),
1174 "comfortable" => Some(Density::Comfortable),
1175 _ => None,
1176 }
1177 }
1178 pub fn as_str(self) -> &'static str {
1179 match self {
1180 Density::Compact => "compact",
1181 Density::Default => "default",
1182 Density::Comfortable => "comfortable",
1183 }
1184 }
1185}
1186
1187fn scaled(table: &IndexMap<String, f64>, factor: f64) -> IndexMap<String, f64> {
1188 table.iter().map(|(k, v)| (k.clone(), crate::expr::js_round(v * factor))).collect()
1189}
1190
1191pub fn apply_density(theme: &ResolvedTheme, density: Density) -> ResolvedTheme {
1197 let Some(factor) = (density != Density::Default).then(|| theme.tokens.density.get(density.as_str()).copied()).flatten() else {
1198 return theme.clone();
1199 };
1200 let mut out = theme.clone();
1201 out.tokens.control = scaled(&theme.tokens.control, factor);
1202 out.tokens.spacing = scaled(&theme.tokens.spacing, factor);
1203 out
1204}
1205
1206pub fn apply_contrast(theme: &ResolvedTheme) -> ResolvedTheme {
1209 let mut out = theme.clone();
1210 for mode in Mode::ALL {
1211 overlay_mode(out.modes.get_mut(mode), theme.contrast.get(mode));
1212 }
1213 out
1214}
1215
1216pub fn easing_css(curve: &[f64]) -> String {
1218 format!("cubic-bezier({})", curve.iter().map(|n| json::number_to_string(*n)).collect::<Vec<_>>().join(", "))
1219}
1220
1221fn matches(rule: &RecipeRule, props: &Props, states: &[String]) -> bool {
1222 let Some(when) = &rule.when else { return true };
1223 for (key, want) in when {
1224 if key == "state" {
1225 let needed: Vec<&Value> = match want {
1226 Value::Array(a) => a.iter().collect(),
1227 other => vec![other],
1228 };
1229 if !needed.iter().all(|s| s.as_str().is_some_and(|s| states.iter().any(|x| x == s))) {
1230 return false;
1231 }
1232 continue;
1233 }
1234 let ok = match props.get(key) {
1235 None => false,
1236 Some(actual) => match want {
1237 Value::Array(list) => list.iter().any(|w| json::strict_eq(w, actual)),
1238 w => json::strict_eq(w, actual),
1239 },
1240 };
1241 if !ok {
1242 return false;
1243 }
1244 }
1245 true
1246}
1247
1248pub fn rule_specificity(rule: &RecipeRule) -> usize {
1257 let Some(when) = &rule.when else { return 0 };
1258 when.iter()
1259 .map(|(key, want)| match (key.as_str(), want) {
1260 ("state", Value::Array(list)) => list.len(),
1261 _ => 1,
1262 })
1263 .sum()
1264}
1265
1266pub fn recipe_style(theme: &ResolvedTheme, query: &RecipeQuery) -> Props {
1267 let mut out = Props::new();
1268 let Some(rules) = theme.recipes.get(&query.component).and_then(|p| p.get(&query.part)) else { return out };
1269 let mut ordered: Vec<(usize, &RecipeRule)> = rules.iter().enumerate().collect();
1270 ordered.sort_by_key(|(_, r)| rule_specificity(r));
1272 for (_, rule) in ordered {
1273 if matches(rule, &query.props, &query.states) {
1274 for (k, v) in &rule.style {
1275 out.insert(k.clone(), v.clone());
1276 }
1277 }
1278 }
1279 out
1280}
1281
1282pub fn recipe_reacts_to(theme: &ResolvedTheme, query: &RecipeQuery, state: &str) -> bool {
1288 let Some(rules) = theme.recipes.get(&query.component).and_then(|p| p.get(&query.part)) else { return false };
1289 rules.iter().any(|rule| {
1290 let Some(when) = &rule.when else { return false };
1291 let names_state = match when.get("state") {
1292 Some(Value::Array(list)) => list.iter().any(|s| s.as_str() == Some(state)),
1293 Some(v) => v.as_str() == Some(state),
1294 None => false,
1295 };
1296 names_state
1297 && when.iter().filter(|(k, _)| k.as_str() != "state").all(|(key, want)| match query.props.get(key) {
1298 None => false,
1299 Some(actual) => match want {
1300 Value::Array(list) => list.iter().any(|w| json::strict_eq(w, actual)),
1301 w => json::strict_eq(w, actual),
1302 },
1303 })
1304 })
1305}
1306
1307pub fn resolve_recipe(theme: &ResolvedTheme, query: &RecipeQuery, mode: Mode) -> Props {
1309 resolve_style_values(theme, &recipe_style(theme, query), mode)
1310}
1311
1312pub fn node_recipe_query(node: &UiNode, index: &RecipeIndex) -> RecipeQuery {
1315 if let Some(r) = &node.recipe {
1316 return RecipeQuery::new(r.macro_.clone(), r.part.clone(), r.props.clone(), Vec::new());
1317 }
1318 RecipeQuery::new(node.component.clone(), "root", index.native_recipe_props(&node.component, &node.props), Vec::new())
1319}
1320
1321pub fn resolve_node_style(theme: &ResolvedTheme, node: &UiNode, mode: Mode, states: &[String], index: &RecipeIndex) -> Props {
1325 let own_query = RecipeQuery::new(node.component.clone(), "root", index.native_recipe_props(&node.component, &node.props), states.to_vec());
1326 let mut out = resolve_recipe(theme, &own_query, mode);
1327 let style = node.style.as_ref().map(|s| resolve_style_values(theme, s, mode)).unwrap_or_default();
1328 out.extend(style.clone());
1329 if let Some(recipe) = &node.recipe {
1330 let mut q = node_recipe_query(node, index);
1331 q.states = states.to_vec();
1332 out.extend(resolve_recipe(theme, &q, mode));
1333 for authored in &recipe.authored {
1334 let key = resolve_condition_key(theme, authored);
1335 if let Some(v) = style.get(&key) {
1336 out.insert(key, v.clone());
1337 }
1338 }
1339 }
1340 out
1341}
1342
1343pub fn shadow_css(layers: &[Shadow]) -> String {
1345 if layers.is_empty() {
1346 return "none".into();
1347 }
1348 let n = json::number_to_string;
1349 layers
1350 .iter()
1351 .map(|l| format!("{}px {}px {}px {}px {}", n(l.x), n(l.y), n(l.blur), n(l.spread), l.color))
1352 .collect::<Vec<_>>()
1353 .join(", ")
1354}
1355
1356#[cfg(all(test, feature = "builtin-themes"))]
1360mod tests {
1361 #[test]
1365 fn recipe_rules_merge_by_specificity() {
1366 use serde_json::json;
1367 let theme = crate::themes::builtin_theme("exponential").unwrap();
1368 let props = |checked: bool| crate::types::Props::from_iter([("checked".to_string(), json!(checked)), ("disabled".to_string(), json!(false))]);
1369 let on = super::resolve_recipe(&theme, &super::RecipeQuery::new("Switch", "track", props(true), vec!["checked".into()]), super::Mode::Dark);
1370 let off = super::resolve_recipe(&theme, &super::RecipeQuery::new("Switch", "track", props(false), vec![]), super::Mode::Dark);
1371 let color = |name: &str| theme.modes.dark.color.get(name).cloned().map(serde_json::Value::String);
1372 assert_eq!(on.get("backgroundColor").cloned(), color("primary"));
1373 assert_eq!(off.get("backgroundColor").cloned(), color("input"));
1374 let playful = crate::themes::builtin_theme("playful").unwrap();
1375 let track = super::resolve_recipe(&playful, &super::RecipeQuery::new("Switch", "track", props(true), vec!["checked".into()]), super::Mode::Dark);
1376 assert_eq!(track.get("width").and_then(serde_json::Value::as_f64), Some(44.0));
1377 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);
1378 }
1379
1380 use super::*;
1381 use crate::themes::{builtin_refs, builtin_theme, builtin_themes, BUILTIN_THEME_IDS};
1382 use serde_json::json;
1383
1384 fn q(component: &str, part: &str, props: Value, states: &[&str]) -> RecipeQuery {
1385 RecipeQuery::new(component, part, props.as_object().cloned().unwrap_or_default(), states.iter().map(|s| s.to_string()).collect())
1386 }
1387
1388 #[test]
1389 fn the_four_built_ins_load_neutral_is_the_root() {
1390 assert_eq!(BUILTIN_THEME_IDS, ["neutral", "exponential", "playful", "orange"]);
1391 for theme in builtin_themes() {
1392 assert_eq!(theme.chain[0], "neutral");
1393 assert_eq!(theme.modes.light.color.len(), theme.modes.dark.color.len());
1394 }
1395 assert_eq!(builtin_theme("playful").unwrap().chain, ["neutral", "playful"]);
1396 assert_eq!(builtin_theme("exponential").unwrap().chain, ["neutral", "exponential"]);
1397 assert_eq!(builtin_theme("orange").unwrap().chain, ["neutral", "exponential", "orange"]);
1398 }
1399
1400 #[test]
1401 fn the_exponential_theme_carries_the_apps_values() {
1402 let t = builtin_theme("exponential").unwrap();
1403 assert_eq!(t.tokens.spacing["md"], 12.0);
1404 assert_eq!(t.tokens.radius["lg"], 12.0);
1405 assert_eq!(t.tokens.r#type.family["sans"], "Inter");
1406 assert_eq!(t.tokens.control["buttonMd"], 32.0);
1407 assert_eq!(t.modes.dark.color["background"], "#0a0a0a");
1408 assert_eq!(t.modes.light.color["background"], "#ffffff");
1409 let card = &t.modes.dark.color["card"];
1410 assert!(card.starts_with("#ffffff") && card.len() == 9);
1411 }
1412
1413 #[test]
1414 fn playful_differs_from_neutral_where_it_says_so_and_inherits_the_rest() {
1415 let neutral = builtin_theme("neutral").unwrap();
1416 let playful = builtin_theme("playful").unwrap();
1417 assert_ne!(playful.tokens.radius["md"], neutral.tokens.radius["md"]);
1418 assert_eq!(playful.tokens.spacing, neutral.tokens.spacing);
1419 assert_eq!(playful.tokens.r#type.family["sans"], "Nunito");
1420 let button = |id: &str| resolve_recipe(&builtin_theme(id).unwrap(), &q("Button", "root", json!({"variant": "default", "size": "default"}), &[]), Mode::Light);
1421 assert_eq!(button("playful")["borderRadius"], json!(9999));
1422 assert_eq!(button("neutral")["borderRadius"], json!(8));
1423 assert_eq!(button("playful")["backgroundColor"], json!("#7c3aed"));
1424 }
1425
1426 #[test]
1427 fn tokens_resolve_per_mode() {
1428 let n = builtin_theme("neutral").unwrap();
1429 assert_eq!(resolve_token(&n, &json!("$color.primary"), Mode::Light), Some(json!("#171717")));
1430 assert_eq!(resolve_token(&n, &json!("$color.primary"), Mode::Dark), Some(json!("#e5e5e5")));
1431 assert_eq!(resolve_token(&n, &json!("$spacing.md"), Mode::Light), Some(json!(12)));
1432 assert_eq!(resolve_token(&n, &json!("$type.family.sans"), Mode::Light), Some(json!("Geist")));
1433 assert_eq!(resolve_token(&n, &json!("$shadow.none"), Mode::Light), Some(json!([])));
1434 assert_eq!(resolve_token(&n, &json!("nope"), Mode::Light), None);
1435 }
1436
1437 fn has(style: &Props, expected: Value) {
1438 for (k, v) in expected.as_object().unwrap() {
1439 assert!(style.get(k).is_some_and(|s| json::equal(s, v)), "{k}: {:?} != {v}", style.get(k));
1440 }
1441 }
1442
1443 #[test]
1444 fn rules_merge_in_order_variant_size_and_state_compose() {
1445 let n = builtin_theme("neutral").unwrap();
1446 let r = |props: Value, states: &[&str]| resolve_recipe(&n, &q("Button", "root", props, states), Mode::Light);
1447 has(&r(json!({"variant": "default", "size": "default"}), &[]), json!({"height": 36, "backgroundColor": "#171717", "color": "#fafafa", "borderRadius": 8}));
1448 has(&r(json!({"variant": "outline", "size": "sm"}), &[]), json!({"height": 32, "borderWidth": 1, "borderColor": "#e5e5e5", "backgroundColor": "#ffffff"}));
1449 has(&r(json!({"variant": "outline", "size": "sm"}), &["hover"]), json!({"backgroundColor": "#f5f5f5"}));
1450 has(&r(json!({"variant": "ghost", "size": "icon"}), &[]), json!({"width": 36, "height": 36, "backgroundColor": "#00000000", "boxShadow": []}));
1451 has(&r(json!({"variant": "default", "size": "default"}), &["disabled"]), json!({"opacity": 0.5}));
1452 has(&r(json!({"variant": "default", "size": "default"}), &["hover"]), json!({"opacity": 0.9}));
1453 }
1454
1455 #[test]
1456 fn a_state_rule_needs_every_listed_state_a_list_value_matches_any() {
1457 let n = builtin_theme("neutral").unwrap();
1458 let style = resolve_recipe(&n, &q("Badge", "icon", json!({"variant": "outline"}), &[]), Mode::Dark);
1459 assert_eq!(style["color"], json!("#fafafa"));
1460 }
1461
1462 #[test]
1463 fn node_precedence_native_recipe_then_node_style_then_macro_part_recipe() {
1464 let n = builtin_theme("neutral").unwrap();
1466 let index = RecipeIndex::core();
1467 let mut label = UiNode::new("b.label", "Text");
1468 label.props = json!({"text": "New", "variant": "caption"}).as_object().cloned().unwrap();
1469 label.recipe = Some(crate::types::Recipe { macro_: "Badge".into(), part: "label".into(), props: json!({"variant": "destructive"}).as_object().cloned().unwrap(), authored: Vec::new() });
1470 let query = node_recipe_query(&label, &index);
1471 assert_eq!(query, q("Badge", "label", json!({"variant": "destructive"}), &[]));
1472 let style = resolve_node_style(&n, &label, Mode::Light, &[], &index);
1473 assert_eq!(style["fontSize"], json!(12));
1474 assert_eq!(style["color"], json!(n.modes.light.color["destructiveForeground"]));
1475 assert_eq!(style["fontWeight"], json!(500));
1476 }
1477
1478 #[test]
1479 fn an_authors_style_on_a_plain_native_wins_over_its_recipe() {
1480 let n = builtin_theme("neutral").unwrap();
1481 let mut text = UiNode::new("t", "Text");
1482 text.props = json!({"text": "x", "variant": "muted"}).as_object().cloned().unwrap();
1483 text.style = json!({"color": "#ff0000"}).as_object().cloned();
1484 assert_eq!(resolve_node_style(&n, &text, Mode::Light, &[], &RecipeIndex::core())["color"], json!("#ff0000"));
1485 }
1486
1487 fn expand(node: Value) -> UiNode {
1488 use crate::catalog::CORE_CATALOG_ID;
1489 use crate::reducer::{reduce_nested, ReduceOptions};
1490 reduce_nested(&serde_json::from_value(node).unwrap(), &ReduceOptions::new(CORE_CATALOG_ID)).root
1491 }
1492
1493 #[test]
1494 fn vapp163_the_expander_lists_the_authors_keys_sorted() {
1495 let heading = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi", "level": "h1"}, "style": {"fontSize": 40, "color": "$color.primaryForeground"}}));
1496 assert_eq!(heading.recipe.as_ref().unwrap().authored, vec!["color".to_string(), "fontSize".to_string()]);
1497 let plain = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi"}}));
1498 assert!(plain.recipe.as_ref().unwrap().authored.is_empty());
1499 assert!(serde_json::to_value(plain.recipe.unwrap()).unwrap().get("authored").is_none());
1500 }
1501
1502 #[test]
1503 fn vapp163_an_authors_style_on_a_heading_wins_over_its_part_recipe() {
1504 let index = RecipeIndex::core();
1505 let heading = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi", "level": "h1"}, "style": {"fontSize": 40, "color": "$color.primaryForeground"}}));
1506 for id in ["playful", "neutral"] {
1507 let t = builtin_theme(id).unwrap();
1508 let style = resolve_node_style(&t, &heading, Mode::Light, &[], &index);
1509 assert_eq!(style["fontSize"], json!(40), "{id}");
1510 assert_eq!(style["color"], json!(t.modes.light.color["primaryForeground"]), "{id}");
1511 let recipe = resolve_recipe(&t, &q("Heading", "root", json!({"level": "h1"}), &[]), Mode::Light);
1512 assert_eq!(style["fontWeight"], recipe["fontWeight"], "{id}");
1513 }
1514 }
1515
1516 #[test]
1517 fn vapp163_badge_author_background_wins_template_structure_still_loses() {
1518 let index = RecipeIndex::core();
1519 let badge = expand(json!({"id": "b", "component": "Badge", "props": {"text": "New"}, "style": {"backgroundColor": "#123456"}}));
1520 assert_eq!(badge.recipe.as_ref().unwrap().authored, vec!["backgroundColor".to_string()]);
1521 let neutral = builtin_theme("neutral").unwrap();
1522 assert_eq!(resolve_node_style(&neutral, &badge, Mode::Light, &[], &index)["backgroundColor"], json!("#123456"));
1523 let playful = builtin_theme("playful").unwrap();
1524 let style = resolve_node_style(&playful, &badge, Mode::Light, &[], &index);
1525 assert_eq!(style["backgroundColor"], json!("#123456"));
1526 assert!(badge.style.as_ref().unwrap().contains_key("borderRadius"));
1527 let recipe = resolve_recipe(&playful, &q("Badge", "root", json!({"variant": "default"}), &[]), Mode::Light);
1528 assert_eq!(style["borderRadius"], recipe["borderRadius"]);
1529 }
1530
1531 #[test]
1532 fn vapp163_an_authored_condition_block_wins_with_its_media_key_resolved() {
1533 let playful = builtin_theme("playful").unwrap();
1534 let node = expand(json!({"id": "h", "component": "Heading", "props": {"text": "Hi"}, "style": {"@media (min-width: $breakpoint.md)": {"color": "#00ff00"}}}));
1535 assert_eq!(node.recipe.as_ref().unwrap().authored, vec!["@media (min-width: $breakpoint.md)".to_string()]);
1536 let style = resolve_node_style(&playful, &node, Mode::Light, &[], &RecipeIndex::core());
1537 let key = format!("@media (min-width: {}px)", json::number_to_string(playful.tokens.breakpoint["md"]));
1538 assert_eq!(style[&key], json!({"color": "#00ff00"}));
1539 }
1540
1541 #[test]
1542 fn a_theme_that_changes_primary_and_the_button_radius_works_everywhere() {
1543 let refs = builtin_refs();
1544 let brand = load_theme(
1545 &json!({"$schema": THEME_SCHEMA_ID, "id": "brand", "name": "Brand", "extends": "neutral", "modes": {"light": {"color": {"primary": "#2563eb"}}}, "recipes": {"Button": {"root": [{"style": {"borderRadius": "$radius.full"}}]}}}),
1546 &ThemeOptions::core(&refs),
1547 )
1548 .unwrap();
1549 assert_eq!(brand.chain, ["neutral", "brand"]);
1550 assert_eq!(brand.modes.light.color["primary"], "#2563eb");
1551 assert_eq!(brand.modes.dark.color["primary"], "#e5e5e5");
1552 assert_eq!(brand.modes.light.color["secondary"], "#f5f5f5");
1553 has(&resolve_recipe(&brand, &q("Button", "root", json!({"variant": "default", "size": "sm"}), &[]), Mode::Light), json!({"backgroundColor": "#2563eb", "borderRadius": 9999, "height": 32}));
1554 assert_eq!(resolve_recipe(&brand, &q("Badge", "root", json!({"variant": "default"}), &[]), Mode::Light)["backgroundColor"], json!("#2563eb"));
1555 assert_eq!(resolve_recipe(&brand, &q("Text", "root", json!({"variant": "title", "tone": "primary"}), &[]), Mode::Light)["color"], json!("#2563eb"));
1556 }
1557
1558 #[test]
1559 fn chains_of_chains_and_resolved_parents() {
1560 let refs = [ThemeRef::Resolved(builtin_theme("playful").unwrap())];
1561 let child = load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "c", "name": "C", "extends": "playful", "tokens": {"spacing": {"md": 20}}}), &ThemeOptions::core(&refs)).unwrap();
1562 assert_eq!(child.chain, ["neutral", "playful", "c"]);
1563 assert_eq!(child.tokens.spacing["md"], 20.0);
1564 assert_eq!(child.tokens.radius["md"], 14.0);
1565 }
1566
1567 #[test]
1568 fn source_chains_and_cycles() {
1569 let refs = [
1570 ThemeRef::Source(json!({"$schema": THEME_SCHEMA_ID, "id": "a", "name": "A", "extends": "b"})),
1571 ThemeRef::Source(json!({"$schema": THEME_SCHEMA_ID, "id": "b", "name": "B", "extends": "a"})),
1572 ];
1573 let err = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "c", "name": "C", "extends": "a"}), &ThemeOptions::core(&refs)).unwrap_err();
1574 assert_eq!(err, [issue("extends", "cycle: c → a → b → a")]);
1575 }
1576
1577 #[test]
1578 fn an_invalid_theme_fails_with_every_issue_listed_never_a_crash() {
1579 let refs = builtin_refs();
1580 let opts = ThemeOptions::core(&refs);
1581 let e = load_theme(&Value::Null, &opts).unwrap_err();
1582 assert_eq!(e.id, "?");
1583 assert!(load_theme(&json!(42), &ThemeOptions::default()).unwrap_err().to_string().contains("expected a theme object"));
1584 let bad = json!({"$schema": THEME_SCHEMA_ID, "id": "bad", "name": "Bad", "extends": "neutral", "tokens": {"spacing": {"huge": 1}}, "recipes": {"Button": {"root": [{"style": {"display": "flex"}}]}}});
1585 let e = load_theme(&bad, &opts).unwrap_err();
1586 let paths: Vec<&str> = e.issues.iter().map(|i| i.path.as_str()).collect();
1587 assert_eq!(paths, ["tokens.spacing.huge", "recipes.Button.root[0].style.display"]);
1588 let msg = e.to_string();
1589 assert!(msg.contains("Theme \"bad\" is invalid:"));
1590 assert!(msg.contains("not a recipe key"));
1591 let err = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "x", "name": "X", "extends": "nope"}), &opts).unwrap_err();
1592 assert!(err[0].message.contains("unknown theme \"nope\""));
1593 assert_eq!(validate_theme(&json!("neutral"), &ThemeOptions::default()).len(), 1);
1594 }
1595
1596 #[test]
1597 fn a_root_theme_must_give_every_token_a_value() {
1598 let issues = try_load_theme(&json!({"$schema": THEME_SCHEMA_ID, "id": "bare", "name": "Bare", "modes": {}, "tokens": {}, "recipes": {}}), &ThemeOptions::default()).unwrap_err();
1599 assert!(issues.len() > 100);
1600 assert!(issues[0].message.starts_with("missing value for $color."));
1601 }
1602
1603 #[test]
1604 fn shadow_css_prints_js_numbers() {
1605 let n = builtin_theme("neutral").unwrap();
1606 assert_eq!(shadow_css(&n.modes.light.shadow["none"]), "none");
1607 assert_eq!(shadow_css(&n.modes.light.shadow["sm"]), "0px 1px 2px 0px #0000000d");
1608 }
1609
1610 #[test]
1611 fn hex_and_id_checks() {
1612 assert!(is_theme_hex(&json!("#a1b2c3")));
1613 assert!(is_theme_hex(&json!("#a1b2c3d4")));
1614 assert!(!is_theme_hex(&json!("#A1B2C3")));
1615 assert!(!is_theme_hex(&json!("#abc")));
1616 assert!(is_kebab_id("a-1"));
1617 assert!(!is_kebab_id("1a"));
1618 assert!(!is_kebab_id(""));
1619 }
1620}