1use std::{collections::BTreeMap, fmt, sync::OnceLock};
2
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6mod integration;
7pub use integration::*;
8
9pub const DEFAULT_THEME_RUNTIME_BASE_PATH: &str = "/assets/dioprism-theme.js";
10pub const DEFAULT_THEME_RUNTIME_VERSION: &str = "1";
11pub const DEFAULT_THEME_RUNTIME_PATH: &str = "/assets/dioprism-theme.js?v=1";
12pub const THEME_PACKAGE_NAME: &str = "dioprism-theme";
13pub const THEME_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
14pub const DEFAULT_THEME_STORAGE_KEY: &str = "dioprism-theme";
15pub const DEFAULT_THEME_ATTRIBUTE: &str = "data-dxt-theme";
16pub const DEFAULT_THEME_TARGET: &str = "html";
17pub const DEFAULT_THEME_DURATION_MS: u32 = 220;
18pub const DEFAULT_THEME_EASING: &str = "ease-in-out";
19pub const DEFAULT_THEME_ANIMATION_STORAGE_KEY: &str = "dioprism-theme-animation";
20pub const DEFAULT_THEME_ANIMATION_SPEED: u16 = 100;
21pub const DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY: &str = "dioprism-theme-animation-speed";
22pub const MIN_THEME_ANIMATION_SPEED: u16 = 25;
23pub const MAX_THEME_ANIMATION_SPEED: u16 = 300;
24pub const THEME_TOKEN_BG: &str = "--dxt-bg";
25pub const THEME_TOKEN_FG: &str = "--dxt-fg";
26pub const THEME_TOKEN_MUTED: &str = "--dxt-muted";
27pub const THEME_TOKEN_PANEL: &str = "--dxt-panel";
28pub const THEME_TOKEN_PANEL_BORDER: &str = "--dxt-panel-border";
29pub const THEME_TOKEN_ACCENT: &str = "--dxt-accent";
30pub const THEME_TOKEN_BACKGROUND: &str = THEME_TOKEN_BG;
31pub const THEME_TOKEN_TEXT: &str = THEME_TOKEN_FG;
32pub const THEME_TOKEN_SURFACE: &str = THEME_TOKEN_PANEL;
33pub const THEME_TOKEN_SURFACE_BORDER: &str = THEME_TOKEN_PANEL_BORDER;
34pub const THEME_CHANGE_EVENT: &str = "dioprism-theme:change";
35pub const THEME_VISUAL_TOKEN_MANIFEST_VERSION: u8 = 1;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum ThemeVisualTokenRole {
40 Background,
41 Text,
42 Muted,
43 Surface,
44 SurfaceBorder,
45 Accent,
46}
47
48impl ThemeVisualTokenRole {
49 pub const fn as_attr(self) -> &'static str {
50 match self {
51 Self::Background => "background",
52 Self::Text => "text",
53 Self::Muted => "muted",
54 Self::Surface => "surface",
55 Self::SurfaceBorder => "surface-border",
56 Self::Accent => "accent",
57 }
58 }
59
60 pub const fn js_key(self) -> &'static str {
61 match self {
62 Self::Background => "background",
63 Self::Text => "text",
64 Self::Muted => "muted",
65 Self::Surface => "surface",
66 Self::SurfaceBorder => "surfaceBorder",
67 Self::Accent => "accent",
68 }
69 }
70
71 pub const fn css_var(self) -> &'static str {
72 match self {
73 Self::Background => THEME_TOKEN_BACKGROUND,
74 Self::Text => THEME_TOKEN_TEXT,
75 Self::Muted => THEME_TOKEN_MUTED,
76 Self::Surface => THEME_TOKEN_SURFACE,
77 Self::SurfaceBorder => THEME_TOKEN_SURFACE_BORDER,
78 Self::Accent => THEME_TOKEN_ACCENT,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ThemeVisualTokenDefinition {
86 pub role: ThemeVisualTokenRole,
87 pub key: &'static str,
88 pub css_var: &'static str,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ThemeVisualTokenManifest {
94 pub version: u8,
95 pub change_event: &'static str,
96 pub tokens: &'static [ThemeVisualTokenDefinition],
97}
98
99pub const THEME_VISUAL_TOKENS: [ThemeVisualTokenDefinition; 6] = [
100 ThemeVisualTokenDefinition {
101 role: ThemeVisualTokenRole::Background,
102 key: ThemeVisualTokenRole::Background.js_key(),
103 css_var: THEME_TOKEN_BACKGROUND,
104 },
105 ThemeVisualTokenDefinition {
106 role: ThemeVisualTokenRole::Text,
107 key: ThemeVisualTokenRole::Text.js_key(),
108 css_var: THEME_TOKEN_TEXT,
109 },
110 ThemeVisualTokenDefinition {
111 role: ThemeVisualTokenRole::Muted,
112 key: ThemeVisualTokenRole::Muted.js_key(),
113 css_var: THEME_TOKEN_MUTED,
114 },
115 ThemeVisualTokenDefinition {
116 role: ThemeVisualTokenRole::Surface,
117 key: ThemeVisualTokenRole::Surface.js_key(),
118 css_var: THEME_TOKEN_SURFACE,
119 },
120 ThemeVisualTokenDefinition {
121 role: ThemeVisualTokenRole::SurfaceBorder,
122 key: ThemeVisualTokenRole::SurfaceBorder.js_key(),
123 css_var: THEME_TOKEN_SURFACE_BORDER,
124 },
125 ThemeVisualTokenDefinition {
126 role: ThemeVisualTokenRole::Accent,
127 key: ThemeVisualTokenRole::Accent.js_key(),
128 css_var: THEME_TOKEN_ACCENT,
129 },
130];
131
132pub fn theme_visual_token_css_var(alias: impl AsRef<str>) -> Option<&'static str> {
133 match alias.as_ref().trim() {
134 "background" | "bg" | "canvas" => Some(THEME_TOKEN_BACKGROUND),
135 "text" | "fg" | "foreground" => Some(THEME_TOKEN_TEXT),
136 "muted" | "subtle" => Some(THEME_TOKEN_MUTED),
137 "surface" | "panel" => Some(THEME_TOKEN_SURFACE),
138 "surface-border" | "panel-border" | "border" => Some(THEME_TOKEN_SURFACE_BORDER),
139 "accent" | "primary" => Some(THEME_TOKEN_ACCENT),
140 _ => None,
141 }
142}
143
144pub const fn theme_visual_token_manifest() -> ThemeVisualTokenManifest {
145 ThemeVisualTokenManifest {
146 version: THEME_VISUAL_TOKEN_MANIFEST_VERSION,
147 change_event: THEME_CHANGE_EVENT,
148 tokens: &THEME_VISUAL_TOKENS,
149 }
150}
151
152pub fn theme_visual_token_manifest_json() -> Result<String, serde_json::Error> {
153 static MANIFEST_JSON: OnceLock<String> = OnceLock::new();
154 Ok(MANIFEST_JSON
155 .get_or_init(|| {
156 serde_json::to_string(&theme_visual_token_manifest())
157 .expect("theme visual token manifest serializes")
158 })
159 .clone())
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164#[derive(Default)]
165pub enum ThemeColorScheme {
166 Light,
167 Dark,
168 #[default]
169 System,
170 Normal,
171}
172
173impl ThemeColorScheme {
174 pub fn as_css(self) -> &'static str {
175 match self {
176 Self::Light => "light",
177 Self::Dark => "dark",
178 Self::System => "light dark",
179 Self::Normal => "normal",
180 }
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "kebab-case")]
186#[derive(Default)]
187pub enum ThemeAnimationMode {
188 #[default]
189 ViewTransition,
190 CssOnly,
191 None,
192}
193
194impl ThemeAnimationMode {
195 pub fn as_attr(self) -> &'static str {
196 match self {
197 Self::ViewTransition => "view-transition",
198 Self::CssOnly => "css-only",
199 Self::None => "none",
200 }
201 }
202
203 pub fn is_animated(self) -> bool {
204 !matches!(self, Self::None)
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "kebab-case")]
210#[derive(Default)]
211pub enum ThemeAnimationPreset {
212 Fade,
213 #[default]
214 CrossFade,
215 Slide,
216 RadialWipe,
217 MaskedWave,
218}
219
220impl ThemeAnimationPreset {
221 pub const fn all() -> &'static [Self; 5] {
222 &[
223 Self::Fade,
224 Self::CrossFade,
225 Self::Slide,
226 Self::RadialWipe,
227 Self::MaskedWave,
228 ]
229 }
230
231 pub const fn as_attr(self) -> &'static str {
232 match self {
233 Self::Fade => "fade",
234 Self::CrossFade => "cross-fade",
235 Self::Slide => "slide",
236 Self::RadialWipe => "radial-wipe",
237 Self::MaskedWave => "masked-wave",
238 }
239 }
240
241 pub const fn label(self) -> &'static str {
242 match self {
243 Self::Fade => "Fade",
244 Self::CrossFade => "Cross fade",
245 Self::Slide => "Slide",
246 Self::RadialWipe => "Radial wipe",
247 Self::MaskedWave => "Masked wave",
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "kebab-case")]
254#[derive(Default)]
255pub enum ThemeReducedMotion {
256 #[default]
257 Respect,
258 Ignore,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum ThemeValidationSeverity {
264 Error,
265 Warning,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "kebab-case")]
270pub enum ThemeValidationCode {
271 EmptyStorageKey,
272 EmptyAnimationStorageKey,
273 EmptyAnimationSpeedStorageKey,
274 MissingDefaultTheme,
275 MissingSystemLightTheme,
276 MissingSystemDarkTheme,
277 InvalidTarget,
278 InvalidAttribute,
279 InvalidTokenName,
280 UnsafeTokenValue,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ThemeValidationIssue {
286 pub severity: ThemeValidationSeverity,
287 pub code: ThemeValidationCode,
288 pub message: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub field: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub theme: Option<String>,
293}
294
295impl ThemeValidationIssue {
296 pub fn error(
297 code: ThemeValidationCode,
298 field: impl Into<String>,
299 message: impl Into<String>,
300 ) -> Self {
301 Self {
302 severity: ThemeValidationSeverity::Error,
303 code,
304 message: message.into(),
305 field: Some(field.into()),
306 theme: None,
307 }
308 }
309
310 pub fn token_error(
311 code: ThemeValidationCode,
312 theme: impl Into<String>,
313 field: impl Into<String>,
314 message: impl Into<String>,
315 ) -> Self {
316 Self {
317 severity: ThemeValidationSeverity::Error,
318 code,
319 message: message.into(),
320 field: Some(field.into()),
321 theme: Some(theme.into()),
322 }
323 }
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct ThemeValidationReport {
329 pub issues: Vec<ThemeValidationIssue>,
330}
331
332impl ThemeValidationReport {
333 pub fn is_valid(&self) -> bool {
334 self.issues
335 .iter()
336 .all(|issue| issue.severity != ThemeValidationSeverity::Error)
337 }
338
339 pub fn errors(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
340 self.issues
341 .iter()
342 .filter(|issue| issue.severity == ThemeValidationSeverity::Error)
343 }
344
345 pub fn warnings(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
346 self.issues
347 .iter()
348 .filter(|issue| issue.severity == ThemeValidationSeverity::Warning)
349 }
350
351 pub fn push(&mut self, issue: ThemeValidationIssue) {
352 self.issues.push(issue);
353 }
354}
355
356impl ThemeReducedMotion {
357 pub fn as_attr(self) -> &'static str {
358 match self {
359 Self::Respect => "respect",
360 Self::Ignore => "ignore",
361 }
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct ThemeDefinition {
368 pub id: String,
369 pub label: String,
370 #[serde(default)]
371 pub color_scheme: ThemeColorScheme,
372 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
373 pub tokens: BTreeMap<String, String>,
374}
375
376impl ThemeDefinition {
377 pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
378 Self {
379 id: theme_id(id),
380 label: label.into(),
381 color_scheme: ThemeColorScheme::System,
382 tokens: BTreeMap::new(),
383 }
384 }
385
386 pub fn light() -> Self {
387 Self::new("light", "Light")
388 .with_color_scheme(ThemeColorScheme::Light)
389 .with_visual_token(ThemeVisualTokenRole::Background, "#f8fafc")
390 .with_visual_token(ThemeVisualTokenRole::Text, "#0f172a")
391 .with_visual_token(ThemeVisualTokenRole::Muted, "#475569")
392 .with_visual_token(ThemeVisualTokenRole::Surface, "#ffffff")
393 .with_visual_token(ThemeVisualTokenRole::SurfaceBorder, "rgba(15,23,42,0.12)")
394 .with_visual_token(ThemeVisualTokenRole::Accent, "#0891b2")
395 }
396
397 pub fn dark() -> Self {
398 Self::new("dark", "Dark")
399 .with_color_scheme(ThemeColorScheme::Dark)
400 .with_visual_token(ThemeVisualTokenRole::Background, "#020617")
401 .with_visual_token(ThemeVisualTokenRole::Text, "#f8fafc")
402 .with_visual_token(ThemeVisualTokenRole::Muted, "#cbd5e1")
403 .with_visual_token(ThemeVisualTokenRole::Surface, "rgba(15,23,42,0.74)")
404 .with_visual_token(
405 ThemeVisualTokenRole::SurfaceBorder,
406 "rgba(255,255,255,0.10)",
407 )
408 .with_visual_token(ThemeVisualTokenRole::Accent, "#22d3ee")
409 }
410
411 pub fn system() -> Self {
412 Self::new("system", "System").with_color_scheme(ThemeColorScheme::System)
413 }
414
415 pub fn with_label(mut self, label: impl Into<String>) -> Self {
416 self.label = label.into();
417 self
418 }
419
420 pub fn label(self, label: impl Into<String>) -> Self {
421 self.with_label(label)
422 }
423
424 pub fn with_color_scheme(mut self, color_scheme: ThemeColorScheme) -> Self {
425 self.color_scheme = color_scheme;
426 self
427 }
428
429 pub fn scheme(self, color_scheme: ThemeColorScheme) -> Self {
430 self.with_color_scheme(color_scheme)
431 }
432
433 pub fn with_token(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
434 let name = name.into();
435 if is_custom_property_name(&name) {
436 self.tokens.insert(name, value.into());
437 }
438 self
439 }
440
441 pub fn token(self, name: impl Into<String>, value: impl Into<String>) -> Self {
442 self.with_token(name, value)
443 }
444
445 pub fn with_visual_token(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
446 self.with_token(role.css_var(), value)
447 }
448
449 pub fn visual(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
450 self.with_visual_token(role, value)
451 }
452
453 pub fn with_visual_tokens<I, V>(mut self, tokens: I) -> Self
454 where
455 I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
456 V: Into<String>,
457 {
458 for (role, value) in tokens {
459 self = self.with_visual_token(role, value);
460 }
461 self
462 }
463
464 pub fn visuals<I, V>(self, tokens: I) -> Self
465 where
466 I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
467 V: Into<String>,
468 {
469 self.with_visual_tokens(tokens)
470 }
471
472 pub fn with_tokens<I, K, V>(mut self, tokens: I) -> Self
473 where
474 I: IntoIterator<Item = (K, V)>,
475 K: Into<String>,
476 V: Into<String>,
477 {
478 for (name, value) in tokens {
479 let name = name.into();
480 if is_custom_property_name(&name) {
481 self.tokens.insert(name, value.into());
482 }
483 }
484 self
485 }
486
487 pub fn tokens<I, K, V>(self, tokens: I) -> Self
488 where
489 I: IntoIterator<Item = (K, V)>,
490 K: Into<String>,
491 V: Into<String>,
492 {
493 self.with_tokens(tokens)
494 }
495
496 pub fn is_system(&self) -> bool {
497 self.id == "system"
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct ThemeRegistry {
504 pub themes: Vec<ThemeDefinition>,
505}
506
507impl Default for ThemeRegistry {
508 fn default() -> Self {
509 Self::defaults()
510 }
511}
512
513impl ThemeRegistry {
514 pub fn new() -> Self {
515 Self { themes: Vec::new() }
516 }
517
518 pub fn defaults() -> Self {
519 Self::new()
520 .with_theme(ThemeDefinition::light())
521 .with_theme(ThemeDefinition::dark())
522 .with_theme(ThemeDefinition::system())
523 }
524
525 pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
526 self.insert_theme(theme);
527 self
528 }
529
530 #[allow(clippy::should_implement_trait)]
531 pub fn add(self, theme: ThemeDefinition) -> Self {
532 self.with_theme(theme)
533 }
534
535 pub fn insert_theme(&mut self, theme: ThemeDefinition) -> Option<ThemeDefinition> {
536 if let Some(existing) = self
537 .themes
538 .iter_mut()
539 .find(|candidate| candidate.id == theme.id)
540 {
541 return Some(std::mem::replace(existing, theme));
542 }
543 self.themes.push(theme);
544 None
545 }
546
547 pub fn contains_theme(&self, id: impl AsRef<str>) -> bool {
548 let id = theme_id(id);
549 self.themes.iter().any(|theme| theme.id == id)
550 }
551
552 pub fn theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
553 let id = theme_id(id);
554 self.themes.iter().find(|theme| theme.id == id)
555 }
556
557 pub fn theme_ids(&self) -> Vec<&str> {
558 self.themes.iter().map(|theme| theme.id.as_str()).collect()
559 }
560
561 pub fn ids(&self) -> Vec<&str> {
562 self.theme_ids()
563 }
564
565 pub fn first_non_system_theme(&self) -> Option<&ThemeDefinition> {
566 self.themes.iter().find(|theme| !theme.is_system())
567 }
568}
569
570impl std::ops::Add<ThemeDefinition> for ThemeRegistry {
571 type Output = Self;
572
573 fn add(self, rhs: ThemeDefinition) -> Self::Output {
574 self.with_theme(rhs)
575 }
576}
577
578pub type ThemeCfg = ThemeConfig;
579pub type ThemeDef = ThemeDefinition;
580pub type ThemeReg = ThemeRegistry;
581pub type ThemeAnim = ThemeAnimationMode;
582pub type ThemePreset = ThemeAnimationPreset;
583
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct ThemeMotion {
586 pub duration_ms: Option<u32>,
587 pub easing: Option<String>,
588 pub reduced_motion: Option<ThemeReducedMotion>,
589 pub animation: Option<ThemeAnimationMode>,
590 pub preset: Option<ThemeAnimationPreset>,
591 pub speed: Option<u16>,
592}
593
594impl ThemeMotion {
595 pub fn new() -> Self {
596 Self {
597 duration_ms: None,
598 easing: None,
599 reduced_motion: None,
600 animation: None,
601 preset: None,
602 speed: None,
603 }
604 }
605
606 pub fn dur(mut self, duration: Duration) -> Self {
607 self.duration_ms = Some(duration.as_millis().min(u128::from(u32::MAX)) as u32);
608 self
609 }
610
611 pub fn dur_ms(mut self, duration_ms: u32) -> Self {
612 self.duration_ms = Some(duration_ms);
613 self
614 }
615
616 pub fn ease(mut self, easing: impl Into<String>) -> Self {
617 self.easing = Some(easing.into());
618 self
619 }
620
621 pub fn reduced(mut self, reduced_motion: ThemeReducedMotion) -> Self {
622 self.reduced_motion = Some(reduced_motion);
623 self
624 }
625
626 pub fn anim(mut self, animation: ThemeAnimationMode) -> Self {
627 self.animation = Some(animation);
628 self
629 }
630
631 pub fn preset(mut self, preset: ThemeAnimationPreset) -> Self {
632 self.preset = Some(preset);
633 self
634 }
635
636 pub fn speed(mut self, speed: u16) -> Self {
637 self.speed = Some(normalize_animation_speed(speed));
638 self
639 }
640}
641
642impl Default for ThemeMotion {
643 fn default() -> Self {
644 Self::new()
645 }
646}
647
648pub fn theme() -> ThemeConfig {
649 ThemeConfig::new()
650}
651
652pub fn theme_def(id: impl AsRef<str>, label: impl Into<String>) -> ThemeDefinition {
653 ThemeDefinition::new(id, label)
654}
655
656pub fn themes() -> ThemeRegistry {
657 ThemeRegistry::new()
658}
659
660pub fn default_themes() -> ThemeRegistry {
661 ThemeRegistry::defaults()
662}
663
664pub fn motion() -> ThemeMotion {
665 ThemeMotion::new()
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(rename_all = "camelCase")]
670pub struct ThemeConfig {
671 pub registry: ThemeRegistry,
672 pub default_theme: String,
673 pub system_light_theme: String,
674 pub system_dark_theme: String,
675 pub storage_key: String,
676 pub attribute: String,
677 pub target: String,
678 pub duration_ms: u32,
679 pub easing: String,
680 pub reduced_motion: ThemeReducedMotion,
681 pub animation: ThemeAnimationMode,
682 pub animation_preset: ThemeAnimationPreset,
683 pub animation_storage_key: String,
684 pub animation_speed: u16,
685 pub animation_speed_storage_key: String,
686 pub isolate_view_transition_names: bool,
687 pub runtime_path: String,
688}
689
690impl Default for ThemeConfig {
691 fn default() -> Self {
692 Self::new()
693 }
694}
695
696impl ThemeConfig {
697 pub fn new() -> Self {
698 Self {
699 registry: ThemeRegistry::default(),
700 default_theme: "system".to_string(),
701 system_light_theme: "light".to_string(),
702 system_dark_theme: "dark".to_string(),
703 storage_key: DEFAULT_THEME_STORAGE_KEY.to_string(),
704 attribute: DEFAULT_THEME_ATTRIBUTE.to_string(),
705 target: DEFAULT_THEME_TARGET.to_string(),
706 duration_ms: DEFAULT_THEME_DURATION_MS,
707 easing: DEFAULT_THEME_EASING.to_string(),
708 reduced_motion: ThemeReducedMotion::Respect,
709 animation: ThemeAnimationMode::ViewTransition,
710 animation_preset: ThemeAnimationPreset::CrossFade,
711 animation_storage_key: DEFAULT_THEME_ANIMATION_STORAGE_KEY.to_string(),
712 animation_speed: DEFAULT_THEME_ANIMATION_SPEED,
713 animation_speed_storage_key: DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY.to_string(),
714 isolate_view_transition_names: true,
715 runtime_path: DEFAULT_THEME_RUNTIME_PATH.to_string(),
716 }
717 }
718
719 pub fn with_registry(mut self, registry: ThemeRegistry) -> Self {
720 self.registry = registry;
721 self
722 }
723
724 pub fn registry(self, registry: ThemeRegistry) -> Self {
725 self.with_registry(registry)
726 }
727
728 pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
729 self.registry.insert_theme(theme);
730 self
731 }
732
733 pub fn theme(self, theme: ThemeDefinition) -> Self {
734 self.with_theme(theme)
735 }
736
737 #[allow(clippy::should_implement_trait)]
738 pub fn add(self, theme: ThemeDefinition) -> Self {
739 self.with_theme(theme)
740 }
741
742 pub fn with_default_theme(mut self, theme: impl AsRef<str>) -> Self {
743 self.default_theme = theme_id(theme);
744 self
745 }
746
747 pub fn default_theme(self, theme: impl AsRef<str>) -> Self {
748 self.with_default_theme(theme)
749 }
750
751 pub fn with_system_theme(
752 mut self,
753 light_theme: impl AsRef<str>,
754 dark_theme: impl AsRef<str>,
755 ) -> Self {
756 self.system_light_theme = theme_id(light_theme);
757 self.system_dark_theme = theme_id(dark_theme);
758 self
759 }
760
761 pub fn system_theme(self, light_theme: impl AsRef<str>, dark_theme: impl AsRef<str>) -> Self {
762 self.with_system_theme(light_theme, dark_theme)
763 }
764
765 pub fn with_storage_key(mut self, storage_key: impl Into<String>) -> Self {
766 self.storage_key = storage_key.into();
767 self
768 }
769
770 pub fn storage(self, storage_key: impl Into<String>) -> Self {
771 self.with_storage_key(storage_key)
772 }
773
774 pub fn with_attribute(mut self, attribute: impl Into<String>) -> Self {
775 self.attribute = attribute.into();
776 self
777 }
778
779 pub fn attr(self, attribute: impl Into<String>) -> Self {
780 self.with_attribute(attribute)
781 }
782
783 pub fn with_target(mut self, target: impl Into<String>) -> Self {
784 self.target = target.into();
785 self
786 }
787
788 pub fn target(self, target: impl Into<String>) -> Self {
789 self.with_target(target)
790 }
791
792 pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
793 self.duration_ms = duration_ms;
794 self
795 }
796
797 pub fn dur(self, duration: Duration) -> Self {
798 self.with_duration_ms(duration.as_millis().min(u128::from(u32::MAX)) as u32)
799 }
800
801 pub fn dur_ms(self, duration_ms: u32) -> Self {
802 self.with_duration_ms(duration_ms)
803 }
804
805 pub fn with_easing(mut self, easing: impl Into<String>) -> Self {
806 self.easing = easing.into();
807 self
808 }
809
810 pub fn ease(self, easing: impl Into<String>) -> Self {
811 self.with_easing(easing)
812 }
813
814 pub fn with_reduced_motion(mut self, reduced_motion: ThemeReducedMotion) -> Self {
815 self.reduced_motion = reduced_motion;
816 self
817 }
818
819 pub fn reduced(self, reduced_motion: ThemeReducedMotion) -> Self {
820 self.with_reduced_motion(reduced_motion)
821 }
822
823 #[cfg(feature = "viewtx")]
824 pub fn with_viewtx_timing(
825 mut self,
826 config: &dioprism_viewtx::core::ViewTransitionConfig,
827 ) -> Self {
828 self.duration_ms = config.duration_ms;
829 self.easing = config.easing.clone();
830 self.reduced_motion = match config.reduced_motion {
831 dioprism_viewtx::core::ViewTransitionReducedMotion::Ignore => {
832 ThemeReducedMotion::Ignore
833 }
834 dioprism_viewtx::core::ViewTransitionReducedMotion::Disable
835 | dioprism_viewtx::core::ViewTransitionReducedMotion::FadeOnly => {
836 ThemeReducedMotion::Respect
837 }
838 };
839 self
840 }
841
842 #[cfg(feature = "viewtx")]
843 pub fn with_viewtx_motion_policy(
844 mut self,
845 policy: &dioprism_viewtx::core::ViewMotionPolicy,
846 ) -> Self {
847 self.duration_ms = policy.duration_ms;
848 self.easing = policy.easing.clone();
849 self.reduced_motion = match policy.reduced_motion {
850 dioprism_viewtx::core::ViewTransitionReducedMotion::Ignore => {
851 ThemeReducedMotion::Ignore
852 }
853 dioprism_viewtx::core::ViewTransitionReducedMotion::Disable
854 | dioprism_viewtx::core::ViewTransitionReducedMotion::FadeOnly => {
855 ThemeReducedMotion::Respect
856 }
857 };
858 self.isolate_view_transition_names = policy.isolate_view_transition_names();
859 self
860 }
861
862 pub fn with_animation(mut self, animation: ThemeAnimationMode) -> Self {
863 self.animation = animation;
864 self
865 }
866
867 pub fn anim(self, animation: ThemeAnimationMode) -> Self {
868 self.with_animation(animation)
869 }
870
871 pub fn with_animation_preset(mut self, animation_preset: ThemeAnimationPreset) -> Self {
872 self.animation_preset = animation_preset;
873 self
874 }
875
876 pub fn preset(self, animation_preset: ThemeAnimationPreset) -> Self {
877 self.with_animation_preset(animation_preset)
878 }
879
880 pub fn with_animation_storage_key(mut self, animation_storage_key: impl Into<String>) -> Self {
881 self.animation_storage_key = animation_storage_key.into();
882 self
883 }
884
885 pub fn anim_storage(self, animation_storage_key: impl Into<String>) -> Self {
886 self.with_animation_storage_key(animation_storage_key)
887 }
888
889 pub fn with_animation_speed(mut self, animation_speed: u16) -> Self {
890 self.animation_speed = normalize_animation_speed(animation_speed);
891 self
892 }
893
894 pub fn speed(self, animation_speed: u16) -> Self {
895 self.with_animation_speed(animation_speed)
896 }
897
898 pub fn with_animation_speed_storage_key(
899 mut self,
900 animation_speed_storage_key: impl Into<String>,
901 ) -> Self {
902 self.animation_speed_storage_key = animation_speed_storage_key.into();
903 self
904 }
905
906 pub fn speed_storage(self, animation_speed_storage_key: impl Into<String>) -> Self {
907 self.with_animation_speed_storage_key(animation_speed_storage_key)
908 }
909
910 pub fn with_view_transition_name_isolation(mut self, isolate: bool) -> Self {
911 self.isolate_view_transition_names = isolate;
912 self
913 }
914
915 pub fn isolate_names(self, isolate: bool) -> Self {
916 self.with_view_transition_name_isolation(isolate)
917 }
918
919 pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
920 self.runtime_path = runtime_path.into();
921 self
922 }
923
924 pub fn runtime(self, runtime_path: impl Into<String>) -> Self {
925 self.with_runtime_path(runtime_path)
926 }
927
928 pub fn motion(mut self, motion: ThemeMotion) -> Self {
929 if let Some(duration_ms) = motion.duration_ms {
930 self.duration_ms = duration_ms;
931 }
932 if let Some(easing) = motion.easing {
933 self.easing = easing;
934 }
935 if let Some(reduced_motion) = motion.reduced_motion {
936 self.reduced_motion = reduced_motion;
937 }
938 if let Some(animation) = motion.animation {
939 self.animation = animation;
940 }
941 if let Some(preset) = motion.preset {
942 self.animation_preset = preset;
943 }
944 if let Some(speed) = motion.speed {
945 self.animation_speed = speed;
946 }
947 self
948 }
949
950 pub fn validate(&self) -> ThemeValidationReport {
951 let mut report = ThemeValidationReport::default();
952 if self.storage_key.trim().is_empty() {
953 report.push(ThemeValidationIssue::error(
954 ThemeValidationCode::EmptyStorageKey,
955 "storage_key",
956 "theme storage key must not be empty",
957 ));
958 }
959 if self.animation_storage_key.trim().is_empty() {
960 report.push(ThemeValidationIssue::error(
961 ThemeValidationCode::EmptyAnimationStorageKey,
962 "animation_storage_key",
963 "animation preset storage key must not be empty",
964 ));
965 }
966 if self.animation_speed_storage_key.trim().is_empty() {
967 report.push(ThemeValidationIssue::error(
968 ThemeValidationCode::EmptyAnimationSpeedStorageKey,
969 "animation_speed_storage_key",
970 "animation speed storage key must not be empty",
971 ));
972 }
973 if !self.registry.contains_theme(&self.default_theme) {
974 report.push(ThemeValidationIssue::error(
975 ThemeValidationCode::MissingDefaultTheme,
976 "default_theme",
977 format!("default theme `{}` is not registered", self.default_theme),
978 ));
979 }
980 if !self.registry.contains_theme(&self.system_light_theme) {
981 report.push(ThemeValidationIssue::error(
982 ThemeValidationCode::MissingSystemLightTheme,
983 "system_light_theme",
984 format!(
985 "system light theme `{}` is not registered",
986 self.system_light_theme
987 ),
988 ));
989 }
990 if !self.registry.contains_theme(&self.system_dark_theme) {
991 report.push(ThemeValidationIssue::error(
992 ThemeValidationCode::MissingSystemDarkTheme,
993 "system_dark_theme",
994 format!(
995 "system dark theme `{}` is not registered",
996 self.system_dark_theme
997 ),
998 ));
999 }
1000 if !is_valid_theme_target(&self.target) {
1001 report.push(ThemeValidationIssue::error(
1002 ThemeValidationCode::InvalidTarget,
1003 "target",
1004 "theme target must be html, :root, or a simple selector",
1005 ));
1006 }
1007 if !is_valid_theme_attribute(&self.attribute) {
1008 report.push(ThemeValidationIssue::error(
1009 ThemeValidationCode::InvalidAttribute,
1010 "attribute",
1011 "theme attribute must be a non-empty attribute name",
1012 ));
1013 }
1014 for theme in &self.registry.themes {
1015 for (name, value) in &theme.tokens {
1016 if !is_custom_property_name(name) {
1017 report.push(ThemeValidationIssue::token_error(
1018 ThemeValidationCode::InvalidTokenName,
1019 theme.id.clone(),
1020 name.clone(),
1021 "theme token names must be CSS custom properties",
1022 ));
1023 }
1024 if !is_safe_css_token_value(value) {
1025 report.push(ThemeValidationIssue::token_error(
1026 ThemeValidationCode::UnsafeTokenValue,
1027 theme.id.clone(),
1028 name.clone(),
1029 "theme token values must be non-empty safe CSS values",
1030 ));
1031 }
1032 }
1033 }
1034 report
1035 }
1036
1037 pub fn resolve_theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
1038 let id = theme_id(id);
1039 self.registry
1040 .theme(&id)
1041 .or_else(|| self.registry.theme(&self.default_theme))
1042 .or_else(|| self.registry.first_non_system_theme())
1043 }
1044
1045 pub fn toggle_theme_id(&self, current: impl AsRef<str>) -> String {
1046 let current = theme_id(current);
1047 let default = if self.default_theme == "system" {
1048 self.system_dark_theme.as_str()
1049 } else {
1050 self.default_theme.as_str()
1051 };
1052 if current == default {
1053 self.registry
1054 .themes
1055 .iter()
1056 .find(|theme| !theme.is_system() && theme.id != default)
1057 .map(|theme| theme.id.clone())
1058 .unwrap_or_else(|| default.to_string())
1059 } else {
1060 default.to_string()
1061 }
1062 }
1063
1064 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1065 serde_json::to_string(self)
1066 }
1067
1068 pub fn to_compact_json(&self) -> Result<String, serde_json::Error> {
1069 let mut value = serde_json::to_value(self)?;
1070 let default = serde_json::to_value(ThemeConfig::default())?;
1071 if let (Some(object), Some(defaults)) = (value.as_object_mut(), default.as_object()) {
1072 for key in [
1073 "defaultTheme",
1074 "systemLightTheme",
1075 "systemDarkTheme",
1076 "storageKey",
1077 "attribute",
1078 "target",
1079 "durationMs",
1080 "easing",
1081 "reducedMotion",
1082 "animation",
1083 "animationPreset",
1084 "animationStorageKey",
1085 "animationSpeed",
1086 "animationSpeedStorageKey",
1087 "isolateViewTransitionNames",
1088 "runtimePath",
1089 ] {
1090 if object.get(key) == defaults.get(key) {
1091 object.remove(key);
1092 }
1093 }
1094 }
1095 serde_json::to_string(&value)
1096 }
1097
1098 pub fn to_preferred_json(
1099 &self,
1100 format: ThemeSerializationFormat,
1101 ) -> Result<String, serde_json::Error> {
1102 match format {
1103 ThemeSerializationFormat::StableJson | ThemeSerializationFormat::ReadableJson => {
1104 self.to_json()
1105 }
1106 ThemeSerializationFormat::CompactJson => self.to_compact_json(),
1107 }
1108 }
1109
1110 pub fn with_route_profile(mut self, profile: ThemePresetProfile) -> Self {
1111 profile.apply_to_config(&mut self);
1112 self
1113 }
1114
1115 pub fn route_profile(self, profile: ThemePresetProfile) -> Self {
1116 self.with_route_profile(profile)
1117 }
1118
1119 pub fn cache_key(&self, route: Option<&str>) -> String {
1120 theme_cache_key(self, route, None)
1121 }
1122
1123 pub fn manifest_fragment(&self, policy: &ThemeRoutePolicy) -> ThemeManifestFragment {
1124 theme_manifest_fragment(self, policy)
1125 }
1126
1127 pub fn output_report(&self, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
1128 theme_output_report(self, policy)
1129 }
1130
1131 pub fn explain(&self, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
1132 explain_theme(self, policy)
1133 }
1134
1135 pub fn try_validated(self) -> Result<Self, ThemeConfigError> {
1136 let report = self.validate();
1137 if report.is_valid() {
1138 Ok(self)
1139 } else {
1140 Err(ThemeConfigError { report })
1141 }
1142 }
1143}
1144
1145#[derive(Debug, Clone, PartialEq, Eq)]
1146pub struct ThemeConfigError {
1147 pub report: ThemeValidationReport,
1148}
1149
1150impl fmt::Display for ThemeConfigError {
1151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1152 let count = self.report.errors().count();
1153 write!(f, "invalid Theme config ({count} error(s))")
1154 }
1155}
1156
1157impl std::error::Error for ThemeConfigError {}
1158
1159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1160#[serde(rename_all = "kebab-case")]
1161pub enum ThemeRuntimeEmission {
1162 Always,
1163 #[default]
1164 WhenUsed,
1165 PrepaintOnly,
1166 Disabled,
1167}
1168
1169impl ThemeRuntimeEmission {
1170 pub const fn as_attr(self) -> &'static str {
1171 match self {
1172 Self::Always => "always",
1173 Self::WhenUsed => "when-used",
1174 Self::PrepaintOnly => "prepaint-only",
1175 Self::Disabled => "disabled",
1176 }
1177 }
1178}
1179
1180#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(rename_all = "kebab-case")]
1182pub enum ThemeSerializationFormat {
1183 #[default]
1184 StableJson,
1185 ReadableJson,
1186 CompactJson,
1187}
1188
1189impl ThemeSerializationFormat {
1190 pub const fn as_attr(self) -> &'static str {
1191 match self {
1192 Self::StableJson => "stable-json",
1193 Self::ReadableJson => "readable-json",
1194 Self::CompactJson => "compact-json",
1195 }
1196 }
1197}
1198
1199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1200#[serde(rename_all = "kebab-case")]
1201pub enum ThemeDiagnosticVerbosity {
1202 Off,
1203 Summary,
1204 #[default]
1205 Detailed,
1206}
1207
1208#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1209#[serde(rename_all = "kebab-case")]
1210pub enum ThemeFallbackStrategy {
1211 #[default]
1212 SystemTheme,
1213 StaticTokens,
1214 NativePort,
1215 DisableRuntime,
1216}
1217
1218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1219#[serde(rename_all = "kebab-case")]
1220pub enum ThemePresetProfile {
1221 Conservative,
1222 #[default]
1223 Balanced,
1224 Expressive,
1225}
1226
1227impl ThemePresetProfile {
1228 pub const fn as_attr(self) -> &'static str {
1229 match self {
1230 Self::Conservative => "conservative",
1231 Self::Balanced => "balanced",
1232 Self::Expressive => "expressive",
1233 }
1234 }
1235
1236 pub fn apply_to_config(self, config: &mut ThemeConfig) {
1237 match self {
1238 Self::Conservative => {
1239 config.duration_ms = config.duration_ms.min(120);
1240 config.reduced_motion = ThemeReducedMotion::Respect;
1241 config.animation = ThemeAnimationMode::CssOnly;
1242 config.animation_preset = ThemeAnimationPreset::CrossFade;
1243 config.animation_speed = normalize_animation_speed(75);
1244 config.isolate_view_transition_names = true;
1245 }
1246 Self::Balanced => {
1247 config.duration_ms = config.duration_ms.max(160).min(260);
1248 config.reduced_motion = ThemeReducedMotion::Respect;
1249 config.animation = ThemeAnimationMode::ViewTransition;
1250 config.animation_speed = normalize_animation_speed(config.animation_speed);
1251 }
1252 Self::Expressive => {
1253 config.duration_ms = config.duration_ms.max(260);
1254 config.animation = ThemeAnimationMode::ViewTransition;
1255 config.animation_preset = ThemeAnimationPreset::RadialWipe;
1256 config.animation_speed = normalize_animation_speed(140);
1257 config.isolate_view_transition_names = true;
1258 }
1259 }
1260 }
1261}
1262
1263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1264#[serde(rename_all = "camelCase")]
1265pub struct ThemeInteropPolicy {
1266 pub dioprism: bool,
1267 pub resume: bool,
1268 pub native_port: bool,
1269 pub viewtx: bool,
1270 pub hoverfx: bool,
1271 pub textfx: bool,
1272}
1273
1274impl Default for ThemeInteropPolicy {
1275 fn default() -> Self {
1276 Self {
1277 dioprism: true,
1278 resume: true,
1279 native_port: true,
1280 viewtx: true,
1281 hoverfx: true,
1282 textfx: true,
1283 }
1284 }
1285}
1286
1287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(rename_all = "camelCase")]
1289pub struct ThemeOutputBudget {
1290 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub max_config_bytes: Option<usize>,
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub max_runtime_bytes: Option<usize>,
1294 #[serde(default, skip_serializing_if = "Option::is_none")]
1295 pub max_style_bytes: Option<usize>,
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub max_theme_count: Option<usize>,
1298}
1299
1300impl ThemeOutputBudget {
1301 pub fn new() -> Self {
1302 Self::default()
1303 }
1304
1305 pub fn config_bytes(mut self, max: usize) -> Self {
1306 self.max_config_bytes = Some(max);
1307 self
1308 }
1309
1310 pub fn runtime_bytes(mut self, max: usize) -> Self {
1311 self.max_runtime_bytes = Some(max);
1312 self
1313 }
1314
1315 pub fn style_bytes(mut self, max: usize) -> Self {
1316 self.max_style_bytes = Some(max);
1317 self
1318 }
1319
1320 pub fn theme_count(mut self, max: usize) -> Self {
1321 self.max_theme_count = Some(max);
1322 self
1323 }
1324}
1325
1326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1327#[serde(rename_all = "camelCase")]
1328pub struct ThemeRoutePolicy {
1329 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub route: Option<String>,
1331 pub enabled: bool,
1332 pub profile: ThemePresetProfile,
1333 pub emission: ThemeRuntimeEmission,
1334 pub serialization: ThemeSerializationFormat,
1335 pub diagnostics: ThemeDiagnosticVerbosity,
1336 pub fallback: ThemeFallbackStrategy,
1337 #[serde(default)]
1338 pub interop: ThemeInteropPolicy,
1339 #[serde(default)]
1340 pub budget: ThemeOutputBudget,
1341 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1342 pub labels: BTreeMap<String, String>,
1343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1344 pub tags: Vec<String>,
1345}
1346
1347impl Default for ThemeRoutePolicy {
1348 fn default() -> Self {
1349 Self {
1350 route: None,
1351 enabled: true,
1352 profile: ThemePresetProfile::Balanced,
1353 emission: ThemeRuntimeEmission::WhenUsed,
1354 serialization: ThemeSerializationFormat::StableJson,
1355 diagnostics: ThemeDiagnosticVerbosity::Detailed,
1356 fallback: ThemeFallbackStrategy::SystemTheme,
1357 interop: ThemeInteropPolicy::default(),
1358 budget: ThemeOutputBudget::default(),
1359 labels: BTreeMap::new(),
1360 tags: Vec::new(),
1361 }
1362 }
1363}
1364
1365impl ThemeRoutePolicy {
1366 pub fn new() -> Self {
1367 Self::default()
1368 }
1369
1370 pub fn route(mut self, route: impl Into<String>) -> Self {
1371 self.route = Some(route.into());
1372 self
1373 }
1374
1375 pub fn enabled(mut self, enabled: bool) -> Self {
1376 self.enabled = enabled;
1377 self
1378 }
1379
1380 pub fn profile(mut self, profile: ThemePresetProfile) -> Self {
1381 self.profile = profile;
1382 self
1383 }
1384
1385 pub fn emission(mut self, emission: ThemeRuntimeEmission) -> Self {
1386 self.emission = emission;
1387 self
1388 }
1389
1390 pub fn serialization(mut self, serialization: ThemeSerializationFormat) -> Self {
1391 self.serialization = serialization;
1392 self
1393 }
1394
1395 pub fn diagnostics(mut self, diagnostics: ThemeDiagnosticVerbosity) -> Self {
1396 self.diagnostics = diagnostics;
1397 self
1398 }
1399
1400 pub fn fallback(mut self, fallback: ThemeFallbackStrategy) -> Self {
1401 self.fallback = fallback;
1402 self
1403 }
1404
1405 pub fn budget(mut self, budget: ThemeOutputBudget) -> Self {
1406 self.budget = budget;
1407 self
1408 }
1409
1410 pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1411 self.labels.insert(key.into(), value.into());
1412 self
1413 }
1414
1415 pub fn tag(mut self, tag: impl Into<String>) -> Self {
1416 let tag = tag.into();
1417 if !tag.is_empty() && !self.tags.contains(&tag) {
1418 self.tags.push(tag);
1419 self.tags.sort();
1420 }
1421 self
1422 }
1423}
1424
1425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1426#[serde(rename_all = "camelCase")]
1427pub struct ThemeManifestFragment {
1428 pub package: String,
1429 pub version: String,
1430 #[serde(default, skip_serializing_if = "Option::is_none")]
1431 pub route: Option<String>,
1432 pub enabled: bool,
1433 pub cache_key: String,
1434 pub default_theme: String,
1435 pub runtime_path: String,
1436 pub profile: ThemePresetProfile,
1437 pub emission: ThemeRuntimeEmission,
1438 pub fallback: ThemeFallbackStrategy,
1439 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1440 pub labels: BTreeMap<String, String>,
1441 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1442 pub tags: Vec<String>,
1443 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1444 pub metrics: BTreeMap<String, u64>,
1445 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1446 pub policies: BTreeMap<String, serde_json::Value>,
1447}
1448
1449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1450#[serde(rename_all = "camelCase")]
1451pub struct ThemeOutputViolation {
1452 pub field: String,
1453 pub actual: usize,
1454 pub budget: usize,
1455}
1456
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1458#[serde(rename_all = "camelCase")]
1459pub struct ThemeOutputReport {
1460 pub package: String,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1462 pub route: Option<String>,
1463 pub cache_key: String,
1464 pub config_bytes: usize,
1465 pub runtime_bytes: usize,
1466 pub style_bytes: usize,
1467 pub theme_count: usize,
1468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1469 pub violations: Vec<ThemeOutputViolation>,
1470}
1471
1472impl ThemeOutputReport {
1473 pub fn is_within_budget(&self) -> bool {
1474 self.violations.is_empty()
1475 }
1476}
1477
1478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1479#[serde(rename_all = "camelCase")]
1480pub struct ThemeExplainReport {
1481 pub package: String,
1482 #[serde(default, skip_serializing_if = "Option::is_none")]
1483 pub route: Option<String>,
1484 pub cache_key: String,
1485 pub runtime_decision: String,
1486 pub token_decision: String,
1487 pub fallback_decision: String,
1488 pub validation: ThemeValidationReport,
1489 pub manifest: ThemeManifestFragment,
1490 pub output: ThemeOutputReport,
1491 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1492 pub notes: Vec<String>,
1493}
1494
1495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1496#[serde(rename_all = "camelCase")]
1497pub struct ThemeCompatibilityRow {
1498 pub target: String,
1499 pub support: String,
1500 pub runtime: String,
1501 pub fallback: String,
1502 pub notes: String,
1503}
1504
1505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1506#[serde(rename_all = "camelCase")]
1507pub struct ThemeCompatibilityMatrix {
1508 pub package: String,
1509 pub rows: Vec<ThemeCompatibilityRow>,
1510}
1511
1512pub trait ThemeManifestPolicyHook {
1513 fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment>;
1514}
1515
1516pub fn apply_theme_manifest_hook<H>(
1517 config: &ThemeConfig,
1518 policy: &ThemeRoutePolicy,
1519 hook: &H,
1520) -> Option<ThemeManifestFragment>
1521where
1522 H: ThemeManifestPolicyHook,
1523{
1524 hook.apply(theme_manifest_fragment(config, policy))
1525}
1526
1527pub fn theme_route_policy() -> ThemeRoutePolicy {
1528 ThemeRoutePolicy::new()
1529}
1530
1531pub fn theme_output_budget() -> ThemeOutputBudget {
1532 ThemeOutputBudget::new()
1533}
1534
1535pub fn theme_cache_key(config: &ThemeConfig, route: Option<&str>, extra: Option<&str>) -> String {
1536 let json = config.to_json().unwrap_or_default();
1537 stable_hash_hex([
1538 THEME_PACKAGE_NAME,
1539 THEME_PACKAGE_VERSION,
1540 route.unwrap_or("*"),
1541 extra.unwrap_or(""),
1542 json.as_str(),
1543 ])
1544}
1545
1546pub fn theme_manifest_fragment(
1547 config: &ThemeConfig,
1548 policy: &ThemeRoutePolicy,
1549) -> ThemeManifestFragment {
1550 let output = theme_output_report(config, policy);
1551 let mut metrics = BTreeMap::new();
1552 metrics.insert("configBytes".to_string(), output.config_bytes as u64);
1553 metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
1554 metrics.insert("styleBytes".to_string(), output.style_bytes as u64);
1555 metrics.insert("themeCount".to_string(), output.theme_count as u64);
1556 let mut policies = BTreeMap::new();
1557 policies.insert(
1558 "interop".to_string(),
1559 serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
1560 );
1561 policies.insert(
1562 "route".to_string(),
1563 serde_json::json!({
1564 "enabled": policy.enabled,
1565 "profile": policy.profile,
1566 "emission": policy.emission,
1567 "serialization": policy.serialization,
1568 "fallback": policy.fallback,
1569 }),
1570 );
1571
1572 ThemeManifestFragment {
1573 package: THEME_PACKAGE_NAME.to_string(),
1574 version: THEME_PACKAGE_VERSION.to_string(),
1575 route: policy.route.clone(),
1576 enabled: policy.enabled,
1577 cache_key: output.cache_key,
1578 default_theme: config.default_theme.clone(),
1579 runtime_path: config.runtime_path.clone(),
1580 profile: policy.profile,
1581 emission: policy.emission,
1582 fallback: policy.fallback,
1583 labels: policy.labels.clone(),
1584 tags: policy.tags.clone(),
1585 metrics,
1586 policies,
1587 }
1588}
1589
1590pub fn theme_output_report(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
1591 let config_json = config
1592 .to_preferred_json(policy.serialization)
1593 .unwrap_or_default();
1594 let runtime_bytes = if policy.enabled
1595 && !matches!(
1596 policy.emission,
1597 ThemeRuntimeEmission::Disabled | ThemeRuntimeEmission::PrepaintOnly
1598 ) {
1599 config.runtime_path.len()
1600 } else {
1601 0
1602 };
1603 let style_bytes = config
1604 .registry
1605 .themes
1606 .iter()
1607 .map(|theme| theme_tokens_css(theme).len())
1608 .sum::<usize>();
1609 let theme_count = config.registry.themes.len();
1610 let mut violations = Vec::new();
1611 push_theme_budget_violation(
1612 &mut violations,
1613 "configBytes",
1614 config_json.len(),
1615 policy.budget.max_config_bytes,
1616 );
1617 push_theme_budget_violation(
1618 &mut violations,
1619 "runtimeBytes",
1620 runtime_bytes,
1621 policy.budget.max_runtime_bytes,
1622 );
1623 push_theme_budget_violation(
1624 &mut violations,
1625 "styleBytes",
1626 style_bytes,
1627 policy.budget.max_style_bytes,
1628 );
1629 push_theme_budget_violation(
1630 &mut violations,
1631 "themeCount",
1632 theme_count,
1633 policy.budget.max_theme_count,
1634 );
1635
1636 ThemeOutputReport {
1637 package: THEME_PACKAGE_NAME.to_string(),
1638 route: policy.route.clone(),
1639 cache_key: theme_cache_key(
1640 config,
1641 policy.route.as_deref(),
1642 Some(policy.profile.as_attr()),
1643 ),
1644 config_bytes: config_json.len(),
1645 runtime_bytes,
1646 style_bytes,
1647 theme_count,
1648 violations,
1649 }
1650}
1651
1652pub fn explain_theme(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
1653 let validation = config.validate();
1654 let output = theme_output_report(config, policy);
1655 let manifest = theme_manifest_fragment(config, policy);
1656 let runtime_decision = if !policy.enabled {
1657 "route disabled theme emission".to_string()
1658 } else if policy.emission == ThemeRuntimeEmission::Disabled {
1659 "theme runtime disabled by route policy".to_string()
1660 } else if policy.emission == ThemeRuntimeEmission::PrepaintOnly {
1661 "only prepaint CSS and data attributes should be emitted".to_string()
1662 } else {
1663 "theme runtime emitted with resumable handlers and storage policy".to_string()
1664 };
1665 let token_decision = format!(
1666 "{} themes produce {} bytes of token CSS",
1667 output.theme_count, output.style_bytes
1668 );
1669 let fallback_decision = format!("fallback strategy: {:?}", policy.fallback);
1670 let mut notes = Vec::new();
1671 if !validation.is_valid() {
1672 notes.push("validation errors must be resolved before SSR emission".to_string());
1673 }
1674 if policy.interop.hoverfx {
1675 notes.push("HoverFX can consume theme CSS custom properties".to_string());
1676 }
1677 if policy.interop.textfx {
1678 notes.push("TextFX gradients can reference theme visual tokens".to_string());
1679 }
1680 if !output.is_within_budget() {
1681 notes.push("one or more theme output budgets were exceeded".to_string());
1682 }
1683
1684 ThemeExplainReport {
1685 package: THEME_PACKAGE_NAME.to_string(),
1686 route: policy.route.clone(),
1687 cache_key: output.cache_key.clone(),
1688 runtime_decision,
1689 token_decision,
1690 fallback_decision,
1691 validation,
1692 manifest,
1693 output,
1694 notes,
1695 }
1696}
1697
1698pub fn theme_compatibility_matrix() -> ThemeCompatibilityMatrix {
1699 ThemeCompatibilityMatrix {
1700 package: THEME_PACKAGE_NAME.to_string(),
1701 rows: vec![
1702 ThemeCompatibilityRow {
1703 target: "web".to_string(),
1704 support: "full".to_string(),
1705 runtime: "prepaint CSS plus module runtime".to_string(),
1706 fallback: "system-theme".to_string(),
1707 notes: "ViewTX, HoverFX, and TextFX can consume shared theme policy".to_string(),
1708 },
1709 ThemeCompatibilityRow {
1710 target: "server".to_string(),
1711 support: "manifest".to_string(),
1712 runtime: "route-gated config/style/runtime emission".to_string(),
1713 fallback: "static-tokens".to_string(),
1714 notes: "resume/Dioprism consumers can use manifest fragments and cache keys"
1715 .to_string(),
1716 },
1717 ThemeCompatibilityRow {
1718 target: "native".to_string(),
1719 support: "adapter".to_string(),
1720 runtime: "native-port theme actions".to_string(),
1721 fallback: "native-port".to_string(),
1722 notes: "native renderers can consume theme ids and visual token manifests"
1723 .to_string(),
1724 },
1725 ThemeCompatibilityRow {
1726 target: "cli".to_string(),
1727 support: "report".to_string(),
1728 runtime: "none".to_string(),
1729 fallback: "stable-json".to_string(),
1730 notes: "budget reports track config, style, runtime bytes, and theme counts"
1731 .to_string(),
1732 },
1733 ],
1734 }
1735}
1736
1737pub fn theme_native_port_hints(
1738 config: &ThemeConfig,
1739 policy: &ThemeRoutePolicy,
1740) -> BTreeMap<String, String> {
1741 let mut hints = BTreeMap::new();
1742 hints.insert("package".to_string(), THEME_PACKAGE_NAME.to_string());
1743 hints.insert("version".to_string(), THEME_PACKAGE_VERSION.to_string());
1744 hints.insert(
1745 "cacheKey".to_string(),
1746 theme_cache_key(config, policy.route.as_deref(), None),
1747 );
1748 hints.insert(
1749 "route".to_string(),
1750 policy.route.clone().unwrap_or_else(|| "*".to_string()),
1751 );
1752 hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
1753 hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
1754 hints.insert("defaultTheme".to_string(), config.default_theme.clone());
1755 hints.insert(
1756 "themeCount".to_string(),
1757 config.registry.themes.len().to_string(),
1758 );
1759 hints
1760}
1761
1762fn push_theme_budget_violation(
1763 violations: &mut Vec<ThemeOutputViolation>,
1764 field: &str,
1765 actual: usize,
1766 budget: Option<usize>,
1767) {
1768 if let Some(budget) = budget
1769 && actual > budget
1770 {
1771 violations.push(ThemeOutputViolation {
1772 field: field.to_string(),
1773 actual,
1774 budget,
1775 });
1776 }
1777}
1778
1779fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
1780 let mut hash = 0xcbf29ce484222325u64;
1781 for part in parts {
1782 for byte in part.as_bytes() {
1783 hash ^= u64::from(*byte);
1784 hash = hash.wrapping_mul(0x100000001b3);
1785 }
1786 hash ^= 0xff;
1787 hash = hash.wrapping_mul(0x100000001b3);
1788 }
1789 format!("{hash:016x}")
1790}
1791
1792impl std::ops::Add<ThemeDefinition> for ThemeConfig {
1793 type Output = Self;
1794
1795 fn add(self, rhs: ThemeDefinition) -> Self::Output {
1796 self.with_theme(rhs)
1797 }
1798}
1799
1800pub mod prelude {
1801 pub use crate::core::integration::*;
1802 pub use crate::core::{
1803 ThemeAnim, ThemeAnimationMode, ThemeCfg, ThemeColorScheme, ThemeCompatibilityMatrix,
1804 ThemeCompatibilityRow, ThemeConfig, ThemeDef, ThemeDefinition, ThemeDiagnosticVerbosity,
1805 ThemeExplainReport, ThemeFallbackStrategy, ThemeInteropPolicy, ThemeManifestFragment,
1806 ThemeManifestPolicyHook, ThemeOutputBudget, ThemeOutputReport, ThemeOutputViolation,
1807 ThemePreset, ThemePresetProfile, ThemeReducedMotion, ThemeReg, ThemeRegistry,
1808 ThemeRoutePolicy, ThemeRuntimeEmission, ThemeSerializationFormat, ThemeVisualTokenRole,
1809 apply_theme_manifest_hook, default_themes, explain_theme, theme, theme_cache_key,
1810 theme_compatibility_matrix, theme_def, theme_id, theme_manifest_fragment,
1811 theme_native_port_hints, theme_output_budget, theme_output_report, theme_route_policy,
1812 themes,
1813 };
1814}
1815
1816pub fn theme_id(id: impl AsRef<str>) -> String {
1817 let mut output = String::new();
1818 for ch in id.as_ref().chars() {
1819 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1820 output.push(ch.to_ascii_lowercase());
1821 } else if ch.is_whitespace() || matches!(ch, '.' | ':' | '/') {
1822 output.push('-');
1823 }
1824 }
1825 let output = output.trim_matches('-');
1826 if output.is_empty() {
1827 "theme".to_string()
1828 } else {
1829 output.to_string()
1830 }
1831}
1832
1833pub fn is_custom_property_name(name: &str) -> bool {
1834 let Some(rest) = name.strip_prefix("--") else {
1835 return false;
1836 };
1837 !rest.is_empty()
1838 && rest
1839 .chars()
1840 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
1841}
1842
1843pub fn is_valid_theme_target(target: &str) -> bool {
1844 let trimmed = target.trim();
1845 matches!(trimmed, "html" | ":root")
1846 || (!trimmed.is_empty()
1847 && trimmed
1848 .chars()
1849 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '#')))
1850}
1851
1852pub fn is_valid_theme_attribute(attribute: &str) -> bool {
1853 let trimmed = attribute.trim();
1854 !trimmed.is_empty()
1855 && trimmed
1856 .chars()
1857 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':'))
1858}
1859
1860pub fn normalize_animation_speed(speed: u16) -> u16 {
1861 speed.clamp(MIN_THEME_ANIMATION_SPEED, MAX_THEME_ANIMATION_SPEED)
1862}
1863
1864pub fn theme_tokens_css(theme: &ThemeDefinition) -> String {
1865 let mut css = String::new();
1866 css.push_str("color-scheme:");
1867 css.push_str(theme.color_scheme.as_css());
1868 css.push(';');
1869 for (name, value) in &theme.tokens {
1870 if is_custom_property_name(name) && is_safe_css_token_value(value) {
1871 css.push_str(name);
1872 css.push(':');
1873 css.push_str(value);
1874 css.push(';');
1875 }
1876 }
1877 css
1878}
1879
1880pub fn is_safe_css_token_value(value: &str) -> bool {
1881 !value.trim().is_empty()
1882 && !value
1883 .chars()
1884 .any(|ch| ch.is_control() || matches!(ch, ';' | '{' | '}' | '<' | '>' | '`'))
1885}
1886
1887#[cfg(test)]
1888mod tests {
1889 use super::*;
1890
1891 #[test]
1892 fn registry_defaults_include_light_dark_system() {
1893 let registry = ThemeRegistry::default();
1894 assert!(registry.contains_theme("light"));
1895 assert!(registry.contains_theme("dark"));
1896 assert!(registry.contains_theme("system"));
1897 }
1898
1899 #[test]
1900 fn theme_ids_are_sanitized() {
1901 assert_eq!(theme_id("High Contrast"), "high-contrast");
1902 assert_eq!(theme_id(""), "theme");
1903 assert_eq!(theme_id("../Dark Mode"), "dark-mode");
1904 }
1905
1906 #[test]
1907 fn duplicate_theme_replaces_existing_definition() {
1908 let registry = ThemeRegistry::new()
1909 .with_theme(ThemeDefinition::new("brand", "Brand"))
1910 .with_theme(ThemeDefinition::new("brand", "Updated"));
1911 assert_eq!(registry.themes.len(), 1);
1912 assert_eq!(registry.theme("brand").unwrap().label, "Updated");
1913 }
1914
1915 #[test]
1916 fn token_css_contains_valid_custom_properties() {
1917 let theme = ThemeDefinition::new("brand", "Brand")
1918 .with_color_scheme(ThemeColorScheme::Dark)
1919 .with_token("--brand-bg", "#000")
1920 .with_token("--bad-value", "red;}body{display:none")
1921 .with_token("bad", "#fff");
1922 let css = theme_tokens_css(&theme);
1923 assert!(css.contains("color-scheme:dark;"));
1924 assert!(css.contains("--brand-bg:#000;"));
1925 assert!(!css.contains("--bad-value"));
1926 assert!(!css.contains("bad:#fff"));
1927 }
1928
1929 #[test]
1930 fn visual_token_helpers_write_canonical_theme_tokens() {
1931 let theme = ThemeDefinition::new("brand", "Brand")
1932 .with_visual_token(ThemeVisualTokenRole::Background, "#101010")
1933 .with_visual_tokens([
1934 (ThemeVisualTokenRole::Text, "#f8fafc"),
1935 (ThemeVisualTokenRole::Accent, "#22d3ee"),
1936 ]);
1937
1938 assert_eq!(
1939 theme.tokens.get(THEME_TOKEN_BG).map(String::as_str),
1940 Some("#101010")
1941 );
1942 assert_eq!(
1943 theme.tokens.get(THEME_TOKEN_FG).map(String::as_str),
1944 Some("#f8fafc")
1945 );
1946 assert_eq!(
1947 theme.tokens.get(THEME_TOKEN_ACCENT).map(String::as_str),
1948 Some("#22d3ee")
1949 );
1950 assert!(theme_tokens_css(&theme).contains("--dxt-accent:#22d3ee;"));
1951 assert_eq!(
1952 theme_visual_token_css_var("surface-border"),
1953 Some(THEME_TOKEN_SURFACE_BORDER)
1954 );
1955 assert_eq!(
1956 theme_visual_token_css_var("primary"),
1957 Some(THEME_TOKEN_ACCENT)
1958 );
1959 assert_eq!(theme_visual_token_css_var("unknown"), None);
1960 }
1961
1962 #[test]
1963 fn visual_token_manifest_is_stable_and_serializable() {
1964 let manifest = theme_visual_token_manifest();
1965 assert_eq!(manifest.version, THEME_VISUAL_TOKEN_MANIFEST_VERSION);
1966 assert_eq!(manifest.change_event, THEME_CHANGE_EVENT);
1967 assert_eq!(manifest.tokens.len(), 6);
1968 assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
1969 assert_eq!(ThemeVisualTokenRole::Surface.js_key(), "surface");
1970 assert_eq!(THEME_TOKEN_TEXT, THEME_TOKEN_FG);
1971 assert_eq!(THEME_TOKEN_SURFACE, THEME_TOKEN_PANEL);
1972
1973 let json = theme_visual_token_manifest_json().expect("manifest serializes");
1974 let cached = theme_visual_token_manifest_json().expect("manifest serializes again");
1975 assert_eq!(json, cached);
1976 assert!(json.contains("\"changeEvent\":\"dioprism-theme:change\""));
1977 assert!(json.contains("\"key\":\"surfaceBorder\""));
1978 assert!(json.contains("\"cssVar\":\"--dxt-accent\""));
1979 }
1980
1981 #[test]
1982 fn compact_config_omits_default_scalar_values() {
1983 let default = ThemeConfig::default();
1984 let full = default.to_json().expect("full config serializes");
1985 let compact = default
1986 .to_compact_json()
1987 .expect("compact config serializes");
1988 assert!(compact.len() < full.len());
1989 assert!(compact.contains("\"registry\""));
1990 assert!(!compact.contains("\"storageKey\""));
1991 assert!(!compact.contains("\"animationPreset\""));
1992
1993 let custom = default
1994 .with_storage_key("brand-theme")
1995 .with_duration_ms(140);
1996 let custom_compact = custom
1997 .to_compact_json()
1998 .expect("custom compact config serializes");
1999 assert!(custom_compact.contains("\"storageKey\":\"brand-theme\""));
2000 assert!(custom_compact.contains("\"durationMs\":140"));
2001 }
2002
2003 #[test]
2004 fn config_serializes_camel_case_overrides() {
2005 let json = ThemeConfig::default()
2006 .with_storage_key("custom-theme")
2007 .with_animation_storage_key("custom-animation")
2008 .with_animation_preset(ThemeAnimationPreset::MaskedWave)
2009 .with_animation_speed(175)
2010 .with_animation_speed_storage_key("custom-animation-speed")
2011 .with_view_transition_name_isolation(false)
2012 .with_easing("linear")
2013 .with_default_theme("dark")
2014 .with_duration_ms(120)
2015 .to_json()
2016 .expect("config serializes");
2017 assert!(json.contains("\"storageKey\":\"custom-theme\""));
2018 assert!(json.contains("\"animationStorageKey\":\"custom-animation\""));
2019 assert!(json.contains("\"animationPreset\":\"masked-wave\""));
2020 assert!(json.contains("\"animationSpeed\":175"));
2021 assert!(json.contains("\"animationSpeedStorageKey\":\"custom-animation-speed\""));
2022 assert!(json.contains("\"isolateViewTransitionNames\":false"));
2023 assert!(json.contains("\"easing\":\"linear\""));
2024 assert!(json.contains("\"defaultTheme\":\"dark\""));
2025 assert!(json.contains("\"durationMs\":120"));
2026 }
2027
2028 #[test]
2029 fn view_transition_name_isolation_defaults_on() {
2030 let config = ThemeConfig::default();
2031 assert!(config.isolate_view_transition_names);
2032 assert!(
2033 config
2034 .to_json()
2035 .expect("config serializes")
2036 .contains("\"isolateViewTransitionNames\":true")
2037 );
2038 }
2039
2040 #[test]
2041 fn animation_presets_are_stable_and_kebab_case() {
2042 assert_eq!(
2043 ThemeAnimationPreset::default(),
2044 ThemeAnimationPreset::CrossFade
2045 );
2046 assert_eq!(ThemeAnimationPreset::all().len(), 5);
2047 assert_eq!(ThemeAnimationPreset::MaskedWave.as_attr(), "masked-wave");
2048 let json =
2049 serde_json::to_string(&ThemeAnimationPreset::RadialWipe).expect("preset serializes");
2050 assert_eq!(json, "\"radial-wipe\"");
2051 }
2052
2053 #[test]
2054 fn animation_speed_is_clamped() {
2055 assert_eq!(
2056 ThemeConfig::default()
2057 .with_animation_speed(0)
2058 .animation_speed,
2059 MIN_THEME_ANIMATION_SPEED
2060 );
2061 assert_eq!(
2062 ThemeConfig::default()
2063 .with_animation_speed(500)
2064 .animation_speed,
2065 MAX_THEME_ANIMATION_SPEED
2066 );
2067 }
2068
2069 #[test]
2070 fn validation_accepts_defaults_and_reports_bad_overrides() {
2071 assert!(ThemeConfig::default().validate().is_valid());
2072
2073 let mut invalid = ThemeConfig::default()
2074 .with_default_theme("missing")
2075 .with_storage_key("")
2076 .with_animation_storage_key("")
2077 .with_animation_speed_storage_key("")
2078 .with_target("html body")
2079 .with_attribute("");
2080 invalid.registry.themes[0]
2081 .tokens
2082 .insert("bad".to_string(), "red".to_string());
2083 invalid.registry.themes[0]
2084 .tokens
2085 .insert("--unsafe".to_string(), "red;}body{display:none".to_string());
2086
2087 let report = invalid.validate();
2088 assert!(!report.is_valid());
2089 assert!(report.errors().count() >= 7);
2090 assert!(
2091 report
2092 .issues
2093 .iter()
2094 .any(|issue| issue.code == ThemeValidationCode::MissingDefaultTheme)
2095 );
2096 assert!(
2097 report
2098 .issues
2099 .iter()
2100 .any(|issue| issue.code == ThemeValidationCode::UnsafeTokenValue)
2101 );
2102 }
2103
2104 #[test]
2105 fn short_theme_builders_match_long_form_config() {
2106 let custom = theme_def("brand", "Brand")
2107 .scheme(ThemeColorScheme::Dark)
2108 .token(THEME_TOKEN_BG, "#111111");
2109 let config = theme()
2110 .add(custom)
2111 .default_theme("brand")
2112 .dur_ms(140)
2113 .ease("linear")
2114 .reduced(ThemeReducedMotion::Ignore)
2115 .preset(ThemeAnimationPreset::RadialWipe)
2116 .speed(180);
2117
2118 assert_eq!(config.default_theme, "brand");
2119 assert_eq!(config.duration_ms, 140);
2120 assert_eq!(config.easing, "linear");
2121 assert_eq!(config.reduced_motion, ThemeReducedMotion::Ignore);
2122 assert_eq!(config.animation_preset, ThemeAnimationPreset::RadialWipe);
2123 assert_eq!(config.animation_speed, 180);
2124 assert!(config.registry.ids().contains(&"brand"));
2125 }
2126
2127 #[test]
2128 fn route_policy_manifest_and_budget_report_track_theme_output() {
2129 let config = theme()
2130 .route_profile(ThemePresetProfile::Expressive)
2131 .theme(
2132 theme_def("brand", "Brand")
2133 .scheme(ThemeColorScheme::Dark)
2134 .token(THEME_TOKEN_ACCENT, "#22d3ee"),
2135 )
2136 .default_theme("brand");
2137 let policy = theme_route_policy()
2138 .route("/theme")
2139 .profile(ThemePresetProfile::Expressive)
2140 .emission(ThemeRuntimeEmission::PrepaintOnly)
2141 .serialization(ThemeSerializationFormat::CompactJson)
2142 .budget(theme_output_budget().config_bytes(4).theme_count(8))
2143 .label("owner", "design-system")
2144 .tag("tokens");
2145
2146 let manifest = config.manifest_fragment(&policy);
2147 let report = config.output_report(&policy);
2148 let hints = theme_native_port_hints(&config, &policy);
2149
2150 assert_eq!(manifest.package, THEME_PACKAGE_NAME);
2151 assert_eq!(manifest.route.as_deref(), Some("/theme"));
2152 assert_eq!(manifest.profile, ThemePresetProfile::Expressive);
2153 assert_eq!(manifest.emission, ThemeRuntimeEmission::PrepaintOnly);
2154 assert_eq!(manifest.metrics["themeCount"], report.theme_count as u64);
2155 assert_eq!(report.runtime_bytes, 0);
2156 assert!(
2157 report
2158 .violations
2159 .iter()
2160 .any(|violation| violation.field == "configBytes")
2161 );
2162 assert_eq!(hints["defaultTheme"], "brand");
2163 assert_eq!(
2164 config.cache_key(Some("/theme")),
2165 config.cache_key(Some("/theme"))
2166 );
2167 }
2168
2169 #[test]
2170 fn explain_report_matrix_and_hook_cover_visual_interop() {
2171 struct DropDisabled;
2172
2173 impl ThemeManifestPolicyHook for DropDisabled {
2174 fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment> {
2175 fragment.enabled.then_some(fragment)
2176 }
2177 }
2178
2179 let config = ThemeConfig::default();
2180 let enabled_policy = theme_route_policy().route("/theme").tag("hoverfx");
2181 let disabled_policy = theme_route_policy()
2182 .route("/theme/off")
2183 .enabled(false)
2184 .emission(ThemeRuntimeEmission::Disabled);
2185 let explain = explain_theme(&config, &enabled_policy);
2186 let matrix = theme_compatibility_matrix();
2187
2188 assert!(explain.validation.is_valid());
2189 assert!(explain.notes.iter().any(|note| note.contains("HoverFX")));
2190 assert!(matrix.rows.iter().any(|row| row.target == "native"));
2191 assert!(apply_theme_manifest_hook(&config, &enabled_policy, &DropDisabled).is_some());
2192 assert!(apply_theme_manifest_hook(&config, &disabled_policy, &DropDisabled).is_none());
2193 }
2194}