Skip to main content

stack_theme/
lib.rs

1//! Typed access to the canonical Stack theme catalog.
2//!
3//! The embedded data is generated from `catalog/catalog.json`. It performs no
4//! filesystem, network, clock, locale, or host-font access at runtime.
5
6use 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/// Returns the embedded catalog parsed into the public Rust contract.
26#[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/// Returns the exact generated JSON embedded in the crate.
34#[must_use]
35pub const fn catalog_json() -> &'static str {
36    CATALOG_JSON
37}
38
39/// Returns the JSON Schema for the embedded catalog document shape.
40#[must_use]
41pub const fn catalog_schema_json() -> &'static str {
42    CATALOG_SCHEMA_JSON
43}
44
45/// Returns the JSON Schema for local user-imported provider icon packs.
46#[must_use]
47pub const fn provider_pack_schema_json() -> &'static str {
48    PROVIDER_PACK_SCHEMA_JSON
49}
50
51/// Returns the JSON Schema for palette-only user theme definitions.
52#[must_use]
53pub const fn theme_overrides_schema_json() -> &'static str {
54    THEME_OVERRIDES_SCHEMA_JSON
55}
56
57/// Returns one validated SVG asset by its catalog path.
58///
59/// The bytes are embedded at compile time; this function never reads the host
60/// filesystem or performs network access.
61#[must_use]
62pub fn icon_svg(asset_path: &str) -> Option<&'static str> {
63    generated::icon_svg(asset_path)
64}
65
66/// The complete versioned theme catalog.
67#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Catalog {
70    /// JSON Schema location recorded by the source catalog.
71    #[serde(rename = "$schema")]
72    pub schema: String,
73    /// Major/minor version of the catalog document shape.
74    pub schema_version: String,
75    /// Version shared by the catalog and both distribution packages.
76    pub catalog_version: String,
77    /// Theme identifiers that cannot be registered again.
78    pub reserved_theme_ids: Vec<String>,
79    /// Deterministic recovery choices for unavailable themes and icons.
80    pub fallbacks: CatalogFallbacks,
81    /// Deterministic, versioned font measurement tables.
82    pub font_metrics: Vec<FontMetrics>,
83    /// Theme records in canonical catalog order.
84    pub themes: Vec<Theme>,
85}
86
87/// Catalog-wide recovery choices used after emitting a missing-resource diagnostic.
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct CatalogFallbacks {
91    /// Core theme selected when a requested non-core theme is unavailable.
92    pub missing_theme_id: String,
93    /// Logical icon selected when an icon is unavailable in the resolved theme.
94    pub missing_icon_id: String,
95}
96
97/// One deterministic font measurement table.
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct FontMetrics {
101    /// Catalog-local metrics identifier.
102    pub id: String,
103    /// Display family name.
104    pub family: String,
105    /// Upstream or repository-authored metrics version.
106    pub version: String,
107    /// Font design units per em.
108    pub units_per_em: u32,
109    /// Ascender in font design units.
110    pub ascent: i32,
111    /// Descender in font design units.
112    pub descent: i32,
113    /// Additional line gap in font design units.
114    pub line_gap: u32,
115    /// Advance used for a scalar absent from `glyph_advances`.
116    pub default_advance: u32,
117    /// Advance used for a scalar covered by `wide_ranges`.
118    pub wide_advance: u32,
119    /// Ordered, non-overlapping Unicode scalar ranges treated as wide.
120    pub wide_ranges: Vec<UnicodeRange>,
121    /// Unicode scalar advances keyed as uppercase `U+XXXX` values.
122    pub glyph_advances: BTreeMap<String, u32>,
123    /// Source, license, and distribution evidence for the metrics.
124    pub provenance: Provenance,
125}
126
127/// An inclusive Unicode scalar range encoded as `U+XXXX` labels.
128#[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/// One theme and its theme-local icon collection.
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(rename_all = "camelCase")]
138pub struct Theme {
139    /// Global Stack theme identifier.
140    pub id: String,
141    /// Human-readable theme name.
142    pub name: String,
143    /// Optional contributor-facing description.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub description: Option<String>,
146    /// Named colors available to other theme records.
147    pub palette: Palette,
148    /// Typography sizes, weights, and deterministic metrics reference.
149    pub typography: Typography,
150    /// Visual fallback for every Stack node kind.
151    pub node_kind_fallbacks: NodeKindFallbacks,
152    /// Connector and connector-label treatment.
153    pub connector: ConnectorStyle,
154    /// Theme-local named and fallback icon assets.
155    pub icons: Vec<Icon>,
156}
157
158/// Required semantic color slots.
159#[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/// Palette-only theme definitions supplied by one user configuration.
174#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
175#[serde(transparent)]
176pub struct ThemeOverrides(pub BTreeMap<String, ThemeOverride>);
177
178/// One user theme definition resolved from a built-in theme.
179#[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/// Built-in themes that may supply non-palette records to a user theme.
187#[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/// Semantic color slots changed by one user theme definition.
207#[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/// A catalog with all configured themes applied and a reproducible identity.
295#[derive(Clone, Debug, Eq, PartialEq)]
296pub struct ResolvedThemeCatalog {
297    pub catalog: Catalog,
298    pub revision: String,
299    pub warnings: Vec<ThemeOverrideWarning>,
300}
301
302/// A non-fatal usability concern found in one configured palette.
303#[derive(Clone, Debug, Eq, PartialEq)]
304pub struct ThemeOverrideWarning {
305    pub code: String,
306    pub theme_id: String,
307    pub message: String,
308}
309
310/// A theme definition that cannot be resolved safely and deterministically.
311#[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
337/// Applies user palette definitions over an immutable built-in catalog.
338///
339/// Every `extends` lookup uses `base_catalog`, including when the configured
340/// name shadows a built-in theme. This makes `default extends default` an
341/// intentional override instead of a recursive definition.
342pub 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/// Typography values expressed without platform font measurement.
559#[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/// Complete fallback mapping for Stack 1.0 node kinds.
573#[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/// Node shape, palette references, and fallback icon.
590#[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/// Renderer-supported node outlines.
603#[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/// A reference to a required palette slot.
614#[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/// Connector line and label treatment.
629#[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/// Theme-local icon metadata and its safe SVG asset.
642#[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/// A repository-relative icon asset and declared viewport.
653#[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/// Source, license, and distribution evidence for an asset.
662#[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/// Supported artifact and application distribution channels.
675#[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/// A local provider icon pack produced from an archive selected by the user.
685#[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/// Stable provider namespace and display name.
703#[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/// Provider packs are always supplied through an explicit local import.
711#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
712#[serde(rename_all = "kebab-case")]
713pub enum ProviderPackDistributionMode {
714    UserImported,
715}
716
717/// Immutable provenance for the official source archive and reviewed terms.
718#[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/// An additional audited archive used by a multi-source provider pack.
735#[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/// Provider-specific usage boundary retained with every imported pack.
744#[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/// Output categories copied from the provider's reviewed terms.
755#[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/// Asset redistribution switches fixed by the user-imported contract.
768#[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/// Local processing and artwork-preservation requirements.
780#[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/// The only modification policy supported by the provider-pack schema.
792#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
793#[serde(rename_all = "kebab-case")]
794pub enum ProviderPackModificationPolicy {
795    VisualPreservationOnly,
796}
797
798/// User-visible source, terms, and non-endorsement text.
799#[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/// One namespaced product icon and its locally processed safe SVG.
808#[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/// Stack node-kind recommendation attached without changing node semantics.
823#[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/// Original and processed identities for one local SVG file.
839#[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/// Auditable, visual-preservation-only transformations applied during import.
853#[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}