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