Skip to main content

gpui_rhai/
theme.rs

1use std::collections::BTreeMap;
2
3use rhai::{Dynamic, Engine, Scope};
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{ColorResolver, ColorValue, ComponentInstancePath, Length, Rgba8};
8
9const REQUIRED_COLORS: &[&str] = &[
10    "surface",
11    "surface_raised",
12    "surface_hover",
13    "text_primary",
14    "text_muted",
15    "accent",
16    "accent_hover",
17    "on_accent",
18    "danger",
19    "on_danger",
20    "warning",
21    "on_warning",
22    "success",
23    "on_success",
24    "border",
25    "focus_ring",
26    "selection",
27    "disabled",
28];
29const REQUIRED_SPACING: &[&str] = &["xxs", "xs", "sm", "md", "lg"];
30const REQUIRED_RADII: &[&str] = &["sm", "md", "lg"];
31const REQUIRED_MOTION_DURATIONS: &[&str] = &["instant", "fast", "normal", "slow", "ambient"];
32const REQUIRED_MOTION_EASINGS: &[&str] = &["standard", "entrance", "exit", "emphasized"];
33const REQUIRED_MOTION_SPRINGS: &[&str] = &["responsive", "gentle", "bouncy"];
34const REQUIRED_MOTION_DISTANCES: &[&str] = &["subtle", "moderate", "large"];
35const REQUIRED_MOTION_STAGGERS: &[&str] = &["tight", "normal", "relaxed"];
36pub const REQUIRED_TYPOGRAPHY: &[&str] = &[
37    "caption",
38    "body_small",
39    "body",
40    "subtitle",
41    "title",
42    "heading",
43    "display",
44    "display_large",
45];
46
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ThemeMode {
50    Light,
51    Dark,
52}
53
54/// Lightweight resolved identity, distinct from a user preference or token snapshot.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ThemeVariantInfo {
57    pub family: String,
58    pub name: String,
59    pub mode: ThemeMode,
60}
61
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63pub struct ThemeTokens {
64    pub colors: BTreeMap<String, Rgba8>,
65    pub spacing: BTreeMap<String, Length>,
66    pub radii: BTreeMap<String, Length>,
67    pub typography: ThemeTypography,
68    #[serde(default)]
69    pub motion: ThemeMotion,
70    #[serde(default)]
71    pub namespaces: BTreeMap<String, BTreeMap<String, ThemeTokenValue>>,
72}
73
74/// Host-owned token preferences applied uniformly to every loaded theme.
75///
76/// Overrides are intentionally partial: absent entries inherit the value from
77/// each theme, while present entries replace it. Theme family, variant name,
78/// and color mode are never host-overridable through this type.
79#[derive(Clone, Debug, Default, PartialEq)]
80pub struct ThemeTokenOverrides {
81    pub colors: BTreeMap<String, Rgba8>,
82    pub spacing: BTreeMap<String, Length>,
83    pub radii: BTreeMap<String, Length>,
84    pub typography: ThemeTypographyOverrides,
85    pub motion: ThemeMotionOverrides,
86    pub namespaces: BTreeMap<String, BTreeMap<String, ThemeTokenValue>>,
87}
88
89/// Partial host preferences for the shared typography system.
90#[derive(Clone, Debug, Default, PartialEq)]
91pub struct ThemeTypographyOverrides {
92    /// Replaces the theme's primary family when present.
93    pub family: Option<String>,
94    /// Replaces, rather than appends to, the fallback stack when present.
95    pub fallbacks: Option<Vec<String>>,
96    /// Replaces individual typography roles by name.
97    pub roles: BTreeMap<String, TypographyToken>,
98}
99
100/// Partial host preferences for semantic motion tokens.
101#[derive(Clone, Debug, Default, PartialEq)]
102pub struct ThemeMotionOverrides {
103    pub durations_ms: BTreeMap<String, u64>,
104    pub easings: BTreeMap<String, crate::MotionEasing>,
105    pub springs: BTreeMap<String, ThemeMotionSpring>,
106    pub distances: BTreeMap<String, f64>,
107    pub staggers_ms: BTreeMap<String, u64>,
108}
109
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct ThemeMotion {
112    pub durations_ms: BTreeMap<String, u64>,
113    pub easings: BTreeMap<String, crate::MotionEasing>,
114    pub springs: BTreeMap<String, ThemeMotionSpring>,
115    pub distances: BTreeMap<String, f64>,
116    pub staggers_ms: BTreeMap<String, u64>,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
120pub struct ThemeMotionSpring {
121    pub stiffness: f64,
122    pub damping: f64,
123    pub mass: f64,
124}
125
126impl Default for ThemeMotion {
127    fn default() -> Self {
128        Self {
129            durations_ms: BTreeMap::from([
130                ("instant".to_owned(), 1),
131                ("fast".to_owned(), 120),
132                ("normal".to_owned(), 180),
133                ("slow".to_owned(), 320),
134                ("ambient".to_owned(), 1_100),
135            ]),
136            easings: BTreeMap::from([
137                ("standard".to_owned(), crate::MotionEasing::EaseInOut),
138                ("entrance".to_owned(), crate::MotionEasing::EaseOut),
139                ("exit".to_owned(), crate::MotionEasing::EaseIn),
140                ("emphasized".to_owned(), crate::MotionEasing::EaseInOut),
141            ]),
142            springs: BTreeMap::from([
143                (
144                    "responsive".to_owned(),
145                    ThemeMotionSpring {
146                        stiffness: 240.0,
147                        damping: 26.0,
148                        mass: 1.0,
149                    },
150                ),
151                (
152                    "gentle".to_owned(),
153                    ThemeMotionSpring {
154                        stiffness: 140.0,
155                        damping: 22.0,
156                        mass: 1.0,
157                    },
158                ),
159                (
160                    "bouncy".to_owned(),
161                    ThemeMotionSpring {
162                        stiffness: 280.0,
163                        damping: 16.0,
164                        mass: 1.0,
165                    },
166                ),
167            ]),
168            distances: BTreeMap::from([
169                ("subtle".to_owned(), 4.0),
170                ("moderate".to_owned(), 12.0),
171                ("large".to_owned(), 32.0),
172            ]),
173            staggers_ms: BTreeMap::from([
174                ("tight".to_owned(), 24),
175                ("normal".to_owned(), 48),
176                ("relaxed".to_owned(), 80),
177            ]),
178        }
179    }
180}
181
182impl ThemeMotion {
183    /// Validate the semantic motion token contract.
184    ///
185    /// # Errors
186    ///
187    /// Returns a theme error for missing or physically invalid tokens.
188    pub fn validate(&self) -> Result<(), ThemeError> {
189        require_tokens(
190            "motion duration",
191            REQUIRED_MOTION_DURATIONS,
192            &self.durations_ms,
193        )?;
194        require_tokens("motion easing", REQUIRED_MOTION_EASINGS, &self.easings)?;
195        require_tokens("motion spring", REQUIRED_MOTION_SPRINGS, &self.springs)?;
196        require_tokens(
197            "motion distance",
198            REQUIRED_MOTION_DISTANCES,
199            &self.distances,
200        )?;
201        require_tokens(
202            "motion stagger",
203            REQUIRED_MOTION_STAGGERS,
204            &self.staggers_ms,
205        )?;
206        if self.durations_ms.values().any(|duration| *duration == 0)
207            || self.staggers_ms.values().any(|duration| *duration == 0)
208            || self
209                .distances
210                .values()
211                .any(|distance| !distance.is_finite() || *distance < 0.0)
212            || self.springs.values().any(|spring| {
213                !spring.stiffness.is_finite()
214                    || spring.stiffness <= 0.0
215                    || !spring.damping.is_finite()
216                    || spring.damping < 0.0
217                    || !spring.mass.is_finite()
218                    || spring.mass <= 0.0
219            })
220        {
221            return Err(ThemeError::InvalidMotionTokens);
222        }
223        Ok(())
224    }
225}
226
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228pub struct ThemeTypography {
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub family: Option<String>,
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub fallbacks: Vec<String>,
233    pub roles: BTreeMap<String, TypographyToken>,
234}
235
236#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
237pub struct TypographyToken {
238    pub size: Length,
239    pub line_height: Length,
240    pub weight: u16,
241}
242
243#[derive(Clone, Debug, PartialEq)]
244pub struct ResolvedTypography {
245    pub family: Option<String>,
246    pub fallbacks: Vec<String>,
247    pub size: Length,
248    pub line_height: Length,
249    pub weight: u16,
250}
251
252#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
253#[serde(tag = "type", content = "value", rename_all = "snake_case")]
254pub enum ThemeTokenValue {
255    Color(Rgba8),
256    Length(Length),
257    Number(f64),
258    String(String),
259}
260
261impl ThemeTokens {
262    fn install_document_defaults(&mut self) {
263        let color = |name: &str| self.colors.get(name).copied();
264        let syntax = self.namespaces.entry("syntax".to_owned()).or_default();
265        for (name, value) in [
266            ("comment", color("text_muted")),
267            ("string", color("success")),
268            ("number", color("warning")),
269            ("keyword", color("accent")),
270            ("function", color("accent_hover")),
271            ("type", color("warning")),
272            ("variable", color("text_primary")),
273            ("constant", color("danger")),
274            ("operator", color("accent")),
275            ("punctuation", color("text_muted")),
276            ("tag", color("danger")),
277            ("attribute", color("warning")),
278        ] {
279            if let Some(value) = value {
280                syntax
281                    .entry(name.to_owned())
282                    .or_insert(ThemeTokenValue::Color(value));
283            }
284        }
285        let document = self.namespaces.entry("document".to_owned()).or_default();
286        if let Some(value) = color("warning") {
287            document
288                .entry("search_match".to_owned())
289                .or_insert(ThemeTokenValue::Color(with_alpha(value, 0x55)));
290            document
291                .entry("search_current".to_owned())
292                .or_insert(ThemeTokenValue::Color(with_alpha(value, 0xaa)));
293        }
294        let diff = self.namespaces.entry("diff".to_owned()).or_default();
295        for (name, value) in [
296            (
297                "left_only",
298                color("danger").map(|value| with_alpha(value, 0x24)),
299            ),
300            (
301                "right_only",
302                color("success").map(|value| with_alpha(value, 0x24)),
303            ),
304            (
305                "modified",
306                color("accent").map(|value| with_alpha(value, 0x18)),
307            ),
308            (
309                "inline_left",
310                color("danger").map(|value| with_alpha(value, 0x66)),
311            ),
312            (
313                "inline_right",
314                color("success").map(|value| with_alpha(value, 0x66)),
315            ),
316            ("gutter", color("surface_raised")),
317            ("fold", color("surface_hover")),
318        ] {
319            if let Some(value) = value {
320                diff.entry(name.to_owned())
321                    .or_insert(ThemeTokenValue::Color(value));
322            }
323        }
324        let charts = self.namespaces.entry("charts".to_owned()).or_default();
325        for (name, value) in [
326            ("axis", color("text_muted")),
327            ("grid", color("border").map(|value| with_alpha(value, 0x55))),
328            ("tooltip_surface", color("surface_raised")),
329            ("tooltip_text", color("text_primary")),
330            ("positive", color("success")),
331            ("negative", color("danger")),
332            ("selection", color("accent")),
333            ("map_missing", color("surface_hover")),
334            ("crosshair", color("focus_ring")),
335            ("palette_1", color("accent")),
336            ("palette_2", color("success")),
337            ("palette_3", color("warning")),
338            ("palette_4", color("danger")),
339            ("palette_5", color("focus_ring")),
340            ("palette_6", color("accent_hover")),
341            ("palette_7", color("text_muted")),
342            ("palette_8", color("selection")),
343        ] {
344            if let Some(value) = value {
345                charts
346                    .entry(name.to_owned())
347                    .or_insert(ThemeTokenValue::Color(value));
348            }
349        }
350        self.install_component_defaults();
351    }
352
353    fn install_component_defaults(&mut self) {
354        let color = |name: &str| self.colors.get(name).copied();
355        let table_selection = color("surface")
356            .zip(color("accent"))
357            .map(|(surface, accent)| mix_opaque(surface, accent, 0x48));
358        let table = self.namespaces.entry("table".to_owned()).or_default();
359        if let Some(value) = table_selection {
360            table
361                .entry("selection".to_owned())
362                .or_insert(ThemeTokenValue::Color(value));
363        }
364        let tabs_foreground = color("text_muted")
365            .zip(color("text_primary"))
366            .zip(color("surface_hover"))
367            .map(|((muted, primary), surface)| readable_secondary(muted, primary, surface));
368        let tabs = self.namespaces.entry("tabs".to_owned()).or_default();
369        if let Some(value) = tabs_foreground {
370            tabs.entry("foreground".to_owned())
371                .or_insert(ThemeTokenValue::Color(value));
372        }
373    }
374
375    /// Validate the initial semantic token contract.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`ThemeError`] for missing tokens or invalid lengths.
380    pub fn validate(&self) -> Result<(), ThemeError> {
381        require_tokens("color", REQUIRED_COLORS, &self.colors)?;
382        require_tokens("spacing", REQUIRED_SPACING, &self.spacing)?;
383        require_tokens("radius", REQUIRED_RADII, &self.radii)?;
384        self.typography.validate()?;
385        self.motion.validate()?;
386        for (name, value) in self.spacing.iter().chain(&self.radii) {
387            if value.is_theme_token() {
388                return Err(ThemeError::NestedLengthToken(name.clone()));
389            }
390            value
391                .validate()
392                .map_err(|source| ThemeError::InvalidLength {
393                    token: name.clone(),
394                    source,
395                })?;
396        }
397        for (namespace, tokens) in &self.namespaces {
398            if !valid_token_segment(namespace) {
399                return Err(ThemeError::InvalidNamespace(namespace.clone()));
400            }
401            for (name, value) in tokens {
402                if !valid_token_segment(name) {
403                    return Err(ThemeError::InvalidTokenName {
404                        namespace: namespace.clone(),
405                        name: name.clone(),
406                    });
407                }
408                match value {
409                    ThemeTokenValue::Length(length) => {
410                        if length.is_theme_token() {
411                            return Err(ThemeError::NestedNamespacedLength {
412                                namespace: namespace.clone(),
413                                name: name.clone(),
414                            });
415                        }
416                        length
417                            .validate()
418                            .map_err(|source| ThemeError::InvalidLength {
419                                token: format!("{namespace}.{name}"),
420                                source,
421                            })?;
422                    }
423                    ThemeTokenValue::Number(number) if !number.is_finite() => {
424                        return Err(ThemeError::NonFiniteNumber {
425                            namespace: namespace.clone(),
426                            name: name.clone(),
427                        });
428                    }
429                    ThemeTokenValue::Color(_)
430                    | ThemeTokenValue::Number(_)
431                    | ThemeTokenValue::String(_) => {}
432                }
433            }
434        }
435        Ok(())
436    }
437
438    #[must_use]
439    pub fn token(&self, path: &str) -> Option<&ThemeTokenValue> {
440        let (namespace, name) = path.split_once('.')?;
441        self.namespaces.get(namespace)?.get(name)
442    }
443
444    #[must_use]
445    pub fn color(&self, token: &str) -> Option<Rgba8> {
446        self.colors
447            .get(token)
448            .copied()
449            .or_else(|| match self.token(token) {
450                Some(ThemeTokenValue::Color(color)) => Some(*color),
451                _ => None,
452            })
453    }
454}
455
456impl ThemeTypography {
457    /// Validate the shared family/fallback stack and every required type role.
458    ///
459    /// # Errors
460    ///
461    /// Returns [`ThemeError`] for missing roles or invalid font metrics.
462    pub fn validate(&self) -> Result<(), ThemeError> {
463        require_tokens("typography", REQUIRED_TYPOGRAPHY, &self.roles)?;
464        if self
465            .family
466            .as_ref()
467            .is_some_and(|family| !valid_font_family(family))
468        {
469            return Err(ThemeError::InvalidTypographyFamily);
470        }
471        let mut families = std::collections::BTreeSet::new();
472        for family in &self.fallbacks {
473            if !valid_font_family(family) || !families.insert(family) {
474                return Err(ThemeError::InvalidTypographyFallbacks);
475            }
476        }
477        if self
478            .family
479            .as_ref()
480            .is_some_and(|family| families.contains(family))
481        {
482            return Err(ThemeError::InvalidTypographyFallbacks);
483        }
484        for (role, token) in &self.roles {
485            if !REQUIRED_TYPOGRAPHY.contains(&role.as_str()) {
486                return Err(ThemeError::UnknownTypographyRole(role.clone()));
487            }
488            validate_typography_length(role, "size", token.size)?;
489            validate_typography_length(role, "line_height", token.line_height)?;
490            if !(1..=1_000).contains(&token.weight) {
491                return Err(ThemeError::InvalidTypographyWeight {
492                    role: role.clone(),
493                    weight: token.weight,
494                });
495            }
496            match (token.size, token.line_height) {
497                (Length::Pixels(size), Length::Pixels(line_height))
498                | (Length::Rems(size), Length::Rems(line_height))
499                    if line_height < size =>
500                {
501                    return Err(ThemeError::InvalidTypographyLineHeight(role.clone()));
502                }
503                _ => {}
504            }
505        }
506        Ok(())
507    }
508
509    #[must_use]
510    pub fn resolve(&self, role: &str) -> Option<ResolvedTypography> {
511        let token = self.roles.get(role)?;
512        Some(ResolvedTypography {
513            family: self.family.clone(),
514            fallbacks: self.fallbacks.clone(),
515            size: token.size,
516            line_height: token.line_height,
517            weight: token.weight,
518        })
519    }
520}
521
522fn valid_font_family(family: &str) -> bool {
523    let trimmed = family.trim();
524    !trimmed.is_empty() && trimmed.len() <= 256
525}
526
527fn validate_typography_length(
528    role: &str,
529    field: &'static str,
530    value: Length,
531) -> Result<(), ThemeError> {
532    let positive = match value {
533        Length::Pixels(value) | Length::Rems(value) => value.is_finite() && value > 0.0,
534        Length::Relative(_) | Length::ThemeSpacing(_) | Length::ThemeRadius(_) => false,
535    };
536    if positive {
537        Ok(())
538    } else {
539        Err(ThemeError::InvalidTypographyLength {
540            role: role.to_owned(),
541            field,
542        })
543    }
544}
545
546fn valid_token_segment(value: &str) -> bool {
547    !value.is_empty()
548        && !value.starts_with('_')
549        && !value.ends_with('_')
550        && !value.contains("__")
551        && value.chars().all(|character| {
552            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
553        })
554}
555
556fn require_tokens<T>(
557    category: &'static str,
558    required: &[&str],
559    actual: &BTreeMap<String, T>,
560) -> Result<(), ThemeError> {
561    let missing = required
562        .iter()
563        .filter(|name| !actual.contains_key(**name))
564        .map(ToString::to_string)
565        .collect::<Vec<_>>();
566    if missing.is_empty() {
567        Ok(())
568    } else {
569        Err(ThemeError::MissingTokens { category, missing })
570    }
571}
572
573#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
574pub struct ThemeVariant {
575    pub family: String,
576    pub name: String,
577    pub mode: ThemeMode,
578    pub tokens: ThemeTokens,
579}
580
581/// Owned, host-readable snapshot of the effective theme for one mounted view.
582///
583/// `revision` advances whenever the resolved variant changes, including a
584/// system light/dark transition. The complete semantic token table is available
585/// through [`Self::variant`].
586#[derive(Clone, Debug, PartialEq)]
587pub struct ThemeSnapshot {
588    pub revision: u64,
589    pub variant: ThemeVariant,
590}
591
592impl ThemeSnapshot {
593    pub(crate) const fn new(revision: u64, variant: ThemeVariant) -> Self {
594        Self { revision, variant }
595    }
596}
597
598impl ThemeVariant {
599    /// Validate identity and semantic tokens.
600    ///
601    /// # Errors
602    ///
603    /// Returns [`ThemeError`] for empty names or an invalid token set.
604    pub fn validate(&self) -> Result<(), ThemeError> {
605        if self.family.trim().is_empty() || self.name.trim().is_empty() {
606            return Err(ThemeError::EmptyName);
607        }
608        self.tokens.validate()
609    }
610
611    #[must_use]
612    pub fn typography(&self, role: &str) -> Option<ResolvedTypography> {
613        self.tokens.typography.resolve(role)
614    }
615}
616
617impl ThemeTokenOverrides {
618    fn merge_into(&self, variant: &mut ThemeVariant) {
619        variant.tokens.colors.extend(self.colors.clone());
620        variant.tokens.spacing.extend(self.spacing.clone());
621        variant.tokens.radii.extend(self.radii.clone());
622        if let Some(family) = &self.typography.family {
623            variant.tokens.typography.family = Some(family.clone());
624        }
625        if let Some(fallbacks) = &self.typography.fallbacks {
626            variant.tokens.typography.fallbacks.clone_from(fallbacks);
627        }
628        variant
629            .tokens
630            .typography
631            .roles
632            .extend(self.typography.roles.clone());
633        variant
634            .tokens
635            .motion
636            .durations_ms
637            .extend(self.motion.durations_ms.clone());
638        variant
639            .tokens
640            .motion
641            .easings
642            .extend(self.motion.easings.clone());
643        variant
644            .tokens
645            .motion
646            .springs
647            .extend(self.motion.springs.clone());
648        variant
649            .tokens
650            .motion
651            .distances
652            .extend(self.motion.distances.clone());
653        variant
654            .tokens
655            .motion
656            .staggers_ms
657            .extend(self.motion.staggers_ms.clone());
658        for (namespace, tokens) in &self.namespaces {
659            variant
660                .tokens
661                .namespaces
662                .entry(namespace.clone())
663                .or_default()
664                .extend(tokens.clone());
665        }
666    }
667
668    #[must_use]
669    pub fn is_empty(&self) -> bool {
670        self.colors.is_empty()
671            && self.spacing.is_empty()
672            && self.radii.is_empty()
673            && self.typography == ThemeTypographyOverrides::default()
674            && self.motion == ThemeMotionOverrides::default()
675            && self.namespaces.is_empty()
676    }
677}
678
679impl ColorResolver for ThemeVariant {
680    fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
681        match color {
682            ColorValue::Literal(color) => Some(*color),
683            ColorValue::Token(token) => self.tokens.color(token),
684        }
685    }
686
687    fn resolve_length(&self, length: Length) -> Option<Length> {
688        match length {
689            Length::ThemeSpacing(token) => self.tokens.spacing.get(token.as_str()).copied(),
690            Length::ThemeRadius(token) => self.tokens.radii.get(token.as_str()).copied(),
691            Length::Pixels(_) | Length::Rems(_) | Length::Relative(_) => Some(length),
692        }
693    }
694
695    fn color_snapshot(&self) -> BTreeMap<String, Rgba8> {
696        let mut colors = self.tokens.colors.clone();
697        for (namespace, values) in &self.tokens.namespaces {
698            for (name, value) in values {
699                if let ThemeTokenValue::Color(color) = value {
700                    colors.insert(format!("{namespace}.{name}"), *color);
701                }
702            }
703        }
704        colors
705    }
706
707    fn resolve_typography(&self, role: &str) -> Option<ResolvedTypography> {
708        self.typography(role)
709    }
710
711    fn resolve_motion(&self) -> ThemeMotion {
712        self.tokens.motion.clone()
713    }
714}
715
716#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
717pub struct ThemeFamily {
718    pub name: String,
719    pub variants: BTreeMap<String, ThemeVariant>,
720    pub default_light: String,
721    pub default_dark: String,
722}
723
724impl ThemeFamily {
725    /// Validate variants and system-mode defaults.
726    ///
727    /// # Errors
728    ///
729    /// Returns [`ThemeError`] when variants disagree on family, map keys do not
730    /// match variant names, or defaults are absent/wrong-mode.
731    pub fn validate(&self) -> Result<(), ThemeError> {
732        if self.name.trim().is_empty() {
733            return Err(ThemeError::EmptyName);
734        }
735        for (key, variant) in &self.variants {
736            variant.validate()?;
737            if variant.family != self.name || &variant.name != key {
738                return Err(ThemeError::VariantIdentity {
739                    family: self.name.clone(),
740                    key: key.clone(),
741                });
742            }
743        }
744        self.require_default(&self.default_light, ThemeMode::Light)?;
745        self.require_default(&self.default_dark, ThemeMode::Dark)?;
746        Ok(())
747    }
748
749    fn require_default(&self, name: &str, mode: ThemeMode) -> Result<(), ThemeError> {
750        let variant = self
751            .variants
752            .get(name)
753            .ok_or_else(|| ThemeError::MissingDefault {
754                family: self.name.clone(),
755                variant: name.to_owned(),
756            })?;
757        let family_supports_mode = self
758            .variants
759            .values()
760            .any(|candidate| candidate.mode == mode);
761        if variant.mode == mode || !family_supports_mode {
762            Ok(())
763        } else {
764            Err(ThemeError::WrongDefaultMode {
765                family: self.name.clone(),
766                variant: name.to_owned(),
767                expected: mode,
768            })
769        }
770    }
771}
772
773#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
774pub struct ThemeSelection {
775    pub family: String,
776    pub variant: String,
777}
778
779impl ThemeSelection {
780    #[must_use]
781    pub fn new(family: impl Into<String>, variant: impl Into<String>) -> Self {
782        Self {
783            family: family.into(),
784            variant: variant.into(),
785        }
786    }
787}
788
789#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
790#[serde(tag = "preference", rename_all = "snake_case")]
791pub enum ThemePreference {
792    Fixed { selection: ThemeSelection },
793    System { family: String },
794}
795
796#[derive(Clone, Copy, Debug, Eq, PartialEq)]
797pub enum SystemAppearance {
798    Light,
799    Dark,
800}
801
802#[derive(Clone, Debug)]
803pub struct ThemeManager {
804    families: BTreeMap<String, ThemeFamily>,
805    app: ThemePreference,
806    windows: BTreeMap<String, ThemePreference>,
807    scopes: BTreeMap<ComponentInstancePath, ThemePreference>,
808    generation: u64,
809}
810
811impl ThemeManager {
812    /// Build families from independent variants and select one fixed variant.
813    /// Families that only provide one color mode use their first variant as the
814    /// fallback for the unavailable system appearance.
815    ///
816    /// # Errors
817    ///
818    /// Returns [`ThemeError`] for duplicates, invalid variants, or an unknown
819    /// initial selection.
820    pub fn from_variants(
821        variants: impl IntoIterator<Item = ThemeVariant>,
822        selection: ThemeSelection,
823    ) -> Result<Self, ThemeError> {
824        let mut grouped = BTreeMap::<String, BTreeMap<String, ThemeVariant>>::new();
825        for mut variant in variants {
826            variant.tokens.install_document_defaults();
827            variant.validate()?;
828            let family = grouped.entry(variant.family.clone()).or_default();
829            if family
830                .insert(variant.name.clone(), variant.clone())
831                .is_some()
832            {
833                return Err(ThemeError::DuplicateVariant {
834                    family: variant.family,
835                    variant: variant.name,
836                });
837            }
838        }
839        let families = grouped
840            .into_iter()
841            .map(|(name, variants)| {
842                let fallback = variants
843                    .keys()
844                    .next()
845                    .cloned()
846                    .ok_or(ThemeError::NoVariants)?;
847                let default_light = variants
848                    .values()
849                    .find(|variant| variant.mode == ThemeMode::Light)
850                    .map_or_else(|| fallback.clone(), |variant| variant.name.clone());
851                let default_dark = variants
852                    .values()
853                    .find(|variant| variant.mode == ThemeMode::Dark)
854                    .map(|variant| variant.name.clone())
855                    .unwrap_or(fallback);
856                Ok(ThemeFamily {
857                    name,
858                    variants,
859                    default_light,
860                    default_dark,
861                })
862            })
863            .collect::<Result<Vec<_>, ThemeError>>()?;
864        Self::new(families, ThemePreference::Fixed { selection })
865    }
866
867    /// Create a manager with a validated app preference.
868    ///
869    /// # Errors
870    ///
871    /// Returns [`ThemeError`] if the preference cannot resolve in the supplied
872    /// family set.
873    pub fn new(
874        families: impl IntoIterator<Item = ThemeFamily>,
875        app: ThemePreference,
876    ) -> Result<Self, ThemeError> {
877        let mut manager = Self {
878            families: BTreeMap::new(),
879            app,
880            windows: BTreeMap::new(),
881            scopes: BTreeMap::new(),
882            generation: 1,
883        };
884        for family in families {
885            manager.register_family(family)?;
886        }
887        manager.validate_preference(&manager.app)?;
888        Ok(manager)
889    }
890
891    /// Add a validated theme family.
892    ///
893    /// # Errors
894    ///
895    /// Returns [`ThemeError`] for invalid or duplicate families.
896    pub fn register_family(&mut self, mut family: ThemeFamily) -> Result<(), ThemeError> {
897        for variant in family.variants.values_mut() {
898            variant.tokens.install_document_defaults();
899        }
900        family.validate()?;
901        if self.families.contains_key(&family.name) {
902            return Err(ThemeError::DuplicateFamily(family.name));
903        }
904        self.families.insert(family.name.clone(), family);
905        Ok(())
906    }
907
908    /// Replace one already-registered variant and advance the theme generation.
909    ///
910    /// This is the host-facing path for live theme editors. Identity cannot be
911    /// changed in place; callers create a new manager when families are added or
912    /// removed.
913    ///
914    /// # Errors
915    ///
916    /// Returns validation or unknown-selection errors.
917    pub fn replace_variant(&mut self, mut variant: ThemeVariant) -> Result<(), ThemeError> {
918        variant.tokens.install_document_defaults();
919        variant.validate()?;
920        let selection = ThemeSelection::new(variant.family.clone(), variant.name.clone());
921        let family = self
922            .families
923            .get_mut(&variant.family)
924            .ok_or_else(|| ThemeError::UnknownSelection(selection.clone()))?;
925        if !family.variants.contains_key(&variant.name) {
926            return Err(ThemeError::UnknownSelection(selection));
927        }
928        family.variants.insert(variant.name.clone(), variant);
929        let fallback = family
930            .variants
931            .keys()
932            .next()
933            .cloned()
934            .ok_or(ThemeError::NoVariants)?;
935        family.default_light = family
936            .variants
937            .values()
938            .find(|candidate| candidate.mode == ThemeMode::Light)
939            .map_or_else(|| fallback.clone(), |candidate| candidate.name.clone());
940        family.default_dark = family
941            .variants
942            .values()
943            .find(|candidate| candidate.mode == ThemeMode::Dark)
944            .map_or(fallback, |candidate| candidate.name.clone());
945        family.validate()?;
946        self.generation = self.generation.saturating_add(1);
947        Ok(())
948    }
949
950    /// Change the application fallback preference.
951    ///
952    /// # Errors
953    ///
954    /// Returns [`ThemeError`] when the selection cannot resolve.
955    pub fn set_app(&mut self, preference: ThemePreference) -> Result<(), ThemeError> {
956        self.validate_preference(&preference)?;
957        if self.app != preference {
958            self.app = preference;
959            self.generation = self.generation.saturating_add(1);
960        }
961        Ok(())
962    }
963
964    /// Set a per-window theme preference.
965    ///
966    /// # Errors
967    ///
968    /// Returns [`ThemeError`] when the preference cannot resolve.
969    pub fn set_window(
970        &mut self,
971        window: impl Into<String>,
972        preference: ThemePreference,
973    ) -> Result<(), ThemeError> {
974        self.validate_preference(&preference)?;
975        self.windows.insert(window.into(), preference);
976        self.generation = self.generation.saturating_add(1);
977        Ok(())
978    }
979
980    pub fn remove_window(&mut self, window: &str) -> bool {
981        let removed = self.windows.remove(window).is_some();
982        if removed {
983            self.generation = self.generation.saturating_add(1);
984        }
985        removed
986    }
987
988    pub fn remove_scope(&mut self, scope: &ComponentInstancePath) -> bool {
989        let previous = self.scopes.len();
990        self.scopes.retain(|path, _| !path.is_within(scope));
991        let removed = self.scopes.len() != previous;
992        if removed {
993            self.generation = self.generation.saturating_add(1);
994        }
995        removed
996    }
997
998    /// Set a local theme preference for a component subtree.
999    ///
1000    /// # Errors
1001    ///
1002    /// Returns [`ThemeError`] when the preference cannot resolve.
1003    pub fn set_scope(
1004        &mut self,
1005        scope: ComponentInstancePath,
1006        preference: ThemePreference,
1007    ) -> Result<(), ThemeError> {
1008        self.validate_preference(&preference)?;
1009        self.scopes.insert(scope, preference);
1010        self.generation = self.generation.saturating_add(1);
1011        Ok(())
1012    }
1013
1014    #[must_use]
1015    pub const fn generation(&self) -> u64 {
1016        self.generation
1017    }
1018
1019    #[must_use]
1020    pub fn app_preference(&self) -> &ThemePreference {
1021        &self.app
1022    }
1023
1024    /// Resolve the nearest active theme without changing scripts or UI state.
1025    ///
1026    /// # Errors
1027    ///
1028    /// Returns [`ThemeError`] if an internally stored preference no longer
1029    /// resolves after malformed deserialization.
1030    pub fn resolve(
1031        &self,
1032        window: Option<&str>,
1033        component: Option<&ComponentInstancePath>,
1034        system: SystemAppearance,
1035    ) -> Result<ResolvedTheme<'_>, ThemeError> {
1036        let preference = component
1037            .and_then(|component| self.nearest_scope(component))
1038            .or_else(|| window.and_then(|window| self.windows.get(window)))
1039            .unwrap_or(&self.app);
1040        let selection = self.selection_for(preference, system)?;
1041        let variant = self
1042            .families
1043            .get(&selection.family)
1044            .and_then(|family| family.variants.get(&selection.variant))
1045            .ok_or_else(|| ThemeError::UnknownSelection(selection.clone()))?;
1046        Ok(ResolvedTheme { variant })
1047    }
1048
1049    fn nearest_scope(&self, component: &ComponentInstancePath) -> Option<&ThemePreference> {
1050        let mut current = Some(component.clone());
1051        while let Some(path) = current {
1052            if let Some(preference) = self.scopes.get(&path) {
1053                return Some(preference);
1054            }
1055            current = path.parent();
1056        }
1057        None
1058    }
1059
1060    fn validate_preference(&self, preference: &ThemePreference) -> Result<(), ThemeError> {
1061        self.selection_for(preference, SystemAppearance::Light)?;
1062        self.selection_for(preference, SystemAppearance::Dark)?;
1063        Ok(())
1064    }
1065
1066    fn selection_for(
1067        &self,
1068        preference: &ThemePreference,
1069        system: SystemAppearance,
1070    ) -> Result<ThemeSelection, ThemeError> {
1071        match preference {
1072            ThemePreference::Fixed { selection } => {
1073                let exists = self
1074                    .families
1075                    .get(&selection.family)
1076                    .is_some_and(|family| family.variants.contains_key(&selection.variant));
1077                if exists {
1078                    Ok(selection.clone())
1079                } else {
1080                    Err(ThemeError::UnknownSelection(selection.clone()))
1081                }
1082            }
1083            ThemePreference::System { family } => {
1084                let family = self
1085                    .families
1086                    .get(family)
1087                    .ok_or_else(|| ThemeError::UnknownFamily(family.clone()))?;
1088                Ok(ThemeSelection::new(
1089                    family.name.clone(),
1090                    match system {
1091                        SystemAppearance::Light => family.default_light.clone(),
1092                        SystemAppearance::Dark => family.default_dark.clone(),
1093                    },
1094                ))
1095            }
1096        }
1097    }
1098}
1099
1100pub struct ResolvedTheme<'a> {
1101    variant: &'a ThemeVariant,
1102}
1103
1104impl ResolvedTheme<'_> {
1105    #[must_use]
1106    pub fn variant(&self) -> &ThemeVariant {
1107        self.variant
1108    }
1109}
1110
1111impl ColorResolver for ResolvedTheme<'_> {
1112    fn resolve(&self, color: &ColorValue) -> Option<Rgba8> {
1113        match color {
1114            ColorValue::Literal(color) => Some(*color),
1115            ColorValue::Token(token) => self.variant.tokens.color(token),
1116        }
1117    }
1118
1119    fn resolve_length(&self, length: Length) -> Option<Length> {
1120        self.variant.resolve_length(length)
1121    }
1122
1123    fn resolve_typography(&self, role: &str) -> Option<ResolvedTypography> {
1124        self.variant.typography(role)
1125    }
1126
1127    fn resolve_motion(&self) -> ThemeMotion {
1128        self.variant.tokens.motion.clone()
1129    }
1130}
1131
1132/// Compile and evaluate a Rhai theme source exporting `theme() -> map`.
1133///
1134/// # Errors
1135///
1136/// Returns [`ThemeError`] for compilation, evaluation, decoding, or semantic
1137/// token validation failures.
1138pub fn load_theme_source(
1139    engine: &Engine,
1140    source_name: &str,
1141    source: &str,
1142) -> Result<ThemeVariant, ThemeError> {
1143    let mut theme = decode_theme_source(engine, source_name, source)?;
1144    theme.tokens.install_document_defaults();
1145    theme.validate()?;
1146    Ok(theme)
1147}
1148
1149pub(crate) fn load_theme_source_with_overrides(
1150    engine: &Engine,
1151    source_name: &str,
1152    source: &str,
1153    overrides: &ThemeTokenOverrides,
1154) -> Result<ThemeVariant, ThemeError> {
1155    let theme = decode_theme_source(engine, source_name, source)?;
1156    let mut original = theme.clone();
1157    original.tokens.install_document_defaults();
1158    original.validate()?;
1159    let mut candidate = theme;
1160    overrides.merge_into(&mut candidate);
1161    candidate.tokens.install_document_defaults();
1162    candidate.validate()?;
1163    Ok(candidate)
1164}
1165
1166fn decode_theme_source(
1167    engine: &Engine,
1168    source_name: &str,
1169    source: &str,
1170) -> Result<ThemeVariant, ThemeError> {
1171    let mut ast = engine
1172        .compile(source)
1173        .map_err(|error| ThemeError::Script(error.to_string()))?;
1174    crate::engine::validate_assignment_targets(&ast)
1175        .map_err(|error| ThemeError::Script(error.to_string()))?;
1176    ast.set_source(source_name);
1177    let raw: Dynamic = engine
1178        .call_fn(&mut Scope::new(), &ast, "theme", ())
1179        .map_err(|error| ThemeError::Script(error.to_string()))?;
1180    rhai::serde::from_dynamic::<ThemeVariant>(&raw)
1181        .map_err(|error| ThemeError::Decode(error.to_string()))
1182}
1183
1184const fn with_alpha(color: Rgba8, alpha: u8) -> Rgba8 {
1185    Rgba8::from_rgba_hex((color.as_rgba_hex() & 0xffff_ff00) | alpha as u32)
1186}
1187
1188const fn mix_opaque(background: Rgba8, foreground: Rgba8, weight: u8) -> Rgba8 {
1189    let background = background.as_rgba_hex();
1190    let foreground = foreground.as_rgba_hex();
1191    let inverse = 255_u32 - weight as u32;
1192    let weight = weight as u32;
1193    Rgba8::from_rgba_hex(
1194        (mix_channel(background, foreground, inverse, weight, 24) << 24)
1195            | (mix_channel(background, foreground, inverse, weight, 16) << 16)
1196            | (mix_channel(background, foreground, inverse, weight, 8) << 8)
1197            | 0xff,
1198    )
1199}
1200
1201const fn mix_channel(
1202    background: u32,
1203    foreground: u32,
1204    inverse: u32,
1205    weight: u32,
1206    shift: u32,
1207) -> u32 {
1208    let back = (background >> shift) & 0xff;
1209    let front = (foreground >> shift) & 0xff;
1210    (back * inverse + front * weight + 127) / 255
1211}
1212
1213fn readable_secondary(muted: Rgba8, primary: Rgba8, surface: Rgba8) -> Rgba8 {
1214    if contrast_ratio(muted, surface) >= 4.5 {
1215        return muted;
1216    }
1217    for step in 1_u8..=16 {
1218        let weight = u8::try_from(u16::from(step) * 255 / 16).unwrap_or(255);
1219        let candidate = mix_opaque(muted, primary, weight);
1220        if contrast_ratio(candidate, surface) >= 4.5 {
1221            return candidate;
1222        }
1223    }
1224    primary
1225}
1226
1227fn contrast_ratio(left: Rgba8, right: Rgba8) -> f64 {
1228    let left = relative_luminance(left);
1229    let right = relative_luminance(right);
1230    (left.max(right) + 0.05) / (left.min(right) + 0.05)
1231}
1232
1233fn relative_luminance(color: Rgba8) -> f64 {
1234    let color = color.as_rgba_hex();
1235    let channel = |shift| {
1236        let encoded = f64::from(((color >> shift) & 0xff_u32) as u8) / 255.0;
1237        if encoded <= 0.04045 {
1238            encoded / 12.92
1239        } else {
1240            ((encoded + 0.055) / 1.055).powf(2.4)
1241        }
1242    };
1243    0.2126 * channel(24) + 0.7152 * channel(16) + 0.0722 * channel(8)
1244}
1245
1246#[derive(Debug, Error)]
1247pub enum ThemeError {
1248    #[error("theme family and variant names cannot be empty")]
1249    EmptyName,
1250    #[error("missing {category} tokens: {missing:?}")]
1251    MissingTokens {
1252        category: &'static str,
1253        missing: Vec<String>,
1254    },
1255    #[error("length token `{token}` is invalid: {source}")]
1256    InvalidLength {
1257        token: String,
1258        source: crate::LengthError,
1259    },
1260    #[error("theme length token `{0}` cannot reference another theme token")]
1261    NestedLengthToken(String),
1262    #[error("theme token namespace `{0}` must be snake_case")]
1263    InvalidNamespace(String),
1264    #[error("theme token `{namespace}.{name}` must use a snake_case name")]
1265    InvalidTokenName { namespace: String, name: String },
1266    #[error("theme length token `{namespace}.{name}` cannot reference another theme token")]
1267    NestedNamespacedLength { namespace: String, name: String },
1268    #[error("theme number token `{namespace}.{name}` must be finite")]
1269    NonFiniteNumber { namespace: String, name: String },
1270    #[error("theme typography family must be a non-empty name no longer than 256 bytes")]
1271    InvalidTypographyFamily,
1272    #[error("theme typography fallbacks must contain unique non-empty family names")]
1273    InvalidTypographyFallbacks,
1274    #[error("unknown theme typography role `{0}`")]
1275    UnknownTypographyRole(String),
1276    #[error("theme typography `{role}.{field}` must be a positive px or rem length")]
1277    InvalidTypographyLength { role: String, field: &'static str },
1278    #[error("theme typography `{0}` line height cannot be smaller than its font size")]
1279    InvalidTypographyLineHeight(String),
1280    #[error("theme typography `{role}` weight must be between 1 and 1000, got {weight}")]
1281    InvalidTypographyWeight { role: String, weight: u16 },
1282    #[error("theme motion tokens must use positive durations and physically valid values")]
1283    InvalidMotionTokens,
1284    #[error("variant `{key}` does not match family `{family}` or its map key")]
1285    VariantIdentity { family: String, key: String },
1286    #[error("family `{family}` has no default variant `{variant}`")]
1287    MissingDefault { family: String, variant: String },
1288    #[error("family `{family}` default `{variant}` has wrong mode; expected {expected:?}")]
1289    WrongDefaultMode {
1290        family: String,
1291        variant: String,
1292        expected: ThemeMode,
1293    },
1294    #[error("theme family `{0}` is already registered")]
1295    DuplicateFamily(String),
1296    #[error("theme family `{family}` already contains variant `{variant}`")]
1297    DuplicateVariant { family: String, variant: String },
1298    #[error("at least one theme variant is required")]
1299    NoVariants,
1300    #[error("theme family `{0}` is not registered")]
1301    UnknownFamily(String),
1302    #[error("theme selection `{0:?}` does not exist")]
1303    UnknownSelection(ThemeSelection),
1304    #[error("theme script failed: {0}")]
1305    Script(String),
1306    #[error("theme source could not be decoded: {0}")]
1307    Decode(String),
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use super::*;
1313
1314    fn typography() -> ThemeTypography {
1315        ThemeTypography {
1316            family: None,
1317            fallbacks: Vec::new(),
1318            roles: BTreeMap::from([
1319                ("caption".to_owned(), type_token(11.0, 16.0, 400)),
1320                ("body_small".to_owned(), type_token(12.0, 16.0, 400)),
1321                ("body".to_owned(), type_token(13.0, 18.0, 400)),
1322                ("subtitle".to_owned(), type_token(14.0, 20.0, 400)),
1323                ("title".to_owned(), type_token(16.0, 22.0, 700)),
1324                ("heading".to_owned(), type_token(18.0, 24.0, 700)),
1325                ("display".to_owned(), type_token(24.0, 32.0, 700)),
1326                ("display_large".to_owned(), type_token(28.0, 36.0, 700)),
1327            ]),
1328        }
1329    }
1330
1331    fn type_token(size: f64, line_height: f64, weight: u16) -> TypographyToken {
1332        TypographyToken {
1333            size: Length::Pixels(size),
1334            line_height: Length::Pixels(line_height),
1335            weight,
1336        }
1337    }
1338
1339    fn tokens(accent: u32) -> ThemeTokens {
1340        ThemeTokens {
1341            colors: REQUIRED_COLORS
1342                .iter()
1343                .map(|name| {
1344                    (
1345                        (*name).to_owned(),
1346                        Rgba8::from_rgb_hex(if *name == "accent" {
1347                            accent
1348                        } else {
1349                            0x0011_1111
1350                        }),
1351                    )
1352                })
1353                .collect(),
1354            spacing: BTreeMap::from([
1355                ("xxs".to_owned(), Length::Pixels(2.0)),
1356                ("xs".to_owned(), Length::Pixels(4.0)),
1357                ("sm".to_owned(), Length::Pixels(8.0)),
1358                ("md".to_owned(), Length::Pixels(12.0)),
1359                ("lg".to_owned(), Length::Pixels(16.0)),
1360            ]),
1361            radii: BTreeMap::from([
1362                ("sm".to_owned(), Length::Pixels(4.0)),
1363                ("md".to_owned(), Length::Pixels(8.0)),
1364                ("lg".to_owned(), Length::Pixels(12.0)),
1365            ]),
1366            typography: typography(),
1367            motion: ThemeMotion::default(),
1368            namespaces: BTreeMap::new(),
1369        }
1370    }
1371
1372    fn family() -> ThemeFamily {
1373        ThemeFamily {
1374            name: "Default".to_owned(),
1375            variants: BTreeMap::from([
1376                (
1377                    "Light".to_owned(),
1378                    ThemeVariant {
1379                        family: "Default".to_owned(),
1380                        name: "Light".to_owned(),
1381                        mode: ThemeMode::Light,
1382                        tokens: tokens(0x0033_66ff),
1383                    },
1384                ),
1385                (
1386                    "Dark".to_owned(),
1387                    ThemeVariant {
1388                        family: "Default".to_owned(),
1389                        name: "Dark".to_owned(),
1390                        mode: ThemeMode::Dark,
1391                        tokens: tokens(0x0066_99ff),
1392                    },
1393                ),
1394            ]),
1395            default_light: "Light".to_owned(),
1396            default_dark: "Dark".to_owned(),
1397        }
1398    }
1399
1400    #[test]
1401    fn scope_precedes_window_and_app_preferences() {
1402        let mut manager = ThemeManager::new(
1403            [family()],
1404            ThemePreference::System {
1405                family: "Default".to_owned(),
1406            },
1407        )
1408        .unwrap();
1409        manager
1410            .set_window(
1411                "main",
1412                ThemePreference::Fixed {
1413                    selection: ThemeSelection::new("Default", "Light"),
1414                },
1415            )
1416            .unwrap();
1417        let root = ComponentInstancePath::root("App", "root");
1418        manager
1419            .set_scope(
1420                root.clone(),
1421                ThemePreference::Fixed {
1422                    selection: ThemeSelection::new("Default", "Dark"),
1423                },
1424            )
1425            .unwrap();
1426
1427        let child = root.child("Preview", "preview");
1428        assert_eq!(
1429            manager
1430                .resolve(Some("main"), Some(&child), SystemAppearance::Light)
1431                .unwrap()
1432                .variant()
1433                .name,
1434            "Dark"
1435        );
1436    }
1437
1438    #[test]
1439    fn namespaced_typed_tokens_validate_and_resolve_colors() {
1440        let mut tokens = tokens(0x0033_66ff);
1441        tokens.namespaces.insert(
1442            "charts".to_owned(),
1443            BTreeMap::from([
1444                (
1445                    "series_a".to_owned(),
1446                    ThemeTokenValue::Color(Rgba8::from_rgb_hex(0x00ff_5500)),
1447                ),
1448                (
1449                    "stroke".to_owned(),
1450                    ThemeTokenValue::Length(Length::Pixels(2.0)),
1451                ),
1452                ("muted_alpha".to_owned(), ThemeTokenValue::Number(0.6)),
1453            ]),
1454        );
1455        tokens.validate().unwrap();
1456        assert_eq!(
1457            tokens.color("charts.series_a"),
1458            Some(Rgba8::from_rgb_hex(0x00ff_5500))
1459        );
1460        tokens
1461            .namespaces
1462            .get_mut("charts")
1463            .unwrap()
1464            .insert("bad_number".to_owned(), ThemeTokenValue::Number(f64::NAN));
1465        assert!(matches!(
1466            tokens.validate(),
1467            Err(ThemeError::NonFiniteNumber { .. })
1468        ));
1469    }
1470
1471    #[test]
1472    fn document_palette_defaults_preserve_explicit_theme_tuning() {
1473        let explicit = Rgba8::from_rgb_hex(0x00ab_cdef);
1474        let mut tokens = tokens(0x0033_66ff);
1475        tokens.namespaces.insert(
1476            "syntax".to_owned(),
1477            BTreeMap::from([("keyword".to_owned(), ThemeTokenValue::Color(explicit))]),
1478        );
1479        let manager = ThemeManager::from_variants(
1480            [ThemeVariant {
1481                family: "Tuned".to_owned(),
1482                name: "Dark".to_owned(),
1483                mode: ThemeMode::Dark,
1484                tokens,
1485            }],
1486            ThemeSelection::new("Tuned", "Dark"),
1487        )
1488        .unwrap();
1489        let resolved = manager.resolve(None, None, SystemAppearance::Dark).unwrap();
1490        assert_eq!(
1491            resolved.variant().tokens.color("syntax.keyword"),
1492            Some(explicit)
1493        );
1494        assert!(resolved.variant().tokens.color("diff.left_only").is_some());
1495    }
1496
1497    #[test]
1498    fn switching_theme_only_advances_theme_generation() {
1499        let mut manager = ThemeManager::new(
1500            [family()],
1501            ThemePreference::System {
1502                family: "Default".to_owned(),
1503            },
1504        )
1505        .unwrap();
1506        let initial = manager.generation();
1507        manager
1508            .set_app(ThemePreference::Fixed {
1509                selection: ThemeSelection::new("Default", "Dark"),
1510            })
1511            .unwrap();
1512        assert_eq!(manager.generation(), initial + 1);
1513    }
1514
1515    #[test]
1516    fn replacing_a_variant_preserves_identity_and_advances_generation() {
1517        let mut manager = ThemeManager::new(
1518            [family()],
1519            ThemePreference::Fixed {
1520                selection: ThemeSelection::new("Default", "Dark"),
1521            },
1522        )
1523        .unwrap();
1524        let before = manager.generation();
1525        manager
1526            .replace_variant(ThemeVariant {
1527                family: "Default".to_owned(),
1528                name: "Dark".to_owned(),
1529                mode: ThemeMode::Dark,
1530                tokens: tokens(0x00ff_00ff),
1531            })
1532            .unwrap();
1533
1534        assert_eq!(manager.generation(), before + 1);
1535        assert_eq!(
1536            manager
1537                .resolve(None, None, SystemAppearance::Dark)
1538                .unwrap()
1539                .variant()
1540                .tokens
1541                .colors["accent"],
1542            Rgba8::from_rgb_hex(0x00ff_00ff)
1543        );
1544        assert!(matches!(
1545            manager.replace_variant(ThemeVariant {
1546                family: "Missing".to_owned(),
1547                name: "Dark".to_owned(),
1548                mode: ThemeMode::Dark,
1549                tokens: tokens(0x0000_00ff),
1550            }),
1551            Err(ThemeError::UnknownSelection(_))
1552        ));
1553    }
1554
1555    #[test]
1556    fn missing_semantic_token_is_rejected() {
1557        let mut tokens = tokens(0x0033_66ff);
1558        tokens.colors.remove("focus_ring");
1559        assert!(matches!(
1560            tokens.validate(),
1561            Err(ThemeError::MissingTokens {
1562                category: "color",
1563                ..
1564            })
1565        ));
1566    }
1567
1568    #[test]
1569    fn typography_roles_are_required_validated_and_resolved() {
1570        let mut theme_tokens = tokens(0x0033_66ff);
1571        theme_tokens.typography.family = Some("JetBrains Mono".to_owned());
1572        theme_tokens.typography.fallbacks = vec!["PingFang SC".to_owned()];
1573        let body = theme_tokens.typography.resolve("body").unwrap();
1574        assert_eq!(body.family.as_deref(), Some("JetBrains Mono"));
1575        assert_eq!(body.fallbacks, ["PingFang SC"]);
1576        assert_eq!(body.size, Length::Pixels(13.0));
1577        assert_eq!(body.line_height, Length::Pixels(18.0));
1578        assert_eq!(body.weight, 400);
1579
1580        theme_tokens.typography.roles.remove("caption");
1581        assert!(matches!(
1582            theme_tokens.validate(),
1583            Err(ThemeError::MissingTokens {
1584                category: "typography",
1585                ..
1586            })
1587        ));
1588
1589        let mut invalid = tokens(0x0033_66ff);
1590        invalid
1591            .typography
1592            .roles
1593            .insert("body".to_owned(), type_token(16.0, 12.0, 400));
1594        assert!(matches!(
1595            invalid.validate(),
1596            Err(ThemeError::InvalidTypographyLineHeight(role)) if role == "body"
1597        ));
1598
1599        let mut invalid = tokens(0x0033_66ff);
1600        invalid
1601            .typography
1602            .roles
1603            .insert("bodyish".to_owned(), type_token(12.0, 16.0, 400));
1604        assert!(matches!(
1605            invalid.validate(),
1606            Err(ThemeError::UnknownTypographyRole(role)) if role == "bodyish"
1607        ));
1608    }
1609
1610    #[test]
1611    fn semantic_motion_tokens_default_validate_and_reject_invalid_physics() {
1612        let mut motion = ThemeMotion::default();
1613        motion.validate().unwrap();
1614        assert_eq!(motion.durations_ms["normal"], 180);
1615        motion.springs.get_mut("responsive").unwrap().mass = 0.0;
1616        assert!(matches!(
1617            motion.validate(),
1618            Err(ThemeError::InvalidMotionTokens)
1619        ));
1620    }
1621
1622    #[test]
1623    fn semantic_spacing_and_radius_lengths_resolve_without_recursion() {
1624        let mut theme_family = family();
1625        let variant = theme_family.variants.remove("Dark").unwrap();
1626        assert_eq!(
1627            variant.resolve_length(Length::ThemeSpacing(crate::SpacingToken::Sm)),
1628            Some(Length::Pixels(8.0))
1629        );
1630        assert_eq!(
1631            variant.resolve_length(Length::ThemeRadius(crate::RadiusToken::Md)),
1632            Some(Length::Pixels(8.0))
1633        );
1634        let mut invalid = variant.tokens;
1635        invalid.spacing.insert(
1636            "sm".to_owned(),
1637            Length::ThemeSpacing(crate::SpacingToken::Sm),
1638        );
1639        assert!(matches!(
1640            invalid.validate(),
1641            Err(ThemeError::NestedLengthToken(token)) if token == "sm"
1642        ));
1643    }
1644
1645    #[test]
1646    fn host_token_overrides_are_partial_merged_and_validated() {
1647        let mut variant = ThemeVariant {
1648            family: "Default".to_owned(),
1649            name: "Dark".to_owned(),
1650            mode: ThemeMode::Dark,
1651            tokens: tokens(0x0066_99ff),
1652        };
1653        let original_surface = variant.tokens.colors["surface"];
1654        let overrides = ThemeTokenOverrides {
1655            colors: BTreeMap::from([("accent".to_owned(), Rgba8::from_rgb_hex(0xff00_99ff))]),
1656            radii: BTreeMap::from([
1657                ("sm".to_owned(), Length::Pixels(3.0)),
1658                ("md".to_owned(), Length::Pixels(6.0)),
1659                ("lg".to_owned(), Length::Pixels(9.0)),
1660            ]),
1661            typography: ThemeTypographyOverrides {
1662                family: Some("Host Sans".to_owned()),
1663                roles: BTreeMap::from([("body".to_owned(), type_token(15.0, 21.0, 500))]),
1664                ..ThemeTypographyOverrides::default()
1665            },
1666            motion: ThemeMotionOverrides {
1667                durations_ms: BTreeMap::from([("normal".to_owned(), 240)]),
1668                ..ThemeMotionOverrides::default()
1669            },
1670            namespaces: BTreeMap::from([(
1671                "charts".to_owned(),
1672                BTreeMap::from([(
1673                    "axis".to_owned(),
1674                    ThemeTokenValue::Color(Rgba8::from_rgb_hex(0x7788_99ff)),
1675                )]),
1676            )]),
1677            ..ThemeTokenOverrides::default()
1678        };
1679
1680        overrides.merge_into(&mut variant);
1681        variant.validate().unwrap();
1682
1683        assert_eq!(variant.tokens.colors["surface"], original_surface);
1684        assert_eq!(
1685            variant.tokens.colors["accent"],
1686            Rgba8::from_rgb_hex(0xff00_99ff)
1687        );
1688        assert_eq!(variant.tokens.radii["md"], Length::Pixels(6.0));
1689        assert_eq!(
1690            variant.tokens.typography.family.as_deref(),
1691            Some("Host Sans")
1692        );
1693        assert_eq!(
1694            variant.tokens.typography.roles["body"],
1695            type_token(15.0, 21.0, 500)
1696        );
1697        assert_eq!(variant.tokens.motion.durations_ms["normal"], 240);
1698        assert_eq!(
1699            variant.tokens.token("charts.axis"),
1700            Some(&ThemeTokenValue::Color(Rgba8::from_rgb_hex(0x7788_99ff)))
1701        );
1702    }
1703
1704    #[test]
1705    fn invalid_host_token_override_rejects_the_candidate_theme() {
1706        let variant = ThemeVariant {
1707            family: "Default".to_owned(),
1708            name: "Dark".to_owned(),
1709            mode: ThemeMode::Dark,
1710            tokens: tokens(0x0066_99ff),
1711        };
1712        let overrides = ThemeTokenOverrides {
1713            radii: BTreeMap::from([("md".to_owned(), Length::Pixels(f64::NAN))]),
1714            ..ThemeTokenOverrides::default()
1715        };
1716
1717        let mut candidate = variant;
1718        overrides.merge_into(&mut candidate);
1719        assert!(matches!(
1720            candidate.validate(),
1721            Err(ThemeError::InvalidLength { token, .. }) if token == "md"
1722        ));
1723    }
1724
1725    #[test]
1726    fn rhai_theme_source_is_typed_and_validated() {
1727        let theme = ThemeVariant {
1728            family: "Default".to_owned(),
1729            name: "Dark".to_owned(),
1730            mode: ThemeMode::Dark,
1731            tokens: tokens(0x0066_99ff),
1732        };
1733        let dynamic = rhai::serde::to_dynamic(theme.clone()).unwrap();
1734        let mut scope = Scope::new();
1735        scope.push_dynamic("THEME", dynamic);
1736        let engine = Engine::new();
1737        let ast = engine
1738            .compile_with_scope(&scope, "fn theme() { THEME }")
1739            .unwrap();
1740        let raw: Dynamic = engine.call_fn(&mut scope, &ast, "theme", ()).unwrap();
1741        let decoded: ThemeVariant = rhai::serde::from_dynamic(&raw).unwrap();
1742        decoded.validate().unwrap();
1743        assert_eq!(decoded, theme);
1744    }
1745}