Skip to main content

merman_render/svg/
theme_profile.rs

1use merman_core::MermaidConfig;
2use serde_json::{Map, Value};
3use std::sync::OnceLock;
4
5use super::pipeline::{
6    CssOverridePolicy, CssOverridePostprocessor, DropNativeDuplicateFallbacksPostprocessor,
7    GitGraphBranchLabelBaselinePostprocessor, RootBackgroundPostprocessor,
8    SanitizeCssPostprocessor, ScopedCssPostprocessor, SvgPipeline, SvgPipelinePreset,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum HostThemeAppearance {
13    #[default]
14    Light,
15    Dark,
16}
17
18impl HostThemeAppearance {
19    pub fn is_dark(self) -> bool {
20        matches!(self, Self::Dark)
21    }
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum HostThemePreset {
26    /// Neutral light editor preview palette.
27    #[default]
28    EditorLight,
29    /// Neutral dark editor preview palette.
30    EditorDark,
31    /// One Dark-inspired editor preview palette.
32    OneDark,
33    /// Gruvbox light-inspired editor preview palette.
34    GruvboxLight,
35    /// Gruvbox dark-inspired editor preview palette.
36    GruvboxDark,
37    /// Ayu light-inspired editor preview palette.
38    AyuLight,
39    /// Ayu dark-inspired editor preview palette.
40    AyuDark,
41}
42
43impl HostThemePreset {
44    /// All built-in host profile presets.
45    pub const ALL: [Self; 7] = [
46        Self::EditorLight,
47        Self::EditorDark,
48        Self::OneDark,
49        Self::GruvboxLight,
50        Self::GruvboxDark,
51        Self::AyuLight,
52        Self::AyuDark,
53    ];
54
55    /// Stable `host_theme.preset` value accepted by bindings.
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::EditorLight => "editor-light",
59            Self::EditorDark => "editor-dark",
60            Self::OneDark => "one-dark",
61            Self::GruvboxLight => "gruvbox-light",
62            Self::GruvboxDark => "gruvbox-dark",
63            Self::AyuLight => "ayu-light",
64            Self::AyuDark => "ayu-dark",
65        }
66    }
67}
68
69/// Returns built-in host/editor theme preset names.
70///
71/// These are semantic host presets such as `one-dark` and are intentionally separate from Mermaid
72/// core theme names returned by `merman_core::supported_themes()`.
73pub fn supported_host_theme_presets() -> &'static [&'static str] {
74    static NAMES: OnceLock<Vec<&'static str>> = OnceLock::new();
75    NAMES
76        .get_or_init(|| {
77            HostThemePreset::ALL
78                .iter()
79                .copied()
80                .map(HostThemePreset::as_str)
81                .collect()
82        })
83        .as_slice()
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
87pub struct HostThemeRoles {
88    pub canvas: Option<String>,
89    pub surface: Option<String>,
90    pub surface_alt: Option<String>,
91    pub surface_muted: Option<String>,
92    pub text: Option<String>,
93    pub subtle_text: Option<String>,
94    pub border: Option<String>,
95    pub line: Option<String>,
96    pub edge_label_background: Option<String>,
97    pub cluster_background: Option<String>,
98    pub cluster_border: Option<String>,
99    pub note_background: Option<String>,
100    pub note_border: Option<String>,
101    pub note_text: Option<String>,
102    pub actor_background: Option<String>,
103    pub actor_border: Option<String>,
104    pub actor_text: Option<String>,
105    pub activation_background: Option<String>,
106    pub activation_border: Option<String>,
107    pub error: Option<String>,
108    pub warning: Option<String>,
109    pub success: Option<String>,
110}
111
112impl HostThemeRoles {
113    fn has_values(&self) -> bool {
114        self.canvas.is_some()
115            || self.surface.is_some()
116            || self.surface_alt.is_some()
117            || self.surface_muted.is_some()
118            || self.text.is_some()
119            || self.subtle_text.is_some()
120            || self.border.is_some()
121            || self.line.is_some()
122            || self.edge_label_background.is_some()
123            || self.cluster_background.is_some()
124            || self.cluster_border.is_some()
125            || self.note_background.is_some()
126            || self.note_border.is_some()
127            || self.note_text.is_some()
128            || self.actor_background.is_some()
129            || self.actor_border.is_some()
130            || self.actor_text.is_some()
131            || self.activation_background.is_some()
132            || self.activation_border.is_some()
133            || self.error.is_some()
134            || self.warning.is_some()
135            || self.success.is_some()
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub enum HostThemePipelinePreset {
141    /// Keep Mermaid-parity SVG output.
142    #[default]
143    Parity,
144    /// Keep native `<foreignObject>` labels and add readable SVG text fallbacks.
145    ///
146    /// This is useful for consumers that need both browser-like SVG and non-HTML label fallbacks.
147    /// For browser/editor display surfaces, prefer [`Self::ResvgSafe`] if duplicate labels are a
148    /// risk.
149    Readable,
150    /// Add readable fallback text, remove native `<foreignObject>` labels, and sanitize common
151    /// rasterization hazards.
152    ResvgSafe,
153}
154
155impl From<HostThemePipelinePreset> for SvgPipelinePreset {
156    fn from(value: HostThemePipelinePreset) -> Self {
157        match value {
158            HostThemePipelinePreset::Parity => Self::Parity,
159            HostThemePipelinePreset::Readable => Self::Readable,
160            HostThemePipelinePreset::ResvgSafe => Self::ResvgSafe,
161        }
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
166pub enum HostThemeRootBackground {
167    #[default]
168    None,
169    Canvas,
170    Color(String),
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct HostThemeOutput {
175    pub pipeline: HostThemePipelinePreset,
176    pub css_override_policy: CssOverridePolicy,
177    pub root_background: HostThemeRootBackground,
178    pub drop_native_duplicate_fallbacks: bool,
179    pub scoped_css: Option<String>,
180}
181
182impl Default for HostThemeOutput {
183    fn default() -> Self {
184        Self {
185            pipeline: HostThemePipelinePreset::Parity,
186            css_override_policy: CssOverridePolicy::Preserve,
187            root_background: HostThemeRootBackground::None,
188            drop_native_duplicate_fallbacks: false,
189            scoped_css: None,
190        }
191    }
192}
193
194impl HostThemeOutput {
195    /// Returns product-neutral defaults for editor previews and raster-oriented host surfaces.
196    ///
197    /// The preset selects `resvg-safe` output, strips existing `!important` CSS so host theme rules
198    /// can win predictably, and uses the profile canvas as the root SVG background. Callers can
199    /// still add scoped CSS or override individual fields.
200    pub fn resvg_safe_editor() -> Self {
201        Self {
202            pipeline: HostThemePipelinePreset::ResvgSafe,
203            css_override_policy: CssOverridePolicy::StripExistingImportant,
204            root_background: HostThemeRootBackground::Canvas,
205            drop_native_duplicate_fallbacks: false,
206            scoped_css: None,
207        }
208    }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct HostThemeProfile {
213    pub appearance: HostThemeAppearance,
214    pub font_family: Option<String>,
215    pub font_size: Option<String>,
216    pub roles: HostThemeRoles,
217    pub series_palette: Vec<String>,
218    pub output: HostThemeOutput,
219    pub theme_variables: Map<String, Value>,
220    pub site_config: Map<String, Value>,
221}
222
223impl Default for HostThemeProfile {
224    fn default() -> Self {
225        Self {
226            appearance: HostThemeAppearance::Light,
227            font_family: None,
228            font_size: None,
229            roles: HostThemeRoles::default(),
230            series_palette: Vec::new(),
231            output: HostThemeOutput::default(),
232            theme_variables: Map::new(),
233            site_config: Map::new(),
234        }
235    }
236}
237
238impl HostThemeProfile {
239    pub fn builder() -> HostThemeProfileBuilder {
240        HostThemeProfileBuilder::default()
241    }
242
243    pub fn from_preset(preset: HostThemePreset) -> Self {
244        match preset {
245            HostThemePreset::EditorLight => Self::editor_light(),
246            HostThemePreset::EditorDark => Self::editor_dark(),
247            HostThemePreset::OneDark => Self::one_dark(),
248            HostThemePreset::GruvboxLight => Self::gruvbox_light(),
249            HostThemePreset::GruvboxDark => Self::gruvbox_dark(),
250            HostThemePreset::AyuLight => Self::ayu_light(),
251            HostThemePreset::AyuDark => Self::ayu_dark(),
252        }
253    }
254
255    pub fn editor_light() -> Self {
256        Self {
257            appearance: HostThemeAppearance::Light,
258            font_family: Some(
259                r#"Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif"#
260                    .to_string(),
261            ),
262            font_size: Some("14px".to_string()),
263            roles: HostThemeRoles {
264                canvas: Some("#ffffff".to_string()),
265                surface: Some("#f8fafc".to_string()),
266                surface_alt: Some("#e2e8f0".to_string()),
267                surface_muted: Some("#f1f5f9".to_string()),
268                text: Some("#0f172a".to_string()),
269                subtle_text: Some("#475569".to_string()),
270                border: Some("#94a3b8".to_string()),
271                line: Some("#64748b".to_string()),
272                edge_label_background: Some("#ffffff".to_string()),
273                cluster_background: Some("#f1f5f9".to_string()),
274                cluster_border: Some("#cbd5e1".to_string()),
275                note_background: Some("#fff7ed".to_string()),
276                note_border: Some("#fdba74".to_string()),
277                note_text: Some("#7c2d12".to_string()),
278                actor_background: Some("#f8fafc".to_string()),
279                actor_border: Some("#94a3b8".to_string()),
280                actor_text: Some("#0f172a".to_string()),
281                activation_background: Some("#e2e8f0".to_string()),
282                activation_border: Some("#94a3b8".to_string()),
283                error: Some("#dc2626".to_string()),
284                warning: Some("#d97706".to_string()),
285                success: Some("#059669".to_string()),
286            },
287            series_palette: vec![
288                "#2563eb".to_string(),
289                "#059669".to_string(),
290                "#d97706".to_string(),
291                "#7c3aed".to_string(),
292                "#0891b2".to_string(),
293                "#be123c".to_string(),
294                "#a16207".to_string(),
295                "#65a30d".to_string(),
296            ],
297            output: HostThemeOutput::resvg_safe_editor(),
298            theme_variables: Map::new(),
299            site_config: Map::new(),
300        }
301    }
302
303    pub fn editor_dark() -> Self {
304        Self {
305            appearance: HostThemeAppearance::Dark,
306            font_family: Some(
307                r#"Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif"#
308                    .to_string(),
309            ),
310            font_size: Some("14px".to_string()),
311            roles: HostThemeRoles {
312                canvas: Some("#0f172a".to_string()),
313                surface: Some("#111827".to_string()),
314                surface_alt: Some("#1f2937".to_string()),
315                surface_muted: Some("#334155".to_string()),
316                text: Some("#e5e7eb".to_string()),
317                subtle_text: Some("#cbd5e1".to_string()),
318                border: Some("#475569".to_string()),
319                line: Some("#94a3b8".to_string()),
320                edge_label_background: Some("#0f172a".to_string()),
321                cluster_background: Some("#1e293b".to_string()),
322                cluster_border: Some("#475569".to_string()),
323                note_background: Some("#422006".to_string()),
324                note_border: Some("#f59e0b".to_string()),
325                note_text: Some("#fef3c7".to_string()),
326                actor_background: Some("#1f2937".to_string()),
327                actor_border: Some("#475569".to_string()),
328                actor_text: Some("#e5e7eb".to_string()),
329                activation_background: Some("#334155".to_string()),
330                activation_border: Some("#64748b".to_string()),
331                error: Some("#f87171".to_string()),
332                warning: Some("#fbbf24".to_string()),
333                success: Some("#34d399".to_string()),
334            },
335            series_palette: vec![
336                "#60a5fa".to_string(),
337                "#34d399".to_string(),
338                "#f59e0b".to_string(),
339                "#c084fc".to_string(),
340                "#22d3ee".to_string(),
341                "#fb7185".to_string(),
342                "#facc15".to_string(),
343                "#a3e635".to_string(),
344            ],
345            output: HostThemeOutput::resvg_safe_editor(),
346            theme_variables: Map::new(),
347            site_config: Map::new(),
348        }
349    }
350
351    pub fn one_dark() -> Self {
352        Self::editor_profile(
353            HostThemeAppearance::Dark,
354            HostThemeRoles {
355                canvas: Some("#282c34".to_string()),
356                surface: Some("#21252b".to_string()),
357                surface_alt: Some("#2c313a".to_string()),
358                surface_muted: Some("#3e4451".to_string()),
359                text: Some("#abb2bf".to_string()),
360                subtle_text: Some("#828997".to_string()),
361                border: Some("#3e4451".to_string()),
362                line: Some("#61afef".to_string()),
363                edge_label_background: Some("#282c34".to_string()),
364                cluster_background: Some("#2c313a".to_string()),
365                cluster_border: Some("#3e4451".to_string()),
366                note_background: Some("#3a2f1b".to_string()),
367                note_border: Some("#e5c07b".to_string()),
368                note_text: Some("#f0dca4".to_string()),
369                actor_background: Some("#2c313a".to_string()),
370                actor_border: Some("#3e4451".to_string()),
371                actor_text: Some("#abb2bf".to_string()),
372                activation_background: Some("#3e4451".to_string()),
373                activation_border: Some("#5c6370".to_string()),
374                error: Some("#e06c75".to_string()),
375                warning: Some("#e5c07b".to_string()),
376                success: Some("#98c379".to_string()),
377            },
378            [
379                "#61afef", "#98c379", "#e5c07b", "#c678dd", "#56b6c2", "#e06c75", "#d19a66",
380                "#be5046",
381            ],
382            HostThemeOutput::resvg_safe_editor(),
383        )
384    }
385
386    pub fn gruvbox_light() -> Self {
387        Self::editor_profile(
388            HostThemeAppearance::Light,
389            HostThemeRoles {
390                canvas: Some("#fbf1c7".to_string()),
391                surface: Some("#f2e5bc".to_string()),
392                surface_alt: Some("#ebdbb2".to_string()),
393                surface_muted: Some("#d5c4a1".to_string()),
394                text: Some("#3c3836".to_string()),
395                subtle_text: Some("#665c54".to_string()),
396                border: Some("#d5c4a1".to_string()),
397                line: Some("#7c6f64".to_string()),
398                edge_label_background: Some("#fbf1c7".to_string()),
399                cluster_background: Some("#ebdbb2".to_string()),
400                cluster_border: Some("#d5c4a1".to_string()),
401                note_background: Some("#f2e5bc".to_string()),
402                note_border: Some("#d79921".to_string()),
403                note_text: Some("#3c3836".to_string()),
404                actor_background: Some("#ebdbb2".to_string()),
405                actor_border: Some("#d5c4a1".to_string()),
406                actor_text: Some("#3c3836".to_string()),
407                activation_background: Some("#d5c4a1".to_string()),
408                activation_border: Some("#bdae93".to_string()),
409                error: Some("#cc241d".to_string()),
410                warning: Some("#d79921".to_string()),
411                success: Some("#98971a".to_string()),
412            },
413            [
414                "#458588", "#98971a", "#d79921", "#b16286", "#689d6a", "#cc241d", "#d65d0e",
415                "#427b58",
416            ],
417            HostThemeOutput::resvg_safe_editor(),
418        )
419    }
420
421    pub fn gruvbox_dark() -> Self {
422        Self::editor_profile(
423            HostThemeAppearance::Dark,
424            HostThemeRoles {
425                canvas: Some("#282828".to_string()),
426                surface: Some("#3c3836".to_string()),
427                surface_alt: Some("#504945".to_string()),
428                surface_muted: Some("#665c54".to_string()),
429                text: Some("#ebdbb2".to_string()),
430                subtle_text: Some("#d5c4a1".to_string()),
431                border: Some("#665c54".to_string()),
432                line: Some("#d5c4a1".to_string()),
433                edge_label_background: Some("#282828".to_string()),
434                cluster_background: Some("#3c3836".to_string()),
435                cluster_border: Some("#665c54".to_string()),
436                note_background: Some("#3c3836".to_string()),
437                note_border: Some("#fabd2f".to_string()),
438                note_text: Some("#fbf1c7".to_string()),
439                actor_background: Some("#3c3836".to_string()),
440                actor_border: Some("#665c54".to_string()),
441                actor_text: Some("#ebdbb2".to_string()),
442                activation_background: Some("#504945".to_string()),
443                activation_border: Some("#7c6f64".to_string()),
444                error: Some("#fb4934".to_string()),
445                warning: Some("#fabd2f".to_string()),
446                success: Some("#b8bb26".to_string()),
447            },
448            [
449                "#83a598", "#b8bb26", "#fabd2f", "#d3869b", "#8ec07c", "#fb4934", "#fe8019",
450                "#689d6a",
451            ],
452            HostThemeOutput::resvg_safe_editor(),
453        )
454    }
455
456    pub fn ayu_light() -> Self {
457        Self::editor_profile(
458            HostThemeAppearance::Light,
459            HostThemeRoles {
460                canvas: Some("#fafafa".to_string()),
461                surface: Some("#f3f4f5".to_string()),
462                surface_alt: Some("#e6e8eb".to_string()),
463                surface_muted: Some("#d9d7ce".to_string()),
464                text: Some("#5c6773".to_string()),
465                subtle_text: Some("#8a9199".to_string()),
466                border: Some("#d9d7ce".to_string()),
467                line: Some("#55b4d4".to_string()),
468                edge_label_background: Some("#fafafa".to_string()),
469                cluster_background: Some("#f3f4f5".to_string()),
470                cluster_border: Some("#d9d7ce".to_string()),
471                note_background: Some("#fff3bf".to_string()),
472                note_border: Some("#ffaa33".to_string()),
473                note_text: Some("#5c6773".to_string()),
474                actor_background: Some("#f3f4f5".to_string()),
475                actor_border: Some("#d9d7ce".to_string()),
476                actor_text: Some("#5c6773".to_string()),
477                activation_background: Some("#e6e8eb".to_string()),
478                activation_border: Some("#d9d7ce".to_string()),
479                error: Some("#f07171".to_string()),
480                warning: Some("#ffaa33".to_string()),
481                success: Some("#86b300".to_string()),
482            },
483            [
484                "#55b4d4", "#86b300", "#ffaa33", "#a37acc", "#4cbf99", "#f07171", "#f2ae49",
485                "#399ee6",
486            ],
487            HostThemeOutput::resvg_safe_editor(),
488        )
489    }
490
491    pub fn ayu_dark() -> Self {
492        Self::editor_profile(
493            HostThemeAppearance::Dark,
494            HostThemeRoles {
495                canvas: Some("#0b0e14".to_string()),
496                surface: Some("#11151c".to_string()),
497                surface_alt: Some("#1f2430".to_string()),
498                surface_muted: Some("#343b48".to_string()),
499                text: Some("#bfbdb6".to_string()),
500                subtle_text: Some("#8a9199".to_string()),
501                border: Some("#343b48".to_string()),
502                line: Some("#59c2ff".to_string()),
503                edge_label_background: Some("#0b0e14".to_string()),
504                cluster_background: Some("#1f2430".to_string()),
505                cluster_border: Some("#343b48".to_string()),
506                note_background: Some("#332a14".to_string()),
507                note_border: Some("#ffb454".to_string()),
508                note_text: Some("#ffdf99".to_string()),
509                actor_background: Some("#1f2430".to_string()),
510                actor_border: Some("#343b48".to_string()),
511                actor_text: Some("#bfbdb6".to_string()),
512                activation_background: Some("#343b48".to_string()),
513                activation_border: Some("#4f5866".to_string()),
514                error: Some("#f07178".to_string()),
515                warning: Some("#ffb454".to_string()),
516                success: Some("#aad94c".to_string()),
517            },
518            [
519                "#59c2ff", "#aad94c", "#ffb454", "#d2a6ff", "#95e6cb", "#f07178", "#f29668",
520                "#39bae6",
521            ],
522            HostThemeOutput::resvg_safe_editor(),
523        )
524    }
525
526    fn editor_profile<const N: usize>(
527        appearance: HostThemeAppearance,
528        roles: HostThemeRoles,
529        palette: [&str; N],
530        output: HostThemeOutput,
531    ) -> Self {
532        Self {
533            appearance,
534            font_family: Some(
535                r#"Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif"#
536                    .to_string(),
537            ),
538            font_size: Some("14px".to_string()),
539            roles,
540            series_palette: palette.iter().map(|color| color.to_string()).collect(),
541            output,
542            theme_variables: Map::new(),
543            site_config: Map::new(),
544        }
545    }
546
547    pub fn compile(&self) -> CompiledHostTheme {
548        let mut root = Map::new();
549
550        let mut theme_variables = Map::new();
551        let has_profile_theme_input = self.appearance.is_dark()
552            || self.font_family.is_some()
553            || self.font_size.is_some()
554            || self.roles.has_values()
555            || !self.series_palette.is_empty()
556            || !self.theme_variables.is_empty();
557
558        if has_profile_theme_input {
559            root.insert("theme".to_string(), Value::String("base".to_string()));
560            root.insert(
561                "darkMode".to_string(),
562                Value::Bool(self.appearance.is_dark()),
563            );
564            theme_variables.insert(
565                "darkMode".to_string(),
566                Value::Bool(self.appearance.is_dark()),
567            );
568        }
569
570        if let Some(font_family) = self.font_family.as_deref().filter(|s| !s.trim().is_empty()) {
571            root.insert(
572                "fontFamily".to_string(),
573                Value::String(font_family.trim().to_string()),
574            );
575            put_str(&mut theme_variables, "fontFamily", font_family);
576        }
577        if let Some(font_size) = self.font_size.as_deref().filter(|s| !s.trim().is_empty()) {
578            put_str(&mut theme_variables, "fontSize", font_size);
579        }
580
581        let resolved_roles = ResolvedHostThemeRoles::new(&self.roles);
582        put_theme_roles(&mut theme_variables, &resolved_roles);
583        put_series_palette(&mut theme_variables, &self.series_palette);
584        put_diagram_config(
585            &mut root,
586            &mut theme_variables,
587            &resolved_roles,
588            &self.series_palette,
589        );
590
591        merge_object(&mut theme_variables, &self.theme_variables);
592        if !theme_variables.is_empty() {
593            root.insert("themeVariables".to_string(), Value::Object(theme_variables));
594        }
595        merge_object(&mut root, &self.site_config);
596
597        let canvas_color = self
598            .roles
599            .canvas
600            .as_deref()
601            .filter(|s| !s.trim().is_empty())
602            .map(str::trim)
603            .map(str::to_string);
604
605        CompiledHostTheme {
606            site_config: MermaidConfig::from_value(Value::Object(root)),
607            output: CompiledHostThemeOutput {
608                preset: self.output.pipeline.into(),
609                css_override_policy: self.output.css_override_policy,
610                root_background_color: match &self.output.root_background {
611                    HostThemeRootBackground::None => None,
612                    HostThemeRootBackground::Canvas => canvas_color,
613                    HostThemeRootBackground::Color(color) => Some(color.clone()),
614                },
615                drop_native_duplicate_fallbacks: self.output.drop_native_duplicate_fallbacks,
616                scoped_css: self.output.scoped_css.clone(),
617            },
618        }
619    }
620}
621
622#[derive(Debug, Clone, Default)]
623pub struct HostThemeProfileBuilder {
624    profile: HostThemeProfile,
625}
626
627impl HostThemeProfileBuilder {
628    pub fn appearance(mut self, appearance: HostThemeAppearance) -> Self {
629        self.profile.appearance = appearance;
630        self
631    }
632
633    pub fn font_family(mut self, font_family: impl Into<String>) -> Self {
634        self.profile.font_family = Some(font_family.into());
635        self
636    }
637
638    pub fn font_size(mut self, font_size: impl Into<String>) -> Self {
639        self.profile.font_size = Some(font_size.into());
640        self
641    }
642
643    pub fn roles(mut self, roles: HostThemeRoles) -> Self {
644        self.profile.roles = roles;
645        self
646    }
647
648    pub fn series_palette(mut self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
649        self.profile.series_palette = palette.into_iter().map(Into::into).collect();
650        self
651    }
652
653    pub fn output(mut self, output: HostThemeOutput) -> Self {
654        self.profile.output = output;
655        self
656    }
657
658    pub fn theme_variable(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
659        self.profile
660            .theme_variables
661            .insert(key.into(), value.into());
662        self
663    }
664
665    pub fn site_config(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
666        self.profile.site_config.insert(key.into(), value.into());
667        self
668    }
669
670    pub fn build(self) -> HostThemeProfile {
671        self.profile
672    }
673}
674
675#[derive(Debug, Clone)]
676pub struct CompiledHostTheme {
677    pub site_config: MermaidConfig,
678    pub output: CompiledHostThemeOutput,
679}
680
681impl CompiledHostTheme {
682    pub fn into_parts(self) -> (MermaidConfig, CompiledHostThemeOutput) {
683        (self.site_config, self.output)
684    }
685
686    pub fn pipeline(&self) -> SvgPipeline {
687        self.output.pipeline()
688    }
689}
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct CompiledHostThemeOutput {
693    pub preset: SvgPipelinePreset,
694    pub css_override_policy: CssOverridePolicy,
695    pub root_background_color: Option<String>,
696    pub drop_native_duplicate_fallbacks: bool,
697    pub scoped_css: Option<String>,
698}
699
700impl CompiledHostThemeOutput {
701    pub fn pipeline(&self) -> SvgPipeline {
702        let mut pipeline = SvgPipeline::from_preset(self.preset);
703
704        if matches!(
705            self.css_override_policy,
706            CssOverridePolicy::StripExistingImportant
707        ) {
708            pipeline.push_postprocessor(CssOverridePostprocessor::strip_existing_important());
709        }
710
711        if self.drop_native_duplicate_fallbacks
712            && !matches!(self.preset, SvgPipelinePreset::ResvgSafe)
713        {
714            pipeline.push_postprocessor(DropNativeDuplicateFallbacksPostprocessor);
715        }
716
717        if matches!(self.preset, SvgPipelinePreset::ResvgSafe) {
718            pipeline.push_postprocessor(GitGraphBranchLabelBaselinePostprocessor);
719        }
720
721        if let Some(color) = self
722            .root_background_color
723            .as_deref()
724            .filter(|color| !color.trim().is_empty())
725        {
726            pipeline.push_postprocessor(RootBackgroundPostprocessor::new(color.trim()));
727        }
728
729        if let Some(css) = self
730            .scoped_css
731            .as_deref()
732            .filter(|css| !css.trim().is_empty())
733        {
734            pipeline.push_postprocessor(
735                ScopedCssPostprocessor::new(css.to_string())
736                    .with_override_policy(self.css_override_policy),
737            );
738            if matches!(self.preset, SvgPipelinePreset::ResvgSafe) {
739                pipeline.push_postprocessor(SanitizeCssPostprocessor);
740            }
741        }
742
743        pipeline
744    }
745}
746
747#[derive(Debug, Clone, Copy)]
748struct ResolvedHostThemeRoles<'a> {
749    canvas: Option<&'a str>,
750    surface: Option<&'a str>,
751    surface_alt: Option<&'a str>,
752    surface_muted: Option<&'a str>,
753    text: Option<&'a str>,
754    subtle_text: Option<&'a str>,
755    border: Option<&'a str>,
756    line: Option<&'a str>,
757    edge_label_background: Option<&'a str>,
758    commit_label_background: Option<&'a str>,
759    cluster_background: Option<&'a str>,
760    swimlane_background_odd: Option<&'a str>,
761    cluster_border: Option<&'a str>,
762    note_background: Option<&'a str>,
763    note_border: Option<&'a str>,
764    note_text: Option<&'a str>,
765    actor_background: Option<&'a str>,
766    actor_border: Option<&'a str>,
767    actor_text: Option<&'a str>,
768    activation_background: Option<&'a str>,
769    activation_border: Option<&'a str>,
770    error: Option<&'a str>,
771    warning: Option<&'a str>,
772    success: Option<&'a str>,
773}
774
775impl<'a> ResolvedHostThemeRoles<'a> {
776    fn new(roles: &'a HostThemeRoles) -> Self {
777        let canvas = roles.canvas.as_deref();
778        let surface = roles.surface.as_deref();
779        let surface_alt = roles.surface_alt.as_deref().or(surface);
780        let surface_muted = roles.surface_muted.as_deref().or(surface_alt);
781        let text = roles.text.as_deref();
782        let subtle_text = roles.subtle_text.as_deref().or(text);
783        let border = roles.border.as_deref();
784        let line = roles.line.as_deref().or(border);
785
786        Self {
787            canvas,
788            surface,
789            surface_alt,
790            surface_muted,
791            text,
792            subtle_text,
793            border,
794            line,
795            edge_label_background: roles.edge_label_background.as_deref().or(canvas),
796            commit_label_background: roles.edge_label_background.as_deref().or(surface),
797            cluster_background: roles.cluster_background.as_deref().or(surface_alt),
798            swimlane_background_odd: roles.cluster_background.as_deref().or(surface_muted),
799            cluster_border: roles.cluster_border.as_deref().or(border),
800            note_background: roles.note_background.as_deref().or(surface_alt),
801            note_border: roles.note_border.as_deref().or(border),
802            note_text: roles.note_text.as_deref().or(text),
803            actor_background: roles.actor_background.as_deref().or(surface_alt),
804            actor_border: roles.actor_border.as_deref().or(border),
805            actor_text: roles.actor_text.as_deref().or(text),
806            activation_background: roles.activation_background.as_deref().or(surface_muted),
807            activation_border: roles.activation_border.as_deref().or(border),
808            error: roles.error.as_deref(),
809            warning: roles.warning.as_deref(),
810            success: roles.success.as_deref(),
811        }
812    }
813}
814
815fn put_theme_roles(theme_variables: &mut Map<String, Value>, roles: &ResolvedHostThemeRoles<'_>) {
816    put_opt(theme_variables, "background", roles.canvas);
817    put_opt(theme_variables, "primaryColor", roles.surface);
818    put_opt(theme_variables, "mainBkg", roles.surface);
819    put_opt(theme_variables, "secondaryColor", roles.surface_alt);
820    put_opt(theme_variables, "tertiaryColor", roles.surface_muted);
821    put_opt(theme_variables, "primaryTextColor", roles.text);
822    put_opt(theme_variables, "nodeTextColor", roles.text);
823    put_opt(theme_variables, "textColor", roles.text);
824    put_opt(theme_variables, "titleColor", roles.text);
825    put_opt(theme_variables, "secondaryTextColor", roles.subtle_text);
826    put_opt(theme_variables, "tertiaryTextColor", roles.subtle_text);
827    put_opt(theme_variables, "primaryBorderColor", roles.border);
828    put_opt(theme_variables, "nodeBorder", roles.border);
829    put_opt(theme_variables, "lineColor", roles.line);
830    put_opt(theme_variables, "arrowheadColor", roles.line);
831    put_opt(
832        theme_variables,
833        "edgeLabelBackground",
834        roles.edge_label_background,
835    );
836
837    put_opt(theme_variables, "clusterBkg", roles.cluster_background);
838    put_opt(theme_variables, "clusterBorder", roles.cluster_border);
839
840    put_opt(theme_variables, "noteBkgColor", roles.note_background);
841    put_opt(theme_variables, "noteBorderColor", roles.note_border);
842    put_opt(theme_variables, "noteTextColor", roles.note_text);
843
844    put_opt(theme_variables, "actorBkg", roles.actor_background);
845    put_opt(theme_variables, "actorBorder", roles.actor_border);
846    put_opt(theme_variables, "actorTextColor", roles.actor_text);
847    put_opt(theme_variables, "actorLineColor", roles.line);
848    put_opt(theme_variables, "signalColor", roles.line.or(roles.text));
849    put_opt(theme_variables, "signalTextColor", roles.text);
850    put_opt(theme_variables, "labelTextColor", roles.text);
851    put_opt(theme_variables, "loopTextColor", roles.text);
852    put_opt(theme_variables, "labelBoxBkgColor", roles.surface_alt);
853    put_opt(theme_variables, "labelBoxBorderColor", roles.border);
854    put_opt(
855        theme_variables,
856        "activationBkgColor",
857        roles.activation_background,
858    );
859    put_opt(
860        theme_variables,
861        "activationBorderColor",
862        roles.activation_border,
863    );
864
865    put_opt(theme_variables, "classText", roles.text);
866    put_opt(theme_variables, "labelColor", roles.text);
867    put_opt(theme_variables, "transitionColor", roles.line);
868    put_opt(theme_variables, "transitionLabelColor", roles.text);
869    put_opt(theme_variables, "stateLabelColor", roles.text);
870    put_opt(theme_variables, "stateBkg", roles.surface);
871    put_opt(theme_variables, "stateBorder", roles.border);
872    put_opt(theme_variables, "specialStateColor", roles.line);
873    put_opt(
874        theme_variables,
875        "compositeBackground",
876        roles.canvas.or(roles.surface),
877    );
878
879    put_opt(
880        theme_variables,
881        "attributeBackgroundColorOdd",
882        roles.surface,
883    );
884    put_opt(
885        theme_variables,
886        "attributeBackgroundColorEven",
887        roles.surface_alt,
888    );
889    put_opt(theme_variables, "rowOdd", roles.surface);
890    put_opt(theme_variables, "rowEven", roles.surface_alt);
891
892    put_opt(theme_variables, "requirementBackground", roles.surface);
893    put_opt(theme_variables, "requirementBorderColor", roles.border);
894    put_opt(theme_variables, "requirementTextColor", roles.text);
895    put_opt(theme_variables, "relationColor", roles.line);
896    put_opt(
897        theme_variables,
898        "relationLabelBackground",
899        roles.edge_label_background,
900    );
901    put_opt(theme_variables, "relationLabelColor", roles.text);
902    put_opt(
903        theme_variables,
904        "requirementEdgeLabelBackground",
905        roles.edge_label_background,
906    );
907
908    put_opt(theme_variables, "pieTitleTextColor", roles.text);
909    put_opt(theme_variables, "pieSectionTextColor", roles.text);
910    put_opt(theme_variables, "pieLegendTextColor", roles.subtle_text);
911    put_opt(theme_variables, "pieStrokeColor", roles.border);
912    put_opt(theme_variables, "pieOuterStrokeColor", roles.border);
913
914    put_opt(theme_variables, "commitLabelColor", roles.text);
915    put_opt(
916        theme_variables,
917        "commitLabelBackground",
918        roles.commit_label_background,
919    );
920    put_opt(theme_variables, "commitLineColor", roles.line);
921    put_opt(theme_variables, "tagLabelColor", roles.text);
922    put_opt(theme_variables, "tagLabelBackground", roles.surface);
923    put_opt(theme_variables, "tagLabelBorder", roles.border);
924
925    put_opt(theme_variables, "quadrant1Fill", roles.surface);
926    put_opt(theme_variables, "quadrant2Fill", roles.surface_alt);
927    put_opt(
928        theme_variables,
929        "quadrant3Fill",
930        roles.canvas.or(roles.surface),
931    );
932    put_opt(theme_variables, "quadrant4Fill", roles.surface_muted);
933    put_opt(theme_variables, "quadrant1TextFill", roles.text);
934    put_opt(theme_variables, "quadrant2TextFill", roles.text);
935    put_opt(theme_variables, "quadrant3TextFill", roles.text);
936    put_opt(theme_variables, "quadrant4TextFill", roles.text);
937    put_opt(theme_variables, "quadrantPointFill", roles.line);
938    put_opt(theme_variables, "quadrantPointTextFill", roles.text);
939    put_opt(theme_variables, "quadrantTitleFill", roles.text);
940    put_opt(theme_variables, "quadrantXAxisTextFill", roles.subtle_text);
941    put_opt(theme_variables, "quadrantYAxisTextFill", roles.subtle_text);
942    put_opt(
943        theme_variables,
944        "quadrantExternalBorderStrokeFill",
945        roles.border,
946    );
947    put_opt(
948        theme_variables,
949        "quadrantInternalBorderStrokeFill",
950        roles.border,
951    );
952
953    put_opt(theme_variables, "archEdgeColor", roles.line);
954    put_opt(theme_variables, "archEdgeArrowColor", roles.line);
955    put_opt(
956        theme_variables,
957        "archGroupBorderColor",
958        roles.cluster_border,
959    );
960
961    put_opt(theme_variables, "emUiFill", roles.surface);
962    put_opt(theme_variables, "emUiStroke", roles.border);
963    put_opt(theme_variables, "emRelationStroke", roles.line);
964    put_opt(theme_variables, "emArrowhead", roles.line);
965    put_opt(
966        theme_variables,
967        "emSwimlaneBackgroundOdd",
968        roles.swimlane_background_odd,
969    );
970    put_opt(
971        theme_variables,
972        "emSwimlaneBackgroundStroke",
973        roles.cluster_border,
974    );
975
976    put_opt(theme_variables, "taskTextDarkColor", roles.text);
977    put_opt(theme_variables, "taskTextClickableColor", roles.line);
978    put_opt(theme_variables, "taskTextColor", roles.text);
979    put_opt(theme_variables, "taskTextOutsideColor", roles.subtle_text);
980    put_opt(theme_variables, "taskBkgColor", roles.surface);
981    put_opt(theme_variables, "taskBorderColor", roles.border);
982    put_opt(theme_variables, "activeTaskBkgColor", roles.surface_muted);
983    put_opt(theme_variables, "activeTaskBorderColor", roles.line);
984    put_opt(
985        theme_variables,
986        "doneTaskBkgColor",
987        roles.success.or(roles.surface_alt),
988    );
989    put_opt(
990        theme_variables,
991        "doneTaskBorderColor",
992        roles.success.or(roles.border),
993    );
994    put_opt(theme_variables, "critBkgColor", roles.error);
995    put_opt(
996        theme_variables,
997        "critBorderColor",
998        roles.error.or(roles.border),
999    );
1000    put_opt(theme_variables, "excludeBkgColor", roles.surface_alt);
1001    put_opt(theme_variables, "gridColor", roles.border);
1002    put_opt(
1003        theme_variables,
1004        "todayLineColor",
1005        roles.warning.or(roles.error).or(roles.line),
1006    );
1007    put_opt(
1008        theme_variables,
1009        "vertLineColor",
1010        roles.warning.or(roles.line),
1011    );
1012    put_opt(
1013        theme_variables,
1014        "sectionBkgColor",
1015        roles.cluster_background.or(roles.surface_alt),
1016    );
1017    put_opt(theme_variables, "sectionBkgColor2", roles.surface_muted);
1018    put_opt(theme_variables, "altSectionBkgColor", roles.canvas);
1019
1020    put_opt(theme_variables, "errorBkgColor", roles.error);
1021    put_opt(theme_variables, "errorTextColor", roles.text);
1022
1023    put_opt(theme_variables, "faceColor", roles.surface);
1024    put_opt(theme_variables, "border2", roles.cluster_border);
1025}
1026
1027fn put_series_palette(theme_variables: &mut Map<String, Value>, palette: &[String]) {
1028    if palette.is_empty() {
1029        return;
1030    }
1031
1032    let mut xy = Map::new();
1033    xy.insert(
1034        "plotColorPalette".to_string(),
1035        Value::String(palette.join(",")),
1036    );
1037    xy.insert("accentColor".to_string(), Value::String(palette[0].clone()));
1038    theme_variables.insert("xyChart".to_string(), Value::Object(xy));
1039
1040    for (index, color) in palette.iter().enumerate() {
1041        let label = readable_text_color(color);
1042        put_str(theme_variables, &format!("cScale{index}"), color);
1043        put_str(theme_variables, &format!("cScalePeer{index}"), color);
1044        put_str(theme_variables, &format!("cScaleLabel{index}"), &label);
1045        put_str(theme_variables, &format!("cScaleInv{index}"), &label);
1046        put_str(theme_variables, &format!("git{index}"), color);
1047        put_str(theme_variables, &format!("gitBranchLabel{index}"), &label);
1048        put_str(theme_variables, &format!("pie{}", index + 1), color);
1049        put_str(theme_variables, &format!("venn{}", index + 1), color);
1050        put_str(theme_variables, &format!("fillType{index}"), color);
1051        put_str(theme_variables, &format!("actor{index}"), color);
1052    }
1053}
1054
1055fn put_diagram_config(
1056    root: &mut Map<String, Value>,
1057    theme_variables: &mut Map<String, Value>,
1058    roles: &ResolvedHostThemeRoles<'_>,
1059    palette: &[String],
1060) {
1061    let mut packet = Map::new();
1062    put_opt(&mut packet, "startByteColor", roles.line);
1063    put_opt(&mut packet, "endByteColor", roles.border.or(roles.line));
1064    put_opt(&mut packet, "labelColor", roles.text);
1065    put_opt(&mut packet, "titleColor", roles.text);
1066    put_opt(&mut packet, "blockStrokeColor", roles.border);
1067    put_opt(&mut packet, "blockFillColor", roles.surface);
1068    put_nonempty_object(root, "packet", packet);
1069
1070    let mut treemap = Map::new();
1071    put_opt(&mut treemap, "titleColor", roles.text);
1072    put_opt(&mut treemap, "labelColor", roles.text);
1073    put_opt(&mut treemap, "valueColor", roles.subtle_text);
1074    put_opt(&mut treemap, "sectionStrokeColor", roles.border);
1075    put_opt(&mut treemap, "sectionFillColor", roles.surface_alt);
1076    put_opt(&mut treemap, "leafStrokeColor", roles.border);
1077    put_opt(&mut treemap, "leafFillColor", roles.surface);
1078    put_nonempty_object(root, "treemap", treemap);
1079
1080    let mut tree_view = Map::new();
1081    put_opt(&mut tree_view, "labelColor", roles.text);
1082    put_opt(&mut tree_view, "lineColor", roles.line);
1083    if !tree_view.is_empty() {
1084        let entry = theme_variables.get("treeView");
1085        let mut merged = entry
1086            .and_then(Value::as_object)
1087            .cloned()
1088            .unwrap_or_default();
1089        merge_object(&mut merged, &tree_view);
1090        theme_variables.insert("treeView".to_string(), Value::Object(merged));
1091    }
1092
1093    let mut radar = Map::new();
1094    put_opt(&mut radar, "axisColor", roles.line);
1095    put_opt(&mut radar, "graticuleColor", roles.border);
1096    put_nonempty_object(root, "radar", radar);
1097
1098    let mut eventmodeling = Map::new();
1099    put_opt(
1100        &mut eventmodeling,
1101        "emProcessorFill",
1102        palette.get(3).map(String::as_str).or(roles.surface_alt),
1103    );
1104    put_opt(&mut eventmodeling, "emProcessorStroke", roles.border);
1105    put_opt(
1106        &mut eventmodeling,
1107        "emReadModelFill",
1108        palette
1109            .get(1)
1110            .map(String::as_str)
1111            .or(roles.success)
1112            .or(roles.surface_alt),
1113    );
1114    put_opt(
1115        &mut eventmodeling,
1116        "emReadModelStroke",
1117        roles.success.or(roles.border),
1118    );
1119    put_opt(
1120        &mut eventmodeling,
1121        "emCommandFill",
1122        palette.first().map(String::as_str).or(roles.surface_alt),
1123    );
1124    put_opt(
1125        &mut eventmodeling,
1126        "emCommandStroke",
1127        roles.line.or(roles.border),
1128    );
1129    put_opt(
1130        &mut eventmodeling,
1131        "emEventFill",
1132        palette
1133            .get(2)
1134            .map(String::as_str)
1135            .or(roles.warning)
1136            .or(roles.surface_alt),
1137    );
1138    put_opt(
1139        &mut eventmodeling,
1140        "emEventStroke",
1141        roles.warning.or(roles.border),
1142    );
1143    for (key, value) in eventmodeling {
1144        theme_variables.insert(key, value);
1145    }
1146
1147    let mut c4 = Map::new();
1148    for prefix in [
1149        "person",
1150        "system",
1151        "system_db",
1152        "system_queue",
1153        "container",
1154        "container_db",
1155        "container_queue",
1156        "component",
1157        "component_db",
1158        "component_queue",
1159        "external_person",
1160        "external_system",
1161        "external_system_db",
1162        "external_system_queue",
1163        "external_container",
1164        "external_container_db",
1165        "external_container_queue",
1166        "external_component",
1167        "external_component_db",
1168        "external_component_queue",
1169    ] {
1170        put_opt(&mut c4, &format!("{prefix}_bg_color"), roles.surface);
1171        put_opt(&mut c4, &format!("{prefix}_border_color"), roles.border);
1172    }
1173    put_nonempty_object(root, "c4", c4);
1174}
1175
1176fn put_nonempty_object(root: &mut Map<String, Value>, key: &str, object: Map<String, Value>) {
1177    if !object.is_empty() {
1178        root.insert(key.to_string(), Value::Object(object));
1179    }
1180}
1181
1182fn put_opt(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
1183    if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1184        put_str(map, key, value);
1185    }
1186}
1187
1188fn put_str(map: &mut Map<String, Value>, key: &str, value: &str) {
1189    map.insert(key.to_string(), Value::String(value.trim().to_string()));
1190}
1191
1192fn merge_object(target: &mut Map<String, Value>, source: &Map<String, Value>) {
1193    for (key, value) in source {
1194        target.insert(key.clone(), value.clone());
1195    }
1196}
1197
1198fn readable_text_color(color: &str) -> String {
1199    let Some((r, g, b)) = parse_hex_rgb(color) else {
1200        return "#ffffff".to_string();
1201    };
1202    let luminance = relative_luminance(r, g, b);
1203    if luminance > 0.45 {
1204        "#000000".to_string()
1205    } else {
1206        "#ffffff".to_string()
1207    }
1208}
1209
1210fn parse_hex_rgb(color: &str) -> Option<(f64, f64, f64)> {
1211    let raw = color.trim().strip_prefix('#')?;
1212    if raw.len() != 6 || !raw.chars().all(|ch| ch.is_ascii_hexdigit()) {
1213        return None;
1214    }
1215    let r = u8::from_str_radix(&raw[0..2], 16).ok()? as f64 / 255.0;
1216    let g = u8::from_str_radix(&raw[2..4], 16).ok()? as f64 / 255.0;
1217    let b = u8::from_str_radix(&raw[4..6], 16).ok()? as f64 / 255.0;
1218    Some((r, g, b))
1219}
1220
1221fn relative_luminance(r: f64, g: f64, b: f64) -> f64 {
1222    fn linear(channel: f64) -> f64 {
1223        if channel <= 0.04045 {
1224            channel / 12.92
1225        } else {
1226            ((channel + 0.055) / 1.055).powf(2.4)
1227        }
1228    }
1229    0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235
1236    fn sentinel_roles() -> HostThemeRoles {
1237        HostThemeRoles {
1238            canvas: Some("#010101".to_string()),
1239            surface: Some("#020202".to_string()),
1240            surface_alt: Some("#030303".to_string()),
1241            surface_muted: Some("#040404".to_string()),
1242            text: Some("#050505".to_string()),
1243            subtle_text: Some("#060606".to_string()),
1244            border: Some("#070707".to_string()),
1245            line: Some("#080808".to_string()),
1246            edge_label_background: Some("#090909".to_string()),
1247            cluster_background: Some("#0a0a0a".to_string()),
1248            cluster_border: Some("#0b0b0b".to_string()),
1249            note_background: Some("#0c0c0c".to_string()),
1250            note_border: Some("#0d0d0d".to_string()),
1251            note_text: Some("#0e0e0e".to_string()),
1252            actor_background: Some("#0f0f0f".to_string()),
1253            actor_border: Some("#101010".to_string()),
1254            actor_text: Some("#111111".to_string()),
1255            activation_background: Some("#121212".to_string()),
1256            activation_border: Some("#131313".to_string()),
1257            error: Some("#141414".to_string()),
1258            warning: Some("#151515".to_string()),
1259            success: Some("#161616".to_string()),
1260        }
1261    }
1262
1263    fn compiled_sentinel_config() -> Value {
1264        HostThemeProfile::builder()
1265            .roles(sentinel_roles())
1266            .build()
1267            .compile()
1268            .site_config
1269            .as_value()
1270            .clone()
1271    }
1272
1273    #[test]
1274    fn dark_editor_profile_compiles_common_theme_variables() {
1275        let compiled = HostThemeProfile::editor_dark().compile();
1276        let cfg = compiled.site_config.as_value();
1277        let vars = cfg["themeVariables"].as_object().unwrap();
1278
1279        assert_eq!(cfg["theme"], "base");
1280        assert_eq!(cfg["darkMode"], true);
1281        assert_eq!(vars["background"], "#0f172a");
1282        assert_eq!(vars["mainBkg"], "#111827");
1283        assert_eq!(vars["nodeTextColor"], "#e5e7eb");
1284        assert_eq!(vars["lineColor"], "#94a3b8");
1285        assert_eq!(vars["noteBkgColor"], "#422006");
1286        assert_eq!(vars["actorBkg"], "#1f2937");
1287        assert_eq!(
1288            vars["xyChart"]["plotColorPalette"],
1289            "#60a5fa,#34d399,#f59e0b,#c084fc,#22d3ee,#fb7185,#facc15,#a3e635"
1290        );
1291        assert_eq!(vars["pie1"], "#60a5fa");
1292        assert_eq!(vars["git0"], "#60a5fa");
1293        assert_eq!(vars["gitBranchLabel0"], "#ffffff");
1294    }
1295
1296    #[test]
1297    fn host_theme_roles_compile_to_theme_variable_sentinels() {
1298        let cfg = compiled_sentinel_config();
1299        let vars = cfg["themeVariables"].as_object().unwrap();
1300
1301        assert_eq!(cfg["theme"], "base");
1302        assert_eq!(vars["background"], "#010101");
1303        assert_eq!(vars["primaryColor"], "#020202");
1304        assert_eq!(vars["mainBkg"], "#020202");
1305        assert_eq!(vars["secondaryColor"], "#030303");
1306        assert_eq!(vars["tertiaryColor"], "#040404");
1307        assert_eq!(vars["primaryTextColor"], "#050505");
1308        assert_eq!(vars["nodeTextColor"], "#050505");
1309        assert_eq!(vars["textColor"], "#050505");
1310        assert_eq!(vars["titleColor"], "#050505");
1311        assert_eq!(vars["secondaryTextColor"], "#060606");
1312        assert_eq!(vars["tertiaryTextColor"], "#060606");
1313        assert_eq!(vars["primaryBorderColor"], "#070707");
1314        assert_eq!(vars["nodeBorder"], "#070707");
1315        assert_eq!(vars["lineColor"], "#080808");
1316        assert_eq!(vars["arrowheadColor"], "#080808");
1317        assert_eq!(vars["edgeLabelBackground"], "#090909");
1318        assert_eq!(vars["clusterBkg"], "#0a0a0a");
1319        assert_eq!(vars["clusterBorder"], "#0b0b0b");
1320        assert_eq!(vars["noteBkgColor"], "#0c0c0c");
1321        assert_eq!(vars["noteBorderColor"], "#0d0d0d");
1322        assert_eq!(vars["noteTextColor"], "#0e0e0e");
1323        assert_eq!(vars["actorBkg"], "#0f0f0f");
1324        assert_eq!(vars["actorBorder"], "#101010");
1325        assert_eq!(vars["actorTextColor"], "#111111");
1326        assert_eq!(vars["activationBkgColor"], "#121212");
1327        assert_eq!(vars["activationBorderColor"], "#131313");
1328        assert_eq!(vars["critBkgColor"], "#141414");
1329        assert_eq!(vars["vertLineColor"], "#151515");
1330        assert_eq!(vars["doneTaskBkgColor"], "#161616");
1331
1332        assert_eq!(vars["relationLabelBackground"], "#090909");
1333        assert_eq!(vars["requirementEdgeLabelBackground"], "#090909");
1334        assert_eq!(vars["archGroupBorderColor"], "#0b0b0b");
1335        assert_eq!(vars["emSwimlaneBackgroundOdd"], "#0a0a0a");
1336        assert_eq!(vars["emSwimlaneBackgroundStroke"], "#0b0b0b");
1337        assert_eq!(vars["treeView"]["labelColor"], "#050505");
1338        assert_eq!(vars["treeView"]["lineColor"], "#080808");
1339    }
1340
1341    #[test]
1342    fn host_theme_roles_compile_to_diagram_config_sentinels() {
1343        let cfg = compiled_sentinel_config();
1344
1345        assert_eq!(cfg["packet"]["startByteColor"], "#080808");
1346        assert_eq!(cfg["packet"]["endByteColor"], "#070707");
1347        assert_eq!(cfg["packet"]["labelColor"], "#050505");
1348        assert_eq!(cfg["packet"]["titleColor"], "#050505");
1349        assert_eq!(cfg["packet"]["blockStrokeColor"], "#070707");
1350        assert_eq!(cfg["packet"]["blockFillColor"], "#020202");
1351
1352        assert_eq!(cfg["treemap"]["titleColor"], "#050505");
1353        assert_eq!(cfg["treemap"]["labelColor"], "#050505");
1354        assert_eq!(cfg["treemap"]["valueColor"], "#060606");
1355        assert_eq!(cfg["treemap"]["sectionStrokeColor"], "#070707");
1356        assert_eq!(cfg["treemap"]["sectionFillColor"], "#030303");
1357        assert_eq!(cfg["treemap"]["leafStrokeColor"], "#070707");
1358        assert_eq!(cfg["treemap"]["leafFillColor"], "#020202");
1359
1360        assert_eq!(cfg["radar"]["axisColor"], "#080808");
1361        assert_eq!(cfg["radar"]["graticuleColor"], "#070707");
1362
1363        assert_eq!(cfg["c4"]["person_bg_color"], "#020202");
1364        assert_eq!(cfg["c4"]["person_border_color"], "#070707");
1365        assert_eq!(cfg["c4"]["external_component_queue_bg_color"], "#020202");
1366        assert_eq!(
1367            cfg["c4"]["external_component_queue_border_color"],
1368            "#070707"
1369        );
1370    }
1371
1372    #[test]
1373    fn host_theme_role_fallbacks_preserve_context_specific_targets() {
1374        let cfg = HostThemeProfile::builder()
1375            .roles(HostThemeRoles {
1376                canvas: Some("#101010".to_string()),
1377                surface: Some("#202020".to_string()),
1378                surface_alt: Some("#303030".to_string()),
1379                surface_muted: Some("#404040".to_string()),
1380                ..HostThemeRoles::default()
1381            })
1382            .build()
1383            .compile()
1384            .site_config
1385            .as_value()
1386            .clone();
1387        let vars = cfg["themeVariables"].as_object().unwrap();
1388
1389        assert_eq!(vars["edgeLabelBackground"], "#101010");
1390        assert_eq!(vars["relationLabelBackground"], "#101010");
1391        assert_eq!(vars["requirementEdgeLabelBackground"], "#101010");
1392        assert_eq!(vars["commitLabelBackground"], "#202020");
1393
1394        assert_eq!(vars["clusterBkg"], "#303030");
1395        assert_eq!(vars["sectionBkgColor"], "#303030");
1396        assert_eq!(vars["emSwimlaneBackgroundOdd"], "#404040");
1397    }
1398
1399    #[test]
1400    fn common_editor_presets_compile_named_palettes() {
1401        let cases = [
1402            (HostThemePreset::EditorLight, "#ffffff", "#2563eb"),
1403            (HostThemePreset::EditorDark, "#0f172a", "#60a5fa"),
1404            (HostThemePreset::OneDark, "#282c34", "#61afef"),
1405            (HostThemePreset::GruvboxDark, "#282828", "#83a598"),
1406            (HostThemePreset::GruvboxLight, "#fbf1c7", "#458588"),
1407            (HostThemePreset::AyuDark, "#0b0e14", "#59c2ff"),
1408            (HostThemePreset::AyuLight, "#fafafa", "#55b4d4"),
1409        ];
1410
1411        for (preset, background, first_series_color) in cases {
1412            let compiled = HostThemeProfile::from_preset(preset).compile();
1413            let cfg = compiled.site_config.as_value();
1414            let vars = cfg["themeVariables"].as_object().unwrap();
1415
1416            assert_eq!(cfg["theme"], "base", "{preset:?}");
1417            assert_eq!(vars["background"], background, "{preset:?}");
1418            assert_eq!(vars["pie1"], first_series_color, "{preset:?}");
1419            assert_eq!(
1420                compiled.output.preset,
1421                SvgPipelinePreset::ResvgSafe,
1422                "{preset:?}"
1423            );
1424            assert!(
1425                !compiled.output.drop_native_duplicate_fallbacks,
1426                "{preset:?}"
1427            );
1428            assert_eq!(
1429                vars["xyChart"]["accentColor"], first_series_color,
1430                "{preset:?}"
1431            );
1432        }
1433    }
1434
1435    #[test]
1436    fn host_theme_preset_names_are_binding_stable() {
1437        let names = HostThemePreset::ALL.map(HostThemePreset::as_str);
1438
1439        assert_eq!(
1440            names,
1441            [
1442                "editor-light",
1443                "editor-dark",
1444                "one-dark",
1445                "gruvbox-light",
1446                "gruvbox-dark",
1447                "ayu-light",
1448                "ayu-dark"
1449            ]
1450        );
1451    }
1452
1453    #[test]
1454    fn explicit_profile_theme_variables_override_derived_roles() {
1455        let profile = HostThemeProfile::builder()
1456            .roles(HostThemeRoles {
1457                border: Some("#111111".to_string()),
1458                ..HostThemeRoles::default()
1459            })
1460            .theme_variable("nodeBorder", "#abcdef")
1461            .build();
1462
1463        let compiled = profile.compile();
1464        let vars = compiled.site_config.as_value()["themeVariables"]
1465            .as_object()
1466            .unwrap();
1467
1468        assert_eq!(vars["nodeBorder"], "#abcdef");
1469        assert_eq!(vars["primaryBorderColor"], "#111111");
1470    }
1471
1472    #[test]
1473    fn empty_profile_compiles_to_empty_site_config() {
1474        let compiled = HostThemeProfile::default().compile();
1475
1476        assert_eq!(compiled.site_config.as_value(), &Value::Object(Map::new()));
1477        assert_eq!(compiled.output.preset, SvgPipelinePreset::Parity);
1478        assert!(compiled.output.root_background_color.is_none());
1479    }
1480
1481    #[test]
1482    fn compiled_output_builds_host_pipeline() {
1483        let compiled = HostThemeProfile::editor_dark().compile();
1484        let pipeline = compiled.pipeline();
1485        let out = pipeline
1486            .process_to_string(
1487                r#"<svg id="host" style="background-color: white;"><style>.node{fill:red !important;}</style><text>A</text></svg>"#,
1488            )
1489            .unwrap();
1490
1491        assert!(!out.contains("!important"));
1492        assert!(out.contains("background-color: #0f172a;"));
1493    }
1494}