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