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 #[default]
28 EditorLight,
29 EditorDark,
31 OneDark,
33 GruvboxLight,
35 GruvboxDark,
37 AyuLight,
39 AyuDark,
41}
42
43impl HostThemePreset {
44 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 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
69pub 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 #[default]
143 Parity,
144 Readable,
150 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 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}