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#[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#[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#[derive(Clone, Debug, Default, PartialEq)]
91pub struct ThemeTypographyOverrides {
92 pub family: Option<String>,
94 pub fallbacks: Option<Vec<String>>,
96 pub roles: BTreeMap<String, TypographyToken>,
98}
99
100#[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 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 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 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#[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 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 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 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 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 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 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 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 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 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 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
1132pub 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}