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, uses the profile canvas as the root SVG background, and enables
199    /// duplicate fallback cleanup. Callers can 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: true,
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        put_theme_roles(&mut theme_variables, &self.roles);
582        put_series_palette(&mut theme_variables, &self.series_palette);
583        put_diagram_config(
584            &mut root,
585            &mut theme_variables,
586            &self.roles,
587            &self.series_palette,
588        );
589
590        merge_object(&mut theme_variables, &self.theme_variables);
591        if !theme_variables.is_empty() {
592            root.insert("themeVariables".to_string(), Value::Object(theme_variables));
593        }
594        merge_object(&mut root, &self.site_config);
595
596        let canvas_color = self
597            .roles
598            .canvas
599            .as_deref()
600            .filter(|s| !s.trim().is_empty())
601            .map(str::trim)
602            .map(str::to_string);
603
604        CompiledHostTheme {
605            site_config: MermaidConfig::from_value(Value::Object(root)),
606            output: CompiledHostThemeOutput {
607                preset: self.output.pipeline.into(),
608                css_override_policy: self.output.css_override_policy,
609                root_background_color: match &self.output.root_background {
610                    HostThemeRootBackground::None => None,
611                    HostThemeRootBackground::Canvas => canvas_color,
612                    HostThemeRootBackground::Color(color) => Some(color.clone()),
613                },
614                drop_native_duplicate_fallbacks: self.output.drop_native_duplicate_fallbacks,
615                scoped_css: self.output.scoped_css.clone(),
616            },
617        }
618    }
619}
620
621#[derive(Debug, Clone, Default)]
622pub struct HostThemeProfileBuilder {
623    profile: HostThemeProfile,
624}
625
626impl HostThemeProfileBuilder {
627    pub fn appearance(mut self, appearance: HostThemeAppearance) -> Self {
628        self.profile.appearance = appearance;
629        self
630    }
631
632    pub fn font_family(mut self, font_family: impl Into<String>) -> Self {
633        self.profile.font_family = Some(font_family.into());
634        self
635    }
636
637    pub fn font_size(mut self, font_size: impl Into<String>) -> Self {
638        self.profile.font_size = Some(font_size.into());
639        self
640    }
641
642    pub fn roles(mut self, roles: HostThemeRoles) -> Self {
643        self.profile.roles = roles;
644        self
645    }
646
647    pub fn series_palette(mut self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
648        self.profile.series_palette = palette.into_iter().map(Into::into).collect();
649        self
650    }
651
652    pub fn output(mut self, output: HostThemeOutput) -> Self {
653        self.profile.output = output;
654        self
655    }
656
657    pub fn theme_variable(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
658        self.profile
659            .theme_variables
660            .insert(key.into(), value.into());
661        self
662    }
663
664    pub fn site_config(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
665        self.profile.site_config.insert(key.into(), value.into());
666        self
667    }
668
669    pub fn build(self) -> HostThemeProfile {
670        self.profile
671    }
672}
673
674#[derive(Debug, Clone)]
675pub struct CompiledHostTheme {
676    pub site_config: MermaidConfig,
677    pub output: CompiledHostThemeOutput,
678}
679
680impl CompiledHostTheme {
681    pub fn into_parts(self) -> (MermaidConfig, CompiledHostThemeOutput) {
682        (self.site_config, self.output)
683    }
684
685    pub fn pipeline(&self) -> SvgPipeline {
686        self.output.pipeline()
687    }
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct CompiledHostThemeOutput {
692    pub preset: SvgPipelinePreset,
693    pub css_override_policy: CssOverridePolicy,
694    pub root_background_color: Option<String>,
695    pub drop_native_duplicate_fallbacks: bool,
696    pub scoped_css: Option<String>,
697}
698
699impl CompiledHostThemeOutput {
700    pub fn pipeline(&self) -> SvgPipeline {
701        let mut pipeline = SvgPipeline::from_preset(self.preset);
702
703        if matches!(
704            self.css_override_policy,
705            CssOverridePolicy::StripExistingImportant
706        ) {
707            pipeline.push_postprocessor(CssOverridePostprocessor::strip_existing_important());
708        }
709
710        if self.drop_native_duplicate_fallbacks {
711            pipeline.push_postprocessor(DropNativeDuplicateFallbacksPostprocessor);
712        }
713
714        if matches!(self.preset, SvgPipelinePreset::ResvgSafe) {
715            pipeline.push_postprocessor(GitGraphBranchLabelBaselinePostprocessor);
716        }
717
718        if let Some(color) = self
719            .root_background_color
720            .as_deref()
721            .filter(|color| !color.trim().is_empty())
722        {
723            pipeline.push_postprocessor(RootBackgroundPostprocessor::new(color.trim()));
724        }
725
726        if let Some(css) = self
727            .scoped_css
728            .as_deref()
729            .filter(|css| !css.trim().is_empty())
730        {
731            pipeline.push_postprocessor(
732                ScopedCssPostprocessor::new(css.to_string())
733                    .with_override_policy(self.css_override_policy),
734            );
735            if matches!(self.preset, SvgPipelinePreset::ResvgSafe) {
736                pipeline.push_postprocessor(SanitizeCssPostprocessor);
737            }
738        }
739
740        pipeline
741    }
742}
743
744fn put_theme_roles(theme_variables: &mut Map<String, Value>, roles: &HostThemeRoles) {
745    let canvas = roles.canvas.as_deref();
746    let surface = roles.surface.as_deref();
747    let surface_alt = roles.surface_alt.as_deref().or(surface);
748    let surface_muted = roles.surface_muted.as_deref().or(surface_alt);
749    let text = roles.text.as_deref();
750    let subtle_text = roles.subtle_text.as_deref().or(text);
751    let border = roles.border.as_deref();
752    let line = roles.line.as_deref().or(border);
753    let error = roles.error.as_deref();
754    let warning = roles.warning.as_deref();
755    let success = roles.success.as_deref();
756
757    put_opt(theme_variables, "background", canvas);
758    put_opt(theme_variables, "primaryColor", surface);
759    put_opt(theme_variables, "mainBkg", surface);
760    put_opt(theme_variables, "secondaryColor", surface_alt);
761    put_opt(theme_variables, "tertiaryColor", surface_muted);
762    put_opt(theme_variables, "primaryTextColor", text);
763    put_opt(theme_variables, "nodeTextColor", text);
764    put_opt(theme_variables, "textColor", text);
765    put_opt(theme_variables, "titleColor", text);
766    put_opt(theme_variables, "secondaryTextColor", subtle_text);
767    put_opt(theme_variables, "tertiaryTextColor", subtle_text);
768    put_opt(theme_variables, "primaryBorderColor", border);
769    put_opt(theme_variables, "nodeBorder", border);
770    put_opt(theme_variables, "lineColor", line);
771    put_opt(theme_variables, "arrowheadColor", line);
772    put_opt(
773        theme_variables,
774        "edgeLabelBackground",
775        roles.edge_label_background.as_deref().or(canvas),
776    );
777
778    put_opt(
779        theme_variables,
780        "clusterBkg",
781        roles.cluster_background.as_deref().or(surface_alt),
782    );
783    put_opt(
784        theme_variables,
785        "clusterBorder",
786        roles.cluster_border.as_deref().or(border),
787    );
788
789    put_opt(
790        theme_variables,
791        "noteBkgColor",
792        roles.note_background.as_deref().or(surface_alt),
793    );
794    put_opt(
795        theme_variables,
796        "noteBorderColor",
797        roles.note_border.as_deref().or(border),
798    );
799    put_opt(
800        theme_variables,
801        "noteTextColor",
802        roles.note_text.as_deref().or(text),
803    );
804
805    put_opt(
806        theme_variables,
807        "actorBkg",
808        roles.actor_background.as_deref().or(surface_alt),
809    );
810    put_opt(
811        theme_variables,
812        "actorBorder",
813        roles.actor_border.as_deref().or(border),
814    );
815    put_opt(
816        theme_variables,
817        "actorTextColor",
818        roles.actor_text.as_deref().or(text),
819    );
820    put_opt(theme_variables, "actorLineColor", line);
821    put_opt(theme_variables, "signalColor", line.or(text));
822    put_opt(theme_variables, "signalTextColor", text);
823    put_opt(theme_variables, "labelTextColor", text);
824    put_opt(theme_variables, "loopTextColor", text);
825    put_opt(theme_variables, "labelBoxBkgColor", surface_alt);
826    put_opt(theme_variables, "labelBoxBorderColor", border);
827    put_opt(
828        theme_variables,
829        "activationBkgColor",
830        roles.activation_background.as_deref().or(surface_muted),
831    );
832    put_opt(
833        theme_variables,
834        "activationBorderColor",
835        roles.activation_border.as_deref().or(border),
836    );
837
838    put_opt(theme_variables, "classText", text);
839    put_opt(theme_variables, "labelColor", text);
840    put_opt(theme_variables, "transitionColor", line);
841    put_opt(theme_variables, "transitionLabelColor", text);
842    put_opt(theme_variables, "stateLabelColor", text);
843    put_opt(theme_variables, "stateBkg", surface);
844    put_opt(theme_variables, "stateBorder", border);
845    put_opt(theme_variables, "specialStateColor", line);
846    put_opt(theme_variables, "compositeBackground", canvas.or(surface));
847
848    put_opt(theme_variables, "attributeBackgroundColorOdd", surface);
849    put_opt(theme_variables, "attributeBackgroundColorEven", surface_alt);
850    put_opt(theme_variables, "rowOdd", surface);
851    put_opt(theme_variables, "rowEven", surface_alt);
852
853    put_opt(theme_variables, "requirementBackground", surface);
854    put_opt(theme_variables, "requirementBorderColor", border);
855    put_opt(theme_variables, "requirementTextColor", text);
856    put_opt(theme_variables, "relationColor", line);
857    put_opt(
858        theme_variables,
859        "relationLabelBackground",
860        roles.edge_label_background.as_deref().or(canvas),
861    );
862    put_opt(theme_variables, "relationLabelColor", text);
863    put_opt(
864        theme_variables,
865        "requirementEdgeLabelBackground",
866        roles.edge_label_background.as_deref().or(canvas),
867    );
868
869    put_opt(theme_variables, "pieTitleTextColor", text);
870    put_opt(theme_variables, "pieSectionTextColor", text);
871    put_opt(theme_variables, "pieLegendTextColor", subtle_text);
872    put_opt(theme_variables, "pieStrokeColor", border);
873    put_opt(theme_variables, "pieOuterStrokeColor", border);
874
875    put_opt(theme_variables, "commitLabelColor", text);
876    put_opt(
877        theme_variables,
878        "commitLabelBackground",
879        roles.edge_label_background.as_deref().or(surface),
880    );
881    put_opt(theme_variables, "commitLineColor", line);
882    put_opt(theme_variables, "tagLabelColor", text);
883    put_opt(theme_variables, "tagLabelBackground", surface);
884    put_opt(theme_variables, "tagLabelBorder", border);
885
886    put_opt(theme_variables, "quadrant1Fill", surface);
887    put_opt(theme_variables, "quadrant2Fill", surface_alt);
888    put_opt(theme_variables, "quadrant3Fill", canvas.or(surface));
889    put_opt(theme_variables, "quadrant4Fill", surface_muted);
890    put_opt(theme_variables, "quadrant1TextFill", text);
891    put_opt(theme_variables, "quadrant2TextFill", text);
892    put_opt(theme_variables, "quadrant3TextFill", text);
893    put_opt(theme_variables, "quadrant4TextFill", text);
894    put_opt(theme_variables, "quadrantPointFill", line);
895    put_opt(theme_variables, "quadrantPointTextFill", text);
896    put_opt(theme_variables, "quadrantTitleFill", text);
897    put_opt(theme_variables, "quadrantXAxisTextFill", subtle_text);
898    put_opt(theme_variables, "quadrantYAxisTextFill", subtle_text);
899    put_opt(theme_variables, "quadrantExternalBorderStrokeFill", border);
900    put_opt(theme_variables, "quadrantInternalBorderStrokeFill", border);
901
902    put_opt(theme_variables, "archEdgeColor", line);
903    put_opt(theme_variables, "archEdgeArrowColor", line);
904    put_opt(
905        theme_variables,
906        "archGroupBorderColor",
907        roles.cluster_border.as_deref().or(border),
908    );
909
910    put_opt(theme_variables, "emUiFill", surface);
911    put_opt(theme_variables, "emUiStroke", border);
912    put_opt(theme_variables, "emRelationStroke", line);
913    put_opt(theme_variables, "emArrowhead", line);
914    put_opt(
915        theme_variables,
916        "emSwimlaneBackgroundOdd",
917        roles.cluster_background.as_deref().or(surface_muted),
918    );
919    put_opt(
920        theme_variables,
921        "emSwimlaneBackgroundStroke",
922        roles.cluster_border.as_deref().or(border),
923    );
924
925    put_opt(theme_variables, "taskTextDarkColor", text);
926    put_opt(theme_variables, "taskTextClickableColor", line);
927    put_opt(theme_variables, "taskTextColor", text);
928    put_opt(theme_variables, "taskTextOutsideColor", subtle_text);
929    put_opt(theme_variables, "taskBkgColor", surface);
930    put_opt(theme_variables, "taskBorderColor", border);
931    put_opt(theme_variables, "activeTaskBkgColor", surface_muted);
932    put_opt(theme_variables, "activeTaskBorderColor", line);
933    put_opt(theme_variables, "doneTaskBkgColor", success.or(surface_alt));
934    put_opt(theme_variables, "doneTaskBorderColor", success.or(border));
935    put_opt(theme_variables, "critBkgColor", error);
936    put_opt(theme_variables, "critBorderColor", error.or(border));
937    put_opt(theme_variables, "excludeBkgColor", surface_alt);
938    put_opt(theme_variables, "gridColor", border);
939    put_opt(
940        theme_variables,
941        "todayLineColor",
942        warning.or(error).or(line),
943    );
944    put_opt(theme_variables, "vertLineColor", warning.or(line));
945    put_opt(
946        theme_variables,
947        "sectionBkgColor",
948        roles.cluster_background.as_deref().or(surface_alt),
949    );
950    put_opt(theme_variables, "sectionBkgColor2", surface_muted);
951    put_opt(theme_variables, "altSectionBkgColor", canvas);
952
953    put_opt(theme_variables, "errorBkgColor", roles.error.as_deref());
954    put_opt(theme_variables, "errorTextColor", text);
955
956    put_opt(theme_variables, "faceColor", surface);
957    put_opt(
958        theme_variables,
959        "border2",
960        roles.cluster_border.as_deref().or(border),
961    );
962}
963
964fn put_series_palette(theme_variables: &mut Map<String, Value>, palette: &[String]) {
965    if palette.is_empty() {
966        return;
967    }
968
969    let mut xy = Map::new();
970    xy.insert(
971        "plotColorPalette".to_string(),
972        Value::String(palette.join(",")),
973    );
974    xy.insert("accentColor".to_string(), Value::String(palette[0].clone()));
975    theme_variables.insert("xyChart".to_string(), Value::Object(xy));
976
977    for (index, color) in palette.iter().enumerate() {
978        let label = readable_text_color(color);
979        put_str(theme_variables, &format!("cScale{index}"), color);
980        put_str(theme_variables, &format!("cScalePeer{index}"), color);
981        put_str(theme_variables, &format!("cScaleLabel{index}"), &label);
982        put_str(theme_variables, &format!("cScaleInv{index}"), &label);
983        put_str(theme_variables, &format!("git{index}"), color);
984        put_str(theme_variables, &format!("gitBranchLabel{index}"), &label);
985        put_str(theme_variables, &format!("pie{}", index + 1), color);
986        put_str(theme_variables, &format!("venn{}", index + 1), color);
987        put_str(theme_variables, &format!("fillType{index}"), color);
988        put_str(theme_variables, &format!("actor{index}"), color);
989    }
990}
991
992fn put_diagram_config(
993    root: &mut Map<String, Value>,
994    theme_variables: &mut Map<String, Value>,
995    roles: &HostThemeRoles,
996    palette: &[String],
997) {
998    let text = roles.text.as_deref();
999    let subtle_text = roles.subtle_text.as_deref().or(text);
1000    let surface = roles.surface.as_deref();
1001    let surface_alt = roles.surface_alt.as_deref().or(surface);
1002    let border = roles.border.as_deref();
1003    let line = roles.line.as_deref().or(border);
1004    let warning = roles.warning.as_deref();
1005    let success = roles.success.as_deref();
1006
1007    let mut packet = Map::new();
1008    put_opt(&mut packet, "startByteColor", line);
1009    put_opt(&mut packet, "endByteColor", border.or(line));
1010    put_opt(&mut packet, "labelColor", text);
1011    put_opt(&mut packet, "titleColor", text);
1012    put_opt(&mut packet, "blockStrokeColor", border);
1013    put_opt(&mut packet, "blockFillColor", surface);
1014    put_nonempty_object(root, "packet", packet);
1015
1016    let mut treemap = Map::new();
1017    put_opt(&mut treemap, "titleColor", text);
1018    put_opt(&mut treemap, "labelColor", text);
1019    put_opt(&mut treemap, "valueColor", subtle_text);
1020    put_opt(&mut treemap, "sectionStrokeColor", border);
1021    put_opt(&mut treemap, "sectionFillColor", surface_alt);
1022    put_opt(&mut treemap, "leafStrokeColor", border);
1023    put_opt(&mut treemap, "leafFillColor", surface);
1024    put_nonempty_object(root, "treemap", treemap);
1025
1026    let mut tree_view = Map::new();
1027    put_opt(&mut tree_view, "labelColor", text);
1028    put_opt(&mut tree_view, "lineColor", line);
1029    if !tree_view.is_empty() {
1030        let entry = theme_variables.get("treeView");
1031        let mut merged = entry
1032            .and_then(Value::as_object)
1033            .cloned()
1034            .unwrap_or_default();
1035        merge_object(&mut merged, &tree_view);
1036        theme_variables.insert("treeView".to_string(), Value::Object(merged));
1037    }
1038
1039    let mut radar = Map::new();
1040    put_opt(&mut radar, "axisColor", line);
1041    put_opt(&mut radar, "graticuleColor", border);
1042    put_nonempty_object(root, "radar", radar);
1043
1044    let mut eventmodeling = Map::new();
1045    put_opt(
1046        &mut eventmodeling,
1047        "emProcessorFill",
1048        palette.get(3).map(String::as_str).or(surface_alt),
1049    );
1050    put_opt(&mut eventmodeling, "emProcessorStroke", border);
1051    put_opt(
1052        &mut eventmodeling,
1053        "emReadModelFill",
1054        palette
1055            .get(1)
1056            .map(String::as_str)
1057            .or(success)
1058            .or(surface_alt),
1059    );
1060    put_opt(&mut eventmodeling, "emReadModelStroke", success.or(border));
1061    put_opt(
1062        &mut eventmodeling,
1063        "emCommandFill",
1064        palette.first().map(String::as_str).or(surface_alt),
1065    );
1066    put_opt(&mut eventmodeling, "emCommandStroke", line.or(border));
1067    put_opt(
1068        &mut eventmodeling,
1069        "emEventFill",
1070        palette
1071            .get(2)
1072            .map(String::as_str)
1073            .or(warning)
1074            .or(surface_alt),
1075    );
1076    put_opt(&mut eventmodeling, "emEventStroke", warning.or(border));
1077    for (key, value) in eventmodeling {
1078        theme_variables.insert(key, value);
1079    }
1080
1081    let mut c4 = Map::new();
1082    for prefix in [
1083        "person",
1084        "system",
1085        "system_db",
1086        "system_queue",
1087        "container",
1088        "container_db",
1089        "container_queue",
1090        "component",
1091        "component_db",
1092        "component_queue",
1093        "external_person",
1094        "external_system",
1095        "external_system_db",
1096        "external_system_queue",
1097        "external_container",
1098        "external_container_db",
1099        "external_container_queue",
1100        "external_component",
1101        "external_component_db",
1102        "external_component_queue",
1103    ] {
1104        put_opt(&mut c4, &format!("{prefix}_bg_color"), surface);
1105        put_opt(&mut c4, &format!("{prefix}_border_color"), border);
1106    }
1107    put_nonempty_object(root, "c4", c4);
1108}
1109
1110fn put_nonempty_object(root: &mut Map<String, Value>, key: &str, object: Map<String, Value>) {
1111    if !object.is_empty() {
1112        root.insert(key.to_string(), Value::Object(object));
1113    }
1114}
1115
1116fn put_opt(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
1117    if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
1118        put_str(map, key, value);
1119    }
1120}
1121
1122fn put_str(map: &mut Map<String, Value>, key: &str, value: &str) {
1123    map.insert(key.to_string(), Value::String(value.trim().to_string()));
1124}
1125
1126fn merge_object(target: &mut Map<String, Value>, source: &Map<String, Value>) {
1127    for (key, value) in source {
1128        target.insert(key.clone(), value.clone());
1129    }
1130}
1131
1132fn readable_text_color(color: &str) -> String {
1133    let Some((r, g, b)) = parse_hex_rgb(color) else {
1134        return "#ffffff".to_string();
1135    };
1136    let luminance = relative_luminance(r, g, b);
1137    if luminance > 0.45 {
1138        "#000000".to_string()
1139    } else {
1140        "#ffffff".to_string()
1141    }
1142}
1143
1144fn parse_hex_rgb(color: &str) -> Option<(f64, f64, f64)> {
1145    let raw = color.trim().strip_prefix('#')?;
1146    if raw.len() != 6 || !raw.chars().all(|ch| ch.is_ascii_hexdigit()) {
1147        return None;
1148    }
1149    let r = u8::from_str_radix(&raw[0..2], 16).ok()? as f64 / 255.0;
1150    let g = u8::from_str_radix(&raw[2..4], 16).ok()? as f64 / 255.0;
1151    let b = u8::from_str_radix(&raw[4..6], 16).ok()? as f64 / 255.0;
1152    Some((r, g, b))
1153}
1154
1155fn relative_luminance(r: f64, g: f64, b: f64) -> f64 {
1156    fn linear(channel: f64) -> f64 {
1157        if channel <= 0.04045 {
1158            channel / 12.92
1159        } else {
1160            ((channel + 0.055) / 1.055).powf(2.4)
1161        }
1162    }
1163    0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    #[test]
1171    fn dark_editor_profile_compiles_common_theme_variables() {
1172        let compiled = HostThemeProfile::editor_dark().compile();
1173        let cfg = compiled.site_config.as_value();
1174        let vars = cfg["themeVariables"].as_object().unwrap();
1175
1176        assert_eq!(cfg["theme"], "base");
1177        assert_eq!(cfg["darkMode"], true);
1178        assert_eq!(vars["background"], "#0f172a");
1179        assert_eq!(vars["mainBkg"], "#111827");
1180        assert_eq!(vars["nodeTextColor"], "#e5e7eb");
1181        assert_eq!(vars["lineColor"], "#94a3b8");
1182        assert_eq!(vars["noteBkgColor"], "#422006");
1183        assert_eq!(vars["actorBkg"], "#1f2937");
1184        assert_eq!(
1185            vars["xyChart"]["plotColorPalette"],
1186            "#60a5fa,#34d399,#f59e0b,#c084fc,#22d3ee,#fb7185,#facc15,#a3e635"
1187        );
1188        assert_eq!(vars["pie1"], "#60a5fa");
1189        assert_eq!(vars["git0"], "#60a5fa");
1190        assert_eq!(vars["gitBranchLabel0"], "#ffffff");
1191    }
1192
1193    #[test]
1194    fn common_editor_presets_compile_named_palettes() {
1195        let cases = [
1196            (HostThemePreset::EditorLight, "#ffffff", "#2563eb"),
1197            (HostThemePreset::EditorDark, "#0f172a", "#60a5fa"),
1198            (HostThemePreset::OneDark, "#282c34", "#61afef"),
1199            (HostThemePreset::GruvboxDark, "#282828", "#83a598"),
1200            (HostThemePreset::GruvboxLight, "#fbf1c7", "#458588"),
1201            (HostThemePreset::AyuDark, "#0b0e14", "#59c2ff"),
1202            (HostThemePreset::AyuLight, "#fafafa", "#55b4d4"),
1203        ];
1204
1205        for (preset, background, first_series_color) in cases {
1206            let compiled = HostThemeProfile::from_preset(preset).compile();
1207            let cfg = compiled.site_config.as_value();
1208            let vars = cfg["themeVariables"].as_object().unwrap();
1209
1210            assert_eq!(cfg["theme"], "base", "{preset:?}");
1211            assert_eq!(vars["background"], background, "{preset:?}");
1212            assert_eq!(vars["pie1"], first_series_color, "{preset:?}");
1213            assert_eq!(
1214                compiled.output.preset,
1215                SvgPipelinePreset::ResvgSafe,
1216                "{preset:?}"
1217            );
1218            assert_eq!(
1219                vars["xyChart"]["accentColor"], first_series_color,
1220                "{preset:?}"
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn host_theme_preset_names_are_binding_stable() {
1227        let names = HostThemePreset::ALL.map(HostThemePreset::as_str);
1228
1229        assert_eq!(
1230            names,
1231            [
1232                "editor-light",
1233                "editor-dark",
1234                "one-dark",
1235                "gruvbox-light",
1236                "gruvbox-dark",
1237                "ayu-light",
1238                "ayu-dark"
1239            ]
1240        );
1241    }
1242
1243    #[test]
1244    fn explicit_profile_theme_variables_override_derived_roles() {
1245        let profile = HostThemeProfile::builder()
1246            .roles(HostThemeRoles {
1247                border: Some("#111111".to_string()),
1248                ..HostThemeRoles::default()
1249            })
1250            .theme_variable("nodeBorder", "#abcdef")
1251            .build();
1252
1253        let compiled = profile.compile();
1254        let vars = compiled.site_config.as_value()["themeVariables"]
1255            .as_object()
1256            .unwrap();
1257
1258        assert_eq!(vars["nodeBorder"], "#abcdef");
1259        assert_eq!(vars["primaryBorderColor"], "#111111");
1260    }
1261
1262    #[test]
1263    fn empty_profile_compiles_to_empty_site_config() {
1264        let compiled = HostThemeProfile::default().compile();
1265
1266        assert_eq!(compiled.site_config.as_value(), &Value::Object(Map::new()));
1267        assert_eq!(compiled.output.preset, SvgPipelinePreset::Parity);
1268        assert!(compiled.output.root_background_color.is_none());
1269    }
1270
1271    #[test]
1272    fn compiled_output_builds_host_pipeline() {
1273        let compiled = HostThemeProfile::editor_dark().compile();
1274        let pipeline = compiled.pipeline();
1275        let out = pipeline
1276            .process_to_string(
1277                r#"<svg id="host" style="background-color: white;"><style>.node{fill:red !important;}</style><text>A</text></svg>"#,
1278            )
1279            .unwrap();
1280
1281        assert!(!out.contains("!important"));
1282        assert!(out.contains("background-color: #0f172a;"));
1283    }
1284}