Skip to main content

eui_core/
theme.rs

1//! The theme loader and resolver (VAPP-92), a line-by-line port of
2//! `src/theme.ts` + `src/theme-types.ts`. A theme is data, never code: one
3//! JSON file of token VALUES per mode plus component RECIPES.
4//! [`validate_theme`] turns a theme file into readable issues (never a
5//! panic), [`load_theme`] flattens its `extends` chain into a
6//! [`ResolvedTheme`] and [`resolve_recipe`] answers a painter's question for
7//! one mode with concrete values.
8//!
9//! Precedence a painter applies ([`resolve_node_style`]): the native's own
10//! recipe < the node's style < the macro part's recipe < the node style's
11//! AUTHORED keys (VAPP-163: the style written on the macro node, listed in
12//! `recipe.authored` by the expander).
13//!
14//! Source objects are `serde_json::Value`s in SOURCE order (`preserve_order`),
15//! so issues come out in the TS order.
16//!
17//! Round 1 (docs/round-1-contract.md §5): `breakpoint` (px), `ease` (cubic
18//! bezier control points) and `density` (multipliers) token groups, a
19//! high-contrast `contrast` overlay per mode, recipe keys for per-side
20//! borders, text styling and motion, [`resolve_mode`] / [`apply_density`] /
21//! [`apply_contrast`] for the surface settings, and [`resolve_condition_key`]
22//! (a `$breakpoint` inside a media key resolves to px).
23
24use 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// ---------------------------------------------------------------------------
40// Types
41// ---------------------------------------------------------------------------
42
43#[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
67/// The mode names, in resolution order.
68pub const MODES: [&str; 2] = ["light", "dark"];
69
70/// One shadow layer. Numbers serialize in the JS number model (`1`, not `1.0`).
71#[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/// The per-mode values: colours and shadows.
108#[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    /// Family NAMES (`Inter`); `fonts` describes how the host registers them.
144    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    /// Milliseconds.
180    #[serde(serialize_with = "ser_nums")]
181    pub motion: IndexMap<String, f64>,
182    /// Surface widths in px the style conditions may name (`$breakpoint.md`).
183    #[serde(default, serialize_with = "ser_nums")]
184    pub breakpoint: IndexMap<String, f64>,
185    /// `cubic-bezier(x1, y1, x2, y2)` control points.
186    #[serde(default, serialize_with = "ser_curves")]
187    pub ease: IndexMap<String, Vec<f64>>,
188    /// Multipliers over `control` and `spacing` for the non-default densities.
189    #[serde(default, serialize_with = "ser_nums")]
190    pub density: IndexMap<String, f64>,
191    /// Round 2: backdrop blur radii in px (`backdropBlur: $blur.md`).
192    #[serde(default, serialize_with = "ser_nums")]
193    pub blur: IndexMap<String, f64>,
194    /// EXP-1259: the motion scale multipliers painters read (`press` = a
195    /// pressed button, `popoverFrom` = a popover's enter scale).
196    #[serde(default, serialize_with = "ser_nums")]
197    pub scale: IndexMap<String, f64>,
198}
199
200/// The numeric groups directly under `tokens`, in overlay order.
201const NUMBER_GROUPS: [&str; 10] = ["spacing", "radius", "control", "opacity", "border", "motion", "breakpoint", "density", "blur", "scale"];
202/// Token groups whose values are cubic-bezier tuples.
203const TUPLE_GROUPS: [&str; 1] = ["ease"];
204const TYPE_SUBS: [&str; 4] = ["size", "lineHeight", "weight", "family"];
205
206impl ThemeTokens {
207    /// A numeric group by its flattened name (`spacing`, `type.size`, …).
208    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/// How a host registers a family the theme names.
241#[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    /// `system` | `host`.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub source: Option<String>,
250}
251
252/// One rule: applies when every `when` entry matches; rules merge in order.
253#[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
261/// Component → part → rules.
262pub type ThemeRecipes = IndexMap<String, IndexMap<String, Vec<RecipeRule>>>;
263
264/// What a painter receives: complete, flat, `extends` gone.
265#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)]
266pub struct ResolvedTheme {
267    pub id: String,
268    pub name: String,
269    /// The chain this theme was built from, root first.
270    pub chain: Vec<String>,
271    pub modes: Modes,
272    /// The high-contrast overlays per mode (partial tables, may be empty).
273    #[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/// Every issue of a theme that failed to load.
291#[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/// What an `extends` may name: a theme source or an already resolved theme.
310#[derive(Clone, Debug)]
311pub enum ThemeRef {
312    Source(Value),
313    Resolved(Arc<ResolvedTheme>),
314}
315
316impl ThemeRef {
317    /// The theme's id (`None` for a source without a string id).
318    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/// The registry an `extends` resolves against and the catalog view recipes
329/// are validated against.
330#[derive(Clone, Copy)]
331pub struct ThemeOptions<'a> {
332    pub themes: &'a [ThemeRef],
333    pub view: &'a CatalogView,
334}
335
336impl<'a> ThemeOptions<'a> {
337    /// The given registry over the core catalog.
338    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/// The question a painter asks.
350#[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
366// ---------------------------------------------------------------------------
367// Constants
368// ---------------------------------------------------------------------------
369
370/// Token groups whose values are per-mode (under `modes`), not under `tokens`.
371const MODE_GROUPS: [&str; 2] = ["color", "shadow"];
372const STRING_GROUPS: [&str; 1] = ["type.family"];
373/// Which token groups each recipe key accepts.
374const 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
414/// The enum a style key takes (`borderStyle` → solid|dashed|dotted).
415fn 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
431/// The token groups in SOURCE order. `catalog::TOKEN_GROUPS` parses the
432/// nested `type` object through a sorted `Value`, so its `type.*` entries
433/// come out alphabetically; the generated name list keeps tokens.json order.
434fn 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
442/// `#rrggbb` or `#rrggbbaa`, lowercase (`color.ts` `isThemeHex`).
443pub 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
458// ---------------------------------------------------------------------------
459// Validation (one file, no chain knowledge beyond "does the parent exist")
460// ---------------------------------------------------------------------------
461
462fn 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
800/// The recipe index for the options' view (the cached core one when the
801/// view is the core).
802fn 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
810/// Every problem in ONE theme file, each with a path and a readable
811/// message. Never panics: a non-object is one issue.
812pub 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    // Round 4 (VAPP-103): the schema id is READ: a theme written for
818    // another (or no) format version is refused outright.
819    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
860// ---------------------------------------------------------------------------
861// Resolution: flatten the chain, check completeness
862// ---------------------------------------------------------------------------
863
864fn 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
892/// Merge a VALIDATED source over the target (TS `overlay`).
893fn 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
943/// The token names the merged theme still lacks, as issues.
944fn 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
983/// The links a chain needs, root first; an issue when a link is missing.
984fn 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
1013/// Validate + flatten, returning issues instead of failing loudly.
1014pub 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
1052/// A theme file → the flat theme a painter uses, or ONE error listing every issue.
1053pub 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
1060// ---------------------------------------------------------------------------
1061// Resolving values for one mode
1062// ---------------------------------------------------------------------------
1063
1064fn numbers_value(m: &IndexMap<String, f64>) -> Value {
1065    Value::Object(m.iter().map(|(k, v)| (k.clone(), json::number(*v))).collect())
1066}
1067
1068/// `$color.primary` in `dark` → `"#e5e5e5"`; `$spacing.md` → `12`;
1069/// `$shadow.sm` → the layers; `$type.family.sans` → `"Inter"`. `None` when
1070/// not a token (or not one this theme has).
1071pub 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.size` names a whole sub-table, like the TS lookup does.
1080        "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
1088/// A media key with its `$breakpoint.<name>` replaced by the theme's px
1089/// (`resolveConditionKey`); any other key passes.
1090pub 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
1108/// Every token reference in a style object replaced by its value for the
1109/// mode (nested condition blocks, gradients and arrays included, and the
1110/// `$breakpoint` tokens inside media keys); other values pass through.
1111pub 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// ---------------------------------------------------------------------------
1120// Surface settings: mode, density, contrast (round 1)
1121// ---------------------------------------------------------------------------
1122
1123/// What a host asks for: a mode, or `system` = the platform's preference.
1124#[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
1144/// `system` → what the platform prefers; a mode stays itself.
1145pub 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/// The surface density; `default` = the tokens as written.
1160#[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
1191/// The theme a surface of this density runs with: `control` and `spacing`
1192/// scaled by the theme's `$density.<name>` multiplier and rounded to whole
1193/// px; `default` (or a theme without the multiplier) returns it unchanged.
1194/// Done once per surface, so every resolver and fixture stays
1195/// density-agnostic.
1196pub 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
1206/// The theme with its high-contrast overlays merged into the modes (the
1207/// platform asked for more contrast). A theme without overlays is unchanged.
1208pub 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
1216/// An easing as CSS `cubic-bezier(…)`.
1217pub 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
1248/// The matching rules' styles merged in order (token refs kept).
1249/// How specific a rule is: one point per `when` condition (a `state` list
1250/// counts each state). VAPP-90: rules merge in SPECIFICITY order, like the
1251/// CSS the web renderer compiles them to — a base rule (no `when`) never
1252/// shadows a `checked`/`focus`/`variant` rule that sits before it, which is
1253/// what a child theme's appended base override used to do under plain
1254/// source order. Ties keep source order (later wins). Mirrors
1255/// `ruleSpecificity` in `packages/exponential-ui/src/theme.ts`.
1256pub 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    // A stable sort: ties keep source order.
1271    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
1282/// Does the query's recipe change under interaction `state` (`hover`)? True
1283/// when a rule names the state in `when.state` and every other `when` key
1284/// matches the query's props (the rule's OTHER states are not required: a
1285/// `[hover, checked]` rule counts while unchecked). Painters ask it to know
1286/// which nodes to track the pointer over.
1287pub 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
1307/// A part's concrete visuals for one mode (`theme-recipes.json` locks it).
1308pub fn resolve_recipe(theme: &ResolvedTheme, query: &RecipeQuery, mode: Mode) -> Props {
1309    resolve_style_values(theme, &recipe_style(theme, query), mode)
1310}
1311
1312/// The recipe query a NORMALIZED node answers to: its macro part, or the
1313/// native's root with its own recipe props.
1314pub 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
1321/// What a painter paints a node with: native recipe < node style < macro
1322/// part recipe < the node style's authored keys, token references
1323/// resolved, condition blocks kept nested.
1324pub 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
1343/// The shadow layers as one CSS `box-shadow` string (for web painters).
1344pub 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// VAPP-106: these exercise `neutral` and `playful`, which only embed with
1357// the default-on `builtin-themes` feature (tests/generated_drift.rs covers
1358// the other state).
1359#[cfg(all(test, feature = "builtin-themes"))]
1360mod tests {
1361    /// VAPP-90: a conditioned rule wins over a LATER base rule (a child
1362    /// theme's appended override used to shadow every `checked`/`focus`
1363    /// rule under plain source order); ties keep source order.
1364    #[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        // The Badge label node as the reducer emits it (Text + the Badge/label part).
1465        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}