Skip to main content

dioprism_theme/core/
mod.rs

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}