Skip to main content

herogpui_theme/
theme_document.rs

1//! Sparse theme documents, applied through [`ThemeBuilder`].
2//!
3//! A document records only the tokens a caller overrode, the same way v3
4//! authors a `[data-theme]` block: the rest derive from the named `base`
5//! (`light` or `dark`). Serializing a complete [`Theme`] would freeze every
6//! derived hover / soft mix; going through the builder keeps those mixes live.
7//!
8//! The JSON keys are the [`ThemeBuilder`] methods. `.shots/theme_serde_audit.py`
9//! fails if a builder method is added or renamed without a matching key here.
10
11use std::fmt;
12
13use gpui::{px, Hsla, Rgba};
14use herogpui_core::oklcha;
15use serde::{Deserialize, Serialize};
16
17use crate::{Appearance, Theme, ThemeBuilder};
18
19/// A sparse override document for a [`Theme`].
20///
21/// `id` and `base` are required. Every other field is optional and maps onto
22/// one [`ThemeBuilder`] method of the same name (`role` is the map `roles`).
23#[derive(Clone, Debug, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct ThemeDocument {
26    pub id: String,
27    /// Which built-in theme the overrides extend: `"light"` or `"dark"`.
28    pub base: Appearance,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub appearance: Option<Appearance>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub radius: Option<f32>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub field_radius: Option<f32>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub border_width: Option<f32>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub disabled_opacity: Option<f32>,
39    /// The hover cursor for interactive controls, by gpui's `CursorStyle`
40    /// variant name (`"PointingHand"` is v3's `cursor: pointer`, `"Arrow"`
41    /// the platform default).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub cursor_interactive: Option<gpui::CursorStyle>,
44    /// The opacity a hovered `Tabs` item drops to; clamped to `0..=1`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub tabs_hover_opacity: Option<f32>,
47    /// The warm window after the pointer leaves a tooltip during which the
48    /// next tip opens without its delay.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub tooltip_cooldown_ms: Option<u64>,
51    /// How long a `DropdownTrigger::LongPress` waits before it opens.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub long_press_ms: Option<u64>,
54    /// The background fade duration of `anim::hover_fade`.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub hover_fade_ms: Option<u64>,
57    /// `--tooltip-delay`: how long a hover waits before the tip opens.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tooltip_delay_ms: Option<u64>,
60    /// `--tooltip-close-delay`: the per-tooltip close delay default.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub tooltip_close_delay_ms: Option<u64>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub background: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub foreground: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub muted: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub border: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub separator: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub focus: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub link: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub backdrop: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub surface: Option<ColorPair>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub surface_levels: Option<SurfaceLevels>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub overlay: Option<ColorPair>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub segment: Option<ColorPair>,
87    /// Shorthand for [`ThemeBuilder::accent`]: sets `--accent` and derives
88    /// the foreground. Conflicts with `roles.accent`.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub accent: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub roles: Option<Roles>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub field: Option<ColorPair>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub field_placeholder: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub field_border: Option<String>,
99    /// HeroUI's `[data-vibrant-palette="true"]`: reweights the accent,
100    /// success, warning and danger `*-soft-foreground` mixes to 92/8.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub vibrant_palette: Option<bool>,
103}
104
105/// A background / foreground pair, matching the two-argument builder methods.
106#[derive(Clone, Debug, Deserialize, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct ColorPair {
109    pub background: String,
110    pub foreground: String,
111}
112
113/// `--surface-secondary` and `--surface-tertiary`.
114#[derive(Clone, Debug, Deserialize, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct SurfaceLevels {
117    pub secondary: String,
118    pub tertiary: String,
119}
120
121/// The five role slots [`ThemeBuilder::role`] accepts.
122#[derive(Clone, Debug, Default, Deserialize, Serialize)]
123#[serde(deny_unknown_fields)]
124pub struct Roles {
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub default: Option<RoleOverride>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub accent: Option<RoleOverride>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub success: Option<RoleOverride>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub warning: Option<RoleOverride>,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub danger: Option<RoleOverride>,
135}
136
137/// One role's base colour and its on-colour foreground.
138#[derive(Clone, Debug, Deserialize, Serialize)]
139#[serde(deny_unknown_fields)]
140pub struct RoleOverride {
141    pub color: String,
142    pub foreground: String,
143    /// An explicit `*-hover` for the role, in place of the mix-toward-
144    /// foreground derivation — the JSON spelling of
145    /// [`ThemeBuilder::role_hover`]. Unset keeps the derived shade.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub hover: Option<String>,
148}
149
150/// Why a document could not become a [`Theme`].
151#[derive(Debug)]
152pub enum ThemeDocumentError {
153    Json(serde_json::Error),
154    Color {
155        field: String,
156        value: String,
157        detail: String,
158    },
159    AccentConflict,
160}
161
162impl fmt::Display for ThemeDocumentError {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        match self {
165            Self::Json(err) => write!(f, "theme document: {err}"),
166            Self::Color {
167                field,
168                value,
169                detail,
170            } => {
171                write!(
172                    f,
173                    "theme document: {field} value {value:?} is not a colour ({detail})"
174                )
175            }
176            Self::AccentConflict => write!(
177                f,
178                "theme document: `accent` and `roles.accent` cannot both be set"
179            ),
180        }
181    }
182}
183
184impl std::error::Error for ThemeDocumentError {
185    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
186        match self {
187            Self::Json(err) => Some(err),
188            Self::Color { .. } | Self::AccentConflict => None,
189        }
190    }
191}
192
193impl From<serde_json::Error> for ThemeDocumentError {
194    fn from(err: serde_json::Error) -> Self {
195        Self::Json(err)
196    }
197}
198
199impl ThemeDocument {
200    /// Parse a JSON document.
201    pub fn from_json(json: &str) -> Result<Self, ThemeDocumentError> {
202        Ok(serde_json::from_str(json)?)
203    }
204
205    /// Parse a JSON document and apply it through [`ThemeBuilder`].
206    pub fn theme_from_json(json: &str) -> Result<Theme, ThemeDocumentError> {
207        Self::from_json(json)?.to_theme()
208    }
209
210    /// Serialize this sparse document. Derived tokens are not written.
211    pub fn to_json(&self) -> Result<String, ThemeDocumentError> {
212        Ok(serde_json::to_string_pretty(self)?)
213    }
214
215    /// Apply the overrides through [`ThemeBuilder`].
216    pub fn to_theme(&self) -> Result<Theme, ThemeDocumentError> {
217        let base = match self.base {
218            Appearance::Light => Theme::light(),
219            Appearance::Dark => Theme::dark(),
220        };
221        let mut builder = Theme::builder(self.id.clone(), base);
222        if let Some(appearance) = self.appearance {
223            builder = builder.appearance(appearance);
224        }
225        if let Some(radius) = self.radius {
226            builder = builder.radius(px(radius));
227        }
228        if let Some(radius) = self.field_radius {
229            builder = builder.field_radius(px(radius));
230        }
231        if let Some(width) = self.border_width {
232            builder = builder.border_width(px(width));
233        }
234        if let Some(opacity) = self.disabled_opacity {
235            builder = builder.disabled_opacity(opacity);
236        }
237        if let Some(cursor) = self.cursor_interactive {
238            builder = builder.cursor_interactive(cursor);
239        }
240        if let Some(opacity) = self.tabs_hover_opacity {
241            builder = builder.tabs_hover_opacity(opacity);
242        }
243        if let Some(ms) = self.tooltip_cooldown_ms {
244            builder = builder.tooltip_cooldown_ms(ms);
245        }
246        if let Some(ms) = self.long_press_ms {
247            builder = builder.long_press_ms(ms);
248        }
249        if let Some(ms) = self.hover_fade_ms {
250            builder = builder.hover_fade_ms(ms);
251        }
252        if let Some(ms) = self.tooltip_delay_ms {
253            builder = builder.tooltip_delay_ms(ms);
254        }
255        if let Some(vibrant) = self.vibrant_palette {
256            builder = builder.vibrant_palette(vibrant);
257        }
258        if let Some(ms) = self.tooltip_close_delay_ms {
259            builder = builder.tooltip_close_delay_ms(ms);
260        }
261        builder = apply_color(
262            builder,
263            "background",
264            self.background.as_deref(),
265            ThemeBuilder::background,
266        )?;
267        builder = apply_color(
268            builder,
269            "foreground",
270            self.foreground.as_deref(),
271            ThemeBuilder::foreground,
272        )?;
273        builder = apply_color(builder, "muted", self.muted.as_deref(), ThemeBuilder::muted)?;
274        builder = apply_color(
275            builder,
276            "border",
277            self.border.as_deref(),
278            ThemeBuilder::border,
279        )?;
280        builder = apply_color(
281            builder,
282            "separator",
283            self.separator.as_deref(),
284            ThemeBuilder::separator,
285        )?;
286        builder = apply_color(builder, "focus", self.focus.as_deref(), ThemeBuilder::focus)?;
287        builder = apply_color(builder, "link", self.link.as_deref(), ThemeBuilder::link)?;
288        builder = apply_color(
289            builder,
290            "backdrop",
291            self.backdrop.as_deref(),
292            ThemeBuilder::backdrop,
293        )?;
294        if let Some(pair) = &self.surface {
295            builder = builder.surface(
296                parse_color("surface.background", &pair.background)?,
297                parse_color("surface.foreground", &pair.foreground)?,
298            );
299        }
300        if let Some(levels) = &self.surface_levels {
301            builder = builder.surface_levels(
302                parse_color("surface_levels.secondary", &levels.secondary)?,
303                parse_color("surface_levels.tertiary", &levels.tertiary)?,
304            );
305        }
306        if let Some(pair) = &self.overlay {
307            builder = builder.overlay(
308                parse_color("overlay.background", &pair.background)?,
309                parse_color("overlay.foreground", &pair.foreground)?,
310            );
311        }
312        if let Some(pair) = &self.segment {
313            builder = builder.segment(
314                parse_color("segment.background", &pair.background)?,
315                parse_color("segment.foreground", &pair.foreground)?,
316            );
317        }
318        if self.accent.is_some() && self.roles.as_ref().is_some_and(|r| r.accent.is_some()) {
319            return Err(ThemeDocumentError::AccentConflict);
320        }
321        if let Some(accent) = &self.accent {
322            builder = builder.accent(parse_color("accent", accent)?);
323        }
324        if let Some(roles) = &self.roles {
325            builder = apply_role(builder, "default", roles.default.as_ref())?;
326            builder = apply_role(builder, "accent", roles.accent.as_ref())?;
327            builder = apply_role(builder, "success", roles.success.as_ref())?;
328            builder = apply_role(builder, "warning", roles.warning.as_ref())?;
329            builder = apply_role(builder, "danger", roles.danger.as_ref())?;
330        }
331        if let Some(pair) = &self.field {
332            builder = builder.field(
333                parse_color("field.background", &pair.background)?,
334                parse_color("field.foreground", &pair.foreground)?,
335            );
336        }
337        builder = apply_color(
338            builder,
339            "field_placeholder",
340            self.field_placeholder.as_deref(),
341            ThemeBuilder::field_placeholder,
342        )?;
343        builder = apply_color(
344            builder,
345            "field_border",
346            self.field_border.as_deref(),
347            ThemeBuilder::field_border,
348        )?;
349        Ok(builder.build())
350    }
351}
352
353fn apply_color(
354    builder: ThemeBuilder,
355    field: &str,
356    raw: Option<&str>,
357    apply: fn(ThemeBuilder, Hsla) -> ThemeBuilder,
358) -> Result<ThemeBuilder, ThemeDocumentError> {
359    match raw {
360        Some(value) => Ok(apply(builder, parse_color(field, value)?)),
361        None => Ok(builder),
362    }
363}
364
365fn apply_role(
366    builder: ThemeBuilder,
367    name: &str,
368    role: Option<&RoleOverride>,
369) -> Result<ThemeBuilder, ThemeDocumentError> {
370    match role {
371        Some(role) => {
372            let builder = builder.role(
373                name,
374                parse_color(&format!("roles.{name}.color"), &role.color)?,
375                parse_color(&format!("roles.{name}.foreground"), &role.foreground)?,
376            );
377            match &role.hover {
378                Some(hover) => {
379                    Ok(builder
380                        .role_hover(name, parse_color(&format!("roles.{name}.hover"), hover)?))
381                }
382                None => Ok(builder),
383            }
384        }
385        None => Ok(builder),
386    }
387}
388
389/// Colours are CSS `oklch()` / `oklcha()` or `#RGB` / `#RRGGBB` / `#RRGGBBAA`.
390fn parse_color(field: &str, raw: &str) -> Result<Hsla, ThemeDocumentError> {
391    let value = raw.trim();
392    if let Some(hex) = value.strip_prefix('#') {
393        return parse_hex(field, value, hex);
394    }
395    if let Some(inner) = value
396        .strip_prefix("oklch(")
397        .and_then(|rest| rest.strip_suffix(')'))
398    {
399        return parse_oklch(field, value, inner);
400    }
401    if let Some(inner) = value
402        .strip_prefix("oklcha(")
403        .and_then(|rest| rest.strip_suffix(')'))
404    {
405        return parse_oklch(field, value, inner);
406    }
407    Err(ThemeDocumentError::Color {
408        field: field.to_owned(),
409        value: value.to_owned(),
410        detail: "expected oklch(...), oklcha(...) or #hex".into(),
411    })
412}
413
414fn parse_oklch(field: &str, raw: &str, inner: &str) -> Result<Hsla, ThemeDocumentError> {
415    let normalized = inner.replace('/', " ");
416    let parts: Vec<&str> = normalized.split_whitespace().collect();
417    if parts.len() < 3 || parts.len() > 4 {
418        return Err(ThemeDocumentError::Color {
419            field: field.to_owned(),
420            value: raw.to_owned(),
421            detail: "oklch takes L C H, optionally / alpha".into(),
422        });
423    }
424    let l = parse_component(field, raw, parts[0], true)?;
425    let c = parse_component(field, raw, parts[1], false)?;
426    let h = parse_component(field, raw, parts[2], false)?;
427    let a = match parts.get(3) {
428        Some(part) => parse_component(field, raw, part, false)?,
429        None => 1.0,
430    };
431    Ok(oklcha(l, c, h, a))
432}
433
434fn parse_component(
435    field: &str,
436    raw: &str,
437    part: &str,
438    lightness: bool,
439) -> Result<f32, ThemeDocumentError> {
440    let percent = part.ends_with('%');
441    let number = part.trim_end_matches('%');
442    let value: f32 = number.parse().map_err(|_| ThemeDocumentError::Color {
443        field: field.to_owned(),
444        value: raw.to_owned(),
445        detail: format!("cannot parse {part:?} as a number"),
446    })?;
447    if percent || (lightness && value > 1.0) {
448        Ok(value / 100.0)
449    } else {
450        Ok(value)
451    }
452}
453
454fn parse_hex(field: &str, raw: &str, hex: &str) -> Result<Hsla, ThemeDocumentError> {
455    let hex = hex.trim();
456    let fail = |detail: &str| ThemeDocumentError::Color {
457        field: field.to_owned(),
458        value: raw.to_owned(),
459        detail: detail.into(),
460    };
461    let nibble = |ch: u8| match ch {
462        b'0'..=b'9' => Ok(ch - b'0'),
463        b'a'..=b'f' => Ok(ch - b'a' + 10),
464        b'A'..=b'F' => Ok(ch - b'A' + 10),
465        _ => Err(fail("hex digit is not 0-9A-F")),
466    };
467    let byte =
468        |hi: u8, lo: u8| -> Result<u8, ThemeDocumentError> { Ok((nibble(hi)? << 4) | nibble(lo)?) };
469    let bytes = hex.as_bytes();
470    let (r, g, b, a) = match bytes {
471        [r, g, b] => (nibble(*r)? * 17, nibble(*g)? * 17, nibble(*b)? * 17, 255),
472        [r, g, b, a] => (
473            nibble(*r)? * 17,
474            nibble(*g)? * 17,
475            nibble(*b)? * 17,
476            nibble(*a)? * 17,
477        ),
478        [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2)?, byte(*g1, *g2)?, byte(*b1, *b2)?, 255),
479        [r1, r2, g1, g2, b1, b2, a1, a2] => (
480            byte(*r1, *r2)?,
481            byte(*g1, *g2)?,
482            byte(*b1, *b2)?,
483            byte(*a1, *a2)?,
484        ),
485        _ => return Err(fail("hex is #RGB, #RGBA, #RRGGBB or #RRGGBBAA")),
486    };
487    Ok(Hsla::from(Rgba {
488        r: r as f32 / 255.0,
489        g: g as f32 / 255.0,
490        b: b as f32 / 255.0,
491        a: a as f32 / 255.0,
492    }))
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use herogpui_core::{oklch, with_alpha};
499
500    #[test]
501    fn an_empty_document_is_the_named_base_with_a_new_id() {
502        let theme =
503            ThemeDocument::theme_from_json(r#"{ "id": "brand", "base": "light" }"#).unwrap();
504        let base = Theme::light();
505        assert_eq!(theme.id.as_ref(), "brand");
506        assert_eq!(theme.appearance, Appearance::Light);
507        assert_eq!(theme.colors.background, base.colors.background);
508        assert_eq!(theme.colors.accent.color, base.colors.accent.color);
509        assert_eq!(theme.layout.radius, base.layout.radius);
510    }
511
512    #[test]
513    fn overrides_go_through_the_builder_so_derived_mixes_stay_live() {
514        let accent = oklch(0.55, 0.23, 295.0);
515        let via_builder = Theme::builder("violet", Theme::light())
516            .accent(accent)
517            .foreground(oklch(0.30, 0.05, 120.0))
518            .build();
519        let via_json = ThemeDocument::theme_from_json(
520            r#"{
521                "id": "violet",
522                "base": "light",
523                "accent": "oklch(0.55 0.23 295)",
524                "foreground": "oklch(0.30 0.05 120)"
525            }"#,
526        )
527        .unwrap();
528        assert_eq!(
529            via_json.colors.accent.color,
530            via_builder.colors.accent.color
531        );
532        assert_eq!(
533            via_json.colors.accent.foreground,
534            via_builder.colors.accent.foreground
535        );
536        assert_eq!(via_json.colors.scrollbar, via_builder.colors.scrollbar);
537        assert_eq!(
538            via_json.colors.scrollbar,
539            with_alpha(oklch(0.30, 0.05, 120.0), 0.15)
540        );
541        assert!((via_json.colors.accent.soft().a - 0.15).abs() < 1e-4);
542    }
543
544    #[test]
545    fn unknown_keys_are_rejected() {
546        let err =
547            ThemeDocument::from_json(r##"{ "id": "x", "base": "light", "primary": "#f00" }"##)
548                .unwrap_err();
549        let message = err.to_string();
550        assert!(
551            message.contains("primary") || message.contains("unknown"),
552            "{message}"
553        );
554    }
555
556    #[test]
557    fn accent_and_roles_accent_cannot_both_be_set() {
558        let err = ThemeDocument::theme_from_json(
559            r##"{
560                "id": "x",
561                "base": "light",
562                "accent": "#006FEE",
563                "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } }
564            }"##,
565        )
566        .unwrap_err();
567        assert!(matches!(err, ThemeDocumentError::AccentConflict));
568    }
569
570    #[test]
571    fn hex_and_percent_lightness_parse() {
572        let theme = ThemeDocument::theme_from_json(
573            r##"{
574                "id": "x",
575                "base": "dark",
576                "background": "#111",
577                "link": "oklch(55% 0.2 250 / 0.9)"
578            }"##,
579        )
580        .unwrap();
581        assert_eq!(theme.id.as_ref(), "x");
582        assert_eq!(theme.appearance, Appearance::Dark);
583        assert!((theme.colors.link.a - 0.9).abs() < 1e-4);
584    }
585
586    #[test]
587    fn customisation_tokens_apply_from_json_through_the_builder() {
588        let json = r#"{
589                "id": "x",
590                "base": "light",
591                "tabs_hover_opacity": 0.2,
592                "tooltip_cooldown_ms": 250,
593                "long_press_ms": 350,
594                "hover_fade_ms": 0,
595                "tooltip_delay_ms": 50,
596                "tooltip_close_delay_ms": 75
597            }"#;
598        let theme = ThemeDocument::theme_from_json(json).unwrap();
599        assert!((theme.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
600        assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
601        assert_eq!(theme.layout.long_press_ms, 350);
602        assert_eq!(theme.layout.hover_fade_ms, 0);
603        assert_eq!(theme.layout.tooltip_delay_ms, 50);
604        assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
605
606        // Round-trip the sparse document: serializing must not drop a token
607        // and re-parsing must apply the same values.
608        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
609        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
610        assert!((again.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
611        assert_eq!(again.layout.tooltip_cooldown_ms, 250);
612        assert_eq!(again.layout.long_press_ms, 350);
613        assert_eq!(again.layout.hover_fade_ms, 0);
614        assert_eq!(again.layout.tooltip_delay_ms, 50);
615        assert_eq!(again.layout.tooltip_close_delay_ms, 75);
616
617        let clamped = ThemeDocument::theme_from_json(
618            r#"{ "id": "x", "base": "light", "tabs_hover_opacity": 3.0 }"#,
619        )
620        .unwrap();
621        assert!((clamped.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
622    }
623
624    #[test]
625    fn the_vibrant_palette_key_round_trips_and_reaches_the_builder() {
626        let json = r#"{ "id": "x", "base": "light", "vibrant_palette": true }"#;
627        let theme = ThemeDocument::theme_from_json(json).unwrap();
628        assert!(theme.colors.vibrant_palette());
629
630        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
631        assert!(round_tripped.contains("vibrant_palette"));
632        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
633        assert!(again.colors.vibrant_palette());
634
635        // Absent means off, like a document without the attribute.
636        let plain = ThemeDocument::theme_from_json(r#"{ "id": "x", "base": "dark" }"#).unwrap();
637        assert!(!plain.colors.vibrant_palette());
638        assert!(
639            !ThemeDocument::from_json(r#"{ "id": "x", "base": "dark" }"#)
640                .unwrap()
641                .to_json()
642                .unwrap()
643                .contains("vibrant_palette")
644        );
645    }
646
647    #[test]
648    fn a_role_hover_override_round_trips_and_reaches_the_builder() {
649        let json = r##"{
650                "id": "x",
651                "base": "light",
652                "roles": { "accent": { "color": "#006FEE", "foreground": "#fff", "hover": "#0058BE" } }
653            }"##;
654        let theme = ThemeDocument::theme_from_json(json).unwrap();
655        let via_builder = Theme::builder("x", Theme::light())
656            .role(
657                "accent",
658                parse_color("c", "#006FEE").unwrap(),
659                parse_color("f", "#fff").unwrap(),
660            )
661            .role_hover("accent", parse_color("h", "#0058BE").unwrap())
662            .build();
663        assert_eq!(
664            theme.colors.accent.hover(),
665            via_builder.colors.accent.hover()
666        );
667        assert_eq!(
668            theme.colors.accent.hover_override(),
669            via_builder.colors.accent.hover_override()
670        );
671
672        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
673        assert!(round_tripped.contains("hover"));
674        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
675        assert!(again.colors.accent.hover_override().is_some());
676
677        // Absent means the derived shade, like a role without the key.
678        let plain = ThemeDocument::theme_from_json(
679            r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
680        )
681        .unwrap();
682        assert_eq!(plain.colors.accent.hover_override(), None);
683        assert!(
684            !ThemeDocument::from_json(
685                r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
686            )
687            .unwrap()
688            .to_json()
689            .unwrap()
690            .contains("hover")
691        );
692    }
693
694    #[test]
695    fn a_document_round_trips_without_growing_derived_keys() {
696        let original =
697            ThemeDocument::from_json(r#"{ "id": "brand", "base": "light", "radius": 8 }"#).unwrap();
698        let json = original.to_json().unwrap();
699        assert!(!json.contains("scrollbar"));
700        assert!(!json.contains("soft"));
701        let again = ThemeDocument::from_json(&json).unwrap();
702        assert_eq!(again.id, "brand");
703        assert_eq!(again.radius, Some(8.0));
704    }
705}