1use std::collections::BTreeMap;
7use std::fmt::{self, Write};
8use std::sync::OnceLock;
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13mod generated {
14 include!("generated/metadata.rs");
15}
16
17pub use generated::{CATALOG_REVISION, CATALOG_VERSION};
18
19const CATALOG_JSON: &str = include_str!("generated/catalog.json");
20const CATALOG_SCHEMA_JSON: &str = include_str!("../schema/catalog.schema.json");
21const PROVIDER_PACK_SCHEMA_JSON: &str = include_str!("../schema/provider-pack.schema.json");
22const THEME_OVERRIDES_SCHEMA_JSON: &str = include_str!("../schema/theme-overrides.schema.json");
23static CATALOG: OnceLock<Catalog> = OnceLock::new();
24
25#[must_use]
27pub fn catalog() -> &'static Catalog {
28 CATALOG.get_or_init(|| {
29 serde_json::from_str(CATALOG_JSON).expect("generated catalog must match the Rust contract")
30 })
31}
32
33#[must_use]
35pub const fn catalog_json() -> &'static str {
36 CATALOG_JSON
37}
38
39#[must_use]
41pub const fn catalog_schema_json() -> &'static str {
42 CATALOG_SCHEMA_JSON
43}
44
45#[must_use]
47pub const fn provider_pack_schema_json() -> &'static str {
48 PROVIDER_PACK_SCHEMA_JSON
49}
50
51#[must_use]
53pub const fn theme_overrides_schema_json() -> &'static str {
54 THEME_OVERRIDES_SCHEMA_JSON
55}
56
57#[must_use]
62pub fn icon_svg(asset_path: &str) -> Option<&'static str> {
63 generated::icon_svg(asset_path)
64}
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Catalog {
70 #[serde(rename = "$schema")]
72 pub schema: String,
73 pub schema_version: String,
75 pub catalog_version: String,
77 pub reserved_theme_ids: Vec<String>,
79 pub fallbacks: CatalogFallbacks,
81 pub font_metrics: Vec<FontMetrics>,
83 pub themes: Vec<Theme>,
85}
86
87#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct CatalogFallbacks {
91 pub missing_theme_id: String,
93 pub missing_icon_id: String,
95}
96
97#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct FontMetrics {
101 pub id: String,
103 pub family: String,
105 pub version: String,
107 pub units_per_em: u32,
109 pub ascent: i32,
111 pub descent: i32,
113 pub line_gap: u32,
115 pub default_advance: u32,
117 pub wide_advance: u32,
119 pub wide_ranges: Vec<UnicodeRange>,
121 pub glyph_advances: BTreeMap<String, u32>,
123 pub provenance: Provenance,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct UnicodeRange {
131 pub start: String,
132 pub end: String,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct Theme {
139 pub id: String,
141 pub name: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub description: Option<String>,
146 pub palette: Palette,
148 pub typography: Typography,
150 pub node_kind_fallbacks: NodeKindFallbacks,
152 pub connector: ConnectorStyle,
154 pub icons: Vec<Icon>,
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct Palette {
162 pub canvas: String,
163 pub surface: String,
164 pub surface_muted: String,
165 pub text: String,
166 pub text_muted: String,
167 pub border: String,
168 pub accent: String,
169 pub danger: String,
170 pub connector: String,
171}
172
173#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
175#[serde(transparent)]
176pub struct ThemeOverrides(pub BTreeMap<String, ThemeOverride>);
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "camelCase", deny_unknown_fields)]
181pub struct ThemeOverride {
182 pub extends: BuiltinThemeId,
183 pub palette: PaletteOverride,
184}
185
186#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(rename_all = "lowercase")]
189pub enum BuiltinThemeId {
190 Default,
191 Light,
192 Dark,
193}
194
195impl BuiltinThemeId {
196 #[must_use]
197 pub const fn as_str(self) -> &'static str {
198 match self {
199 Self::Default => "default",
200 Self::Light => "light",
201 Self::Dark => "dark",
202 }
203 }
204}
205
206#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
208#[serde(rename_all = "camelCase", deny_unknown_fields)]
209pub struct PaletteOverride {
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub canvas: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 pub surface: Option<String>,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub surface_muted: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub text: Option<String>,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub text_muted: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub border: Option<String>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub accent: Option<String>,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub danger: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub connector: Option<String>,
228}
229
230impl PaletteOverride {
231 fn is_empty(&self) -> bool {
232 self.canvas.is_none()
233 && self.surface.is_none()
234 && self.surface_muted.is_none()
235 && self.text.is_none()
236 && self.text_muted.is_none()
237 && self.border.is_none()
238 && self.accent.is_none()
239 && self.danger.is_none()
240 && self.connector.is_none()
241 }
242
243 fn normalized(&self) -> Result<Self, ThemeOverrideError> {
244 macro_rules! normalized_slot {
245 ($field:ident, $token:literal) => {
246 self.$field
247 .as_deref()
248 .map(|value| {
249 normalize_color(value).ok_or_else(|| {
250 ThemeOverrideError::new(format!(
251 "palette.{} must be a six- or eight-digit hexadecimal color",
252 $token
253 ))
254 })
255 })
256 .transpose()?
257 };
258 }
259
260 Ok(Self {
261 canvas: normalized_slot!(canvas, "canvas"),
262 surface: normalized_slot!(surface, "surface"),
263 surface_muted: normalized_slot!(surface_muted, "surfaceMuted"),
264 text: normalized_slot!(text, "text"),
265 text_muted: normalized_slot!(text_muted, "textMuted"),
266 border: normalized_slot!(border, "border"),
267 accent: normalized_slot!(accent, "accent"),
268 danger: normalized_slot!(danger, "danger"),
269 connector: normalized_slot!(connector, "connector"),
270 })
271 }
272
273 fn apply_to(&self, palette: &mut Palette) {
274 macro_rules! apply_slot {
275 ($field:ident) => {
276 if let Some(value) = &self.$field {
277 palette.$field.clone_from(value);
278 }
279 };
280 }
281
282 apply_slot!(canvas);
283 apply_slot!(surface);
284 apply_slot!(surface_muted);
285 apply_slot!(text);
286 apply_slot!(text_muted);
287 apply_slot!(border);
288 apply_slot!(accent);
289 apply_slot!(danger);
290 apply_slot!(connector);
291 }
292}
293
294#[derive(Clone, Debug, Eq, PartialEq)]
296pub struct ResolvedThemeCatalog {
297 pub catalog: Catalog,
298 pub revision: String,
299 pub warnings: Vec<ThemeOverrideWarning>,
300}
301
302#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct ThemeOverrideWarning {
305 pub code: String,
306 pub theme_id: String,
307 pub message: String,
308}
309
310#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct ThemeOverrideError {
313 reason: String,
314}
315
316impl ThemeOverrideError {
317 fn new(reason: impl Into<String>) -> Self {
318 Self {
319 reason: reason.into(),
320 }
321 }
322
323 #[must_use]
324 pub fn reason(&self) -> &str {
325 &self.reason
326 }
327}
328
329impl fmt::Display for ThemeOverrideError {
330 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331 formatter.write_str(&self.reason)
332 }
333}
334
335impl std::error::Error for ThemeOverrideError {}
336
337pub fn resolve_theme_overrides(
343 base_catalog: &Catalog,
344 base_revision: &str,
345 overrides: &ThemeOverrides,
346) -> Result<ResolvedThemeCatalog, ThemeOverrideError> {
347 if overrides.0.len() > 32 {
348 return Err(ThemeOverrideError::new(
349 "theme overrides may contain at most 32 definitions",
350 ));
351 }
352 if overrides.0.is_empty() {
353 return Ok(ResolvedThemeCatalog {
354 catalog: base_catalog.clone(),
355 revision: base_revision.to_owned(),
356 warnings: Vec::new(),
357 });
358 }
359
360 let mut normalized = BTreeMap::new();
361 let mut resolved = BTreeMap::new();
362 let mut warnings = Vec::new();
363
364 for (theme_id, definition) in &overrides.0 {
365 if !is_theme_identifier(theme_id) {
366 return Err(ThemeOverrideError::new(format!(
367 "theme identifier {theme_id:?} is invalid"
368 )));
369 }
370 if definition.palette.is_empty() {
371 return Err(ThemeOverrideError::new(format!(
372 "theme {theme_id} must override at least one palette color"
373 )));
374 }
375
376 let base_id = definition.extends.as_str();
377 let Some(base_theme) = base_catalog.themes.iter().find(|theme| theme.id == base_id) else {
378 return Err(ThemeOverrideError::new(format!(
379 "built-in theme {base_id} is unavailable"
380 )));
381 };
382 let normalized_palette = definition.palette.normalized().map_err(|error| {
383 ThemeOverrideError::new(format!("theme {theme_id}: {}", error.reason()))
384 })?;
385 let normalized_definition = ThemeOverride {
386 extends: definition.extends,
387 palette: normalized_palette,
388 };
389
390 let mut theme = base_theme.clone();
391 if theme.id != *theme_id {
392 theme.name.clone_from(theme_id);
393 theme.description = None;
394 }
395 theme.id.clone_from(theme_id);
396 normalized_definition.palette.apply_to(&mut theme.palette);
397 warnings.extend(palette_warnings(theme_id, &theme.palette));
398 normalized.insert(theme_id.clone(), normalized_definition);
399 resolved.insert(theme_id.clone(), theme);
400 }
401
402 let mut effective_catalog = base_catalog.clone();
403 for theme in &mut effective_catalog.themes {
404 if let Some(configured) = resolved.remove(&theme.id) {
405 *theme = configured;
406 }
407 }
408 effective_catalog.themes.extend(resolved.into_values());
409
410 let normalized_json = serde_json::to_vec(&ThemeOverrides(normalized))
411 .expect("theme overrides contain only serializable public records");
412 let mut hash = Sha256::new();
413 hash.update(b"stack-theme-effective-v1\0");
414 hash.update(base_revision.as_bytes());
415 hash.update(b"\0");
416 hash.update(normalized_json);
417
418 let mut revision = String::with_capacity(71);
419 revision.push_str("sha256:");
420 for byte in hash.finalize() {
421 write!(&mut revision, "{byte:02x}").expect("writing to a string cannot fail");
422 }
423
424 Ok(ResolvedThemeCatalog {
425 catalog: effective_catalog,
426 revision,
427 warnings,
428 })
429}
430
431fn is_theme_identifier(value: &str) -> bool {
432 let bytes = value.as_bytes();
433 !bytes.is_empty()
434 && bytes.len() <= 64
435 && bytes[0].is_ascii_lowercase()
436 && bytes.iter().all(|byte| {
437 byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' || *byte == b'-'
438 })
439 && !value.contains("--")
440}
441
442fn normalize_color(value: &str) -> Option<String> {
443 let bytes = value.as_bytes();
444 if (bytes.len() == 7 || bytes.len() == 9)
445 && bytes[0] == b'#'
446 && bytes[1..].iter().all(u8::is_ascii_hexdigit)
447 {
448 Some(value.to_ascii_uppercase())
449 } else {
450 None
451 }
452}
453
454fn palette_warnings(theme_id: &str, palette: &Palette) -> Vec<ThemeOverrideWarning> {
455 let colors = [
456 ("canvas", &palette.canvas),
457 ("surface", &palette.surface),
458 ("surfaceMuted", &palette.surface_muted),
459 ("text", &palette.text),
460 ("textMuted", &palette.text_muted),
461 ("border", &palette.border),
462 ("accent", &palette.accent),
463 ("danger", &palette.danger),
464 ("connector", &palette.connector),
465 ];
466 let mut warnings = Vec::new();
467 for (token, color) in colors {
468 if has_transparency(color) {
469 warnings.push(ThemeOverrideWarning {
470 code: "theme-transparent-color".to_owned(),
471 theme_id: theme_id.to_owned(),
472 message: format!(
473 "palette.{token} uses transparency; contrast depends on its rendered backdrop"
474 ),
475 });
476 }
477 }
478
479 for (foreground, background, minimum) in [
480 ("text", "surface", 4.5),
481 ("textMuted", "surface", 4.5),
482 ("danger", "surface", 4.5),
483 ("border", "surface", 3.0),
484 ("accent", "surface", 3.0),
485 ("connector", "canvas", 3.0),
486 ] {
487 let foreground_color = palette_color(palette, foreground);
488 let background_color = palette_color(palette, background);
489 let (Some(foreground_rgb), Some(background_rgb)) =
490 (opaque_rgb(foreground_color), opaque_rgb(background_color))
491 else {
492 continue;
493 };
494 let ratio = contrast_ratio(foreground_rgb, background_rgb);
495 if ratio < minimum {
496 warnings.push(ThemeOverrideWarning {
497 code: "theme-low-contrast".to_owned(),
498 theme_id: theme_id.to_owned(),
499 message: format!(
500 "palette.{foreground} against palette.{background} has {ratio:.2}:1 contrast; expected at least {minimum:.1}:1"
501 ),
502 });
503 }
504 }
505 warnings
506}
507
508fn palette_color<'a>(palette: &'a Palette, token: &str) -> &'a str {
509 match token {
510 "canvas" => &palette.canvas,
511 "surface" => &palette.surface,
512 "text" => &palette.text,
513 "textMuted" => &palette.text_muted,
514 "border" => &palette.border,
515 "accent" => &palette.accent,
516 "danger" => &palette.danger,
517 "connector" => &palette.connector,
518 _ => unreachable!("contrast pairs use known palette tokens"),
519 }
520}
521
522fn opaque_rgb(value: &str) -> Option<[u8; 3]> {
523 if normalize_color(value).is_none() || has_transparency(value) {
524 return None;
525 }
526 Some([
527 u8::from_str_radix(value.get(1..3)?, 16).ok()?,
528 u8::from_str_radix(value.get(3..5)?, 16).ok()?,
529 u8::from_str_radix(value.get(5..7)?, 16).ok()?,
530 ])
531}
532
533fn has_transparency(value: &str) -> bool {
534 value.len() == 9
535 && !value
536 .get(7..9)
537 .is_some_and(|alpha| alpha.eq_ignore_ascii_case("ff"))
538}
539
540fn contrast_ratio(left: [u8; 3], right: [u8; 3]) -> f64 {
541 let left = relative_luminance(left);
542 let right = relative_luminance(right);
543 (left.max(right) + 0.05) / (left.min(right) + 0.05)
544}
545
546fn relative_luminance(color: [u8; 3]) -> f64 {
547 let channels = color.map(|channel| {
548 let value = f64::from(channel) / 255.0;
549 if value <= 0.04045 {
550 value / 12.92
551 } else {
552 ((value + 0.055) / 1.055).powf(2.4)
553 }
554 });
555 channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
556}
557
558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
560#[serde(rename_all = "camelCase")]
561pub struct Typography {
562 pub font_metrics_id: String,
563 pub node_label_size_milli_px: u32,
564 pub node_detail_size_milli_px: u32,
565 pub group_label_size_milli_px: u32,
566 pub edge_label_size_milli_px: u32,
567 pub line_height_permille: u32,
568 pub label_weight: u16,
569 pub detail_weight: u16,
570}
571
572#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
574#[serde(rename_all = "camelCase")]
575pub struct NodeKindFallbacks {
576 pub actor: NodeVisual,
577 pub client: NodeVisual,
578 pub service: NodeVisual,
579 #[serde(rename = "function")]
580 pub function_: NodeVisual,
581 pub worker: NodeVisual,
582 pub database: NodeVisual,
583 pub cache: NodeVisual,
584 pub queue: NodeVisual,
585 pub storage: NodeVisual,
586 pub external: NodeVisual,
587}
588
589#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
591#[serde(rename_all = "camelCase")]
592pub struct NodeVisual {
593 pub shape: NodeShape,
594 pub fill: PaletteToken,
595 pub stroke: PaletteToken,
596 pub text: PaletteToken,
597 pub accent: PaletteToken,
598 pub corner_radius_milli_px: u32,
599 pub fallback_icon_id: String,
600}
601
602#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
604#[serde(rename_all = "kebab-case")]
605pub enum NodeShape {
606 RoundedRectangle,
607 Capsule,
608 Circle,
609 Cylinder,
610 Hexagon,
611}
612
613#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
615#[serde(rename_all = "camelCase")]
616pub enum PaletteToken {
617 Canvas,
618 Surface,
619 SurfaceMuted,
620 Text,
621 TextMuted,
622 Border,
623 Accent,
624 Danger,
625 Connector,
626}
627
628#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
630#[serde(rename_all = "camelCase")]
631pub struct ConnectorStyle {
632 pub stroke: PaletteToken,
633 pub text: PaletteToken,
634 pub label_background: PaletteToken,
635 pub width_milli_px: u32,
636 pub arrow_size_milli_px: u32,
637 #[serde(skip_serializing_if = "Option::is_none")]
638 pub dash_milli_px: Option<Vec<u32>>,
639}
640
641#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
643#[serde(rename_all = "camelCase")]
644pub struct Icon {
645 pub id: String,
646 pub subject: String,
647 #[serde(skip_serializing_if = "Option::is_none")]
648 pub description: Option<String>,
649 pub asset: IconAsset,
650}
651
652#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
654#[serde(rename_all = "camelCase")]
655pub struct IconAsset {
656 pub path: String,
657 pub view_box: [i32; 4],
658 pub provenance: Provenance,
659}
660
661#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
663#[serde(rename_all = "camelCase")]
664pub struct Provenance {
665 pub source_url: String,
666 pub source_revision: String,
667 pub copyright: String,
668 pub license_spdx: String,
669 pub license_file: String,
670 pub modified: bool,
671 pub redistribution: Redistribution,
672}
673
674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
676#[serde(rename_all = "camelCase")]
677pub struct Redistribution {
678 pub cargo: bool,
679 pub npm: bool,
680 pub wasm: bool,
681 pub commercial_applications: bool,
682}
683
684#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
686#[serde(rename_all = "camelCase")]
687pub struct ProviderPack {
688 #[serde(rename = "$schema")]
689 pub schema: String,
690 pub schema_version: String,
691 pub pack_version: String,
692 pub provider: ProviderPackIdentity,
693 pub distribution_mode: ProviderPackDistributionMode,
694 pub source: ProviderPackSource,
695 #[serde(default, skip_serializing_if = "Vec::is_empty")]
696 pub additional_sources: Vec<ProviderPackAdditionalSource>,
697 pub rights: ProviderPackRights,
698 pub notice: ProviderPackNotice,
699 pub icons: Vec<ProviderIcon>,
700}
701
702#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
704#[serde(rename_all = "camelCase")]
705pub struct ProviderPackIdentity {
706 pub id: String,
707 pub name: String,
708}
709
710#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
712#[serde(rename_all = "kebab-case")]
713pub enum ProviderPackDistributionMode {
714 UserImported,
715}
716
717#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
719#[serde(rename_all = "camelCase")]
720pub struct ProviderPackSource {
721 pub page_url: String,
722 pub archive_url: String,
723 pub archive_sha256: String,
724 pub release: String,
725 pub retrieved_at: String,
726 pub terms_url: String,
727 pub terms_reviewed_at: String,
728 pub review_after: String,
729 pub copyright: String,
730 pub license_id: String,
731 pub archive_license_included: bool,
732}
733
734#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
736#[serde(rename_all = "camelCase")]
737pub struct ProviderPackAdditionalSource {
738 pub id: String,
739 #[serde(flatten)]
740 pub source: ProviderPackSource,
741}
742
743#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
745#[serde(rename_all = "camelCase")]
746pub struct ProviderPackRights {
747 pub terms_acceptance_required: bool,
748 pub permitted_outputs: Vec<ProviderPackPermittedOutput>,
749 pub redistribution: ProviderPackRedistribution,
750 pub processing: ProviderPackProcessing,
751 pub modification_policy: ProviderPackModificationPolicy,
752}
753
754#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
756#[serde(rename_all = "kebab-case")]
757pub enum ProviderPackPermittedOutput {
758 ArchitectureDiagram,
759 TrainingMaterial,
760 Documentation,
761 Whitepaper,
762 Presentation,
763 DataSheet,
764 Poster,
765}
766
767#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
769#[serde(rename_all = "camelCase")]
770pub struct ProviderPackRedistribution {
771 pub cargo: bool,
772 pub npm: bool,
773 pub wasm: bool,
774 pub web_asset: bool,
775 pub native_binary: bool,
776 pub generated_output: bool,
777}
778
779#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
781#[serde(rename_all = "camelCase")]
782pub struct ProviderPackProcessing {
783 pub local_only: bool,
784 pub automatic_download: bool,
785 pub server_upload: bool,
786 pub preserve_colors: bool,
787 pub preserve_geometry: bool,
788 pub product_name_nearby: bool,
789}
790
791#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
793#[serde(rename_all = "kebab-case")]
794pub enum ProviderPackModificationPolicy {
795 VisualPreservationOnly,
796}
797
798#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
800#[serde(rename_all = "camelCase")]
801pub struct ProviderPackNotice {
802 pub attribution: String,
803 pub terms_summary: String,
804 pub non_endorsement: String,
805}
806
807#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
809#[serde(rename_all = "camelCase")]
810pub struct ProviderIcon {
811 pub id: String,
812 pub subject: String,
813 pub product_name: String,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
815 pub brand_source_url: Option<String>,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub brand_guidelines_url: Option<String>,
818 pub recommended_node_kind: ProviderNodeKind,
819 pub asset: ProviderIconAsset,
820}
821
822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
824#[serde(rename_all = "kebab-case")]
825pub enum ProviderNodeKind {
826 Actor,
827 Client,
828 Service,
829 Function,
830 Worker,
831 Database,
832 Cache,
833 Queue,
834 Storage,
835 External,
836}
837
838#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
840#[serde(rename_all = "camelCase")]
841pub struct ProviderIconAsset {
842 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub source_id: Option<String>,
844 pub path: String,
845 pub original_path: String,
846 pub view_box: [i32; 4],
847 pub original_sha256: String,
848 pub processed_sha256: String,
849 pub transformations: Vec<ProviderPackTransformation>,
850}
851
852#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
854#[serde(rename_all = "kebab-case")]
855pub enum ProviderPackTransformation {
856 RemoveMetadata,
857 InlineStyles,
858 RemoveUnusedIdentifiers,
859 NamespaceIdentifiers,
860 ScaleViewBoxToIntegers,
861 NormalizeXml,
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 #[test]
869 fn embedded_catalog_matches_public_metadata() {
870 let catalog = catalog();
871
872 assert_eq!(catalog.schema_version, "1.0");
873 assert_eq!(catalog.catalog_version, CATALOG_VERSION);
874 assert!(CATALOG_REVISION.starts_with("sha256:"));
875 assert_eq!(CATALOG_REVISION.len(), 71);
876 assert_eq!(icon_svg("assets/missing.svg"), None);
877 assert_eq!(
878 catalog
879 .themes
880 .iter()
881 .map(|theme| theme.id.as_str())
882 .collect::<Vec<_>>(),
883 ["default", "light", "dark"]
884 );
885 assert!(
886 catalog
887 .themes
888 .iter()
889 .flat_map(|theme| &theme.icons)
890 .all(|icon| icon_svg(&icon.asset.path).is_some())
891 );
892 }
893
894 #[test]
895 fn embedded_catalog_round_trips_semantically() {
896 let reparsed: Catalog = serde_json::from_str(catalog_json()).unwrap();
897 let serialized = serde_json::to_value(&reparsed).unwrap();
898 let source: serde_json::Value = serde_json::from_str(catalog_json()).unwrap();
899 let schema: serde_json::Value = serde_json::from_str(catalog_schema_json()).unwrap();
900
901 assert_eq!(serialized, source);
902 assert_eq!(
903 schema["$schema"],
904 "https://json-schema.org/draft/2020-12/schema"
905 );
906 let provider_schema: serde_json::Value =
907 serde_json::from_str(provider_pack_schema_json()).unwrap();
908 assert_eq!(
909 provider_schema["$id"],
910 "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/provider-pack.schema.json"
911 );
912 let theme_overrides_schema: serde_json::Value =
913 serde_json::from_str(theme_overrides_schema_json()).unwrap();
914 assert_eq!(
915 theme_overrides_schema["$id"],
916 "https://raw.githubusercontent.com/stack-sh/theme/main/schemas/theme-overrides.schema.json"
917 );
918 }
919
920 #[test]
921 fn configured_themes_override_builtins_without_recursive_extends() {
922 let overrides: ThemeOverrides = serde_json::from_str(
923 r##"{
924 "custom-theme":{"extends":"light","palette":{"accent":"#005DBB","connector":"#334155"}},
925 "default":{"extends":"default","palette":{"canvas":"#F7F8FA"}}
926 }"##,
927 )
928 .unwrap();
929 let resolved = resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap();
930
931 let default = resolved
932 .catalog
933 .themes
934 .iter()
935 .find(|theme| theme.id == "default")
936 .unwrap();
937 let original_default = catalog()
938 .themes
939 .iter()
940 .find(|theme| theme.id == "default")
941 .unwrap();
942 assert_eq!(default.palette.canvas, "#F7F8FA");
943 assert_eq!(default.typography, original_default.typography);
944 assert_eq!(
945 default.node_kind_fallbacks,
946 original_default.node_kind_fallbacks
947 );
948
949 let custom = resolved
950 .catalog
951 .themes
952 .iter()
953 .find(|theme| theme.id == "custom-theme")
954 .unwrap();
955 let original_light = catalog()
956 .themes
957 .iter()
958 .find(|theme| theme.id == "light")
959 .unwrap();
960 assert_eq!(custom.name, "custom-theme");
961 assert_eq!(custom.palette.accent, "#005DBB");
962 assert_eq!(custom.palette.canvas, original_light.palette.canvas);
963 assert_eq!(custom.typography, original_light.typography);
964 assert_ne!(resolved.revision, CATALOG_REVISION);
965 assert!(resolved.revision.starts_with("sha256:"));
966 }
967
968 #[test]
969 fn effective_revision_uses_normalized_definition_order_and_colors() {
970 let left: ThemeOverrides = serde_json::from_str(
971 r##"{
972 "z_theme": {"extends":"dark","palette":{"accent":"#aabbcc"}},
973 "a_theme": {"extends":"light","palette":{"canvas":"#123456"}}
974 }"##,
975 )
976 .unwrap();
977 let right: ThemeOverrides = serde_json::from_str(
978 r##"{
979 "a_theme": {"extends":"light","palette":{"canvas":"#123456"}},
980 "z_theme": {"extends":"dark","palette":{"accent":"#AABBCC"}}
981 }"##,
982 )
983 .unwrap();
984
985 let left = resolve_theme_overrides(catalog(), CATALOG_REVISION, &left).unwrap();
986 let right = resolve_theme_overrides(catalog(), CATALOG_REVISION, &right).unwrap();
987 assert_eq!(left.revision, right.revision);
988 assert_eq!(left.catalog, right.catalog);
989 }
990
991 #[test]
992 fn empty_overrides_preserve_the_base_catalog_identity() {
993 let resolved =
994 resolve_theme_overrides(catalog(), CATALOG_REVISION, &ThemeOverrides::default())
995 .unwrap();
996 assert_eq!(resolved.catalog, *catalog());
997 assert_eq!(resolved.revision, CATALOG_REVISION);
998 assert!(resolved.warnings.is_empty());
999 }
1000
1001 #[test]
1002 fn invalid_theme_definitions_fail_before_resolution() {
1003 for (source, expected) in [
1004 (
1005 r##"{"invalid--name":{"extends":"default","palette":{"accent":"#000000"}}}"##,
1006 "identifier",
1007 ),
1008 (
1009 r##"{"empty":{"extends":"default","palette":{}}}"##,
1010 "at least one",
1011 ),
1012 (
1013 r##"{"bad_color":{"extends":"default","palette":{"accent":"red"}}}"##,
1014 "hexadecimal color",
1015 ),
1016 ] {
1017 let overrides: ThemeOverrides = serde_json::from_str(source).unwrap();
1018 let error =
1019 resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap_err();
1020 assert!(error.reason().contains(expected), "{}", error.reason());
1021 }
1022 }
1023
1024 #[test]
1025 fn configured_palette_concerns_are_warnings_and_colors_are_unchanged() {
1026 let overrides: ThemeOverrides = serde_json::from_str(
1027 r##"{
1028 "soft":{"extends":"light","palette":{"text":"#ffffff"}},
1029 "glass":{"extends":"dark","palette":{"canvas":"#11223380"}}
1030 }"##,
1031 )
1032 .unwrap();
1033 let resolved = resolve_theme_overrides(catalog(), CATALOG_REVISION, &overrides).unwrap();
1034
1035 assert!(
1036 resolved.warnings.iter().any(|warning| {
1037 warning.theme_id == "soft" && warning.code == "theme-low-contrast"
1038 })
1039 );
1040 assert!(resolved.warnings.iter().any(|warning| {
1041 warning.theme_id == "glass" && warning.code == "theme-transparent-color"
1042 }));
1043 assert_eq!(
1044 resolved
1045 .catalog
1046 .themes
1047 .iter()
1048 .find(|theme| theme.id == "soft")
1049 .unwrap()
1050 .palette
1051 .text,
1052 "#FFFFFF"
1053 );
1054 }
1055
1056 #[test]
1057 fn multi_source_provider_pack_round_trips_semantically() {
1058 let source = include_str!("../../../tests/fixtures/provider-pack/multi-source.json");
1059 let pack: ProviderPack = serde_json::from_str(source).unwrap();
1060
1061 assert_eq!(pack.schema_version, "1.1");
1062 assert_eq!(pack.additional_sources.len(), 1);
1063 assert_eq!(pack.additional_sources[0].id, "categories");
1064 assert_eq!(pack.icons[0].asset.source_id.as_deref(), Some("categories"));
1065 assert_eq!(
1066 pack.icons[0].brand_guidelines_url.as_deref(),
1067 Some("https://example.com/acme/brand-guidelines")
1068 );
1069 assert_eq!(
1070 serde_json::to_value(&pack).unwrap(),
1071 serde_json::from_str::<serde_json::Value>(source).unwrap()
1072 );
1073 }
1074}