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    // `f32::from_str` also accepts `NaN` and `inf`; neither is a colour.
443    let value: f32 = number
444        .parse()
445        .ok()
446        .filter(|v: &f32| v.is_finite())
447        .ok_or_else(|| ThemeDocumentError::Color {
448            field: field.to_owned(),
449            value: raw.to_owned(),
450            detail: format!("cannot parse {part:?} as a finite number"),
451        })?;
452    if percent || (lightness && value > 1.0) {
453        Ok(value / 100.0)
454    } else {
455        Ok(value)
456    }
457}
458
459fn parse_hex(field: &str, raw: &str, hex: &str) -> Result<Hsla, ThemeDocumentError> {
460    let hex = hex.trim();
461    let fail = |detail: &str| ThemeDocumentError::Color {
462        field: field.to_owned(),
463        value: raw.to_owned(),
464        detail: detail.into(),
465    };
466    let nibble = |ch: u8| match ch {
467        b'0'..=b'9' => Ok(ch - b'0'),
468        b'a'..=b'f' => Ok(ch - b'a' + 10),
469        b'A'..=b'F' => Ok(ch - b'A' + 10),
470        _ => Err(fail("hex digit is not 0-9A-F")),
471    };
472    let byte =
473        |hi: u8, lo: u8| -> Result<u8, ThemeDocumentError> { Ok((nibble(hi)? << 4) | nibble(lo)?) };
474    let bytes = hex.as_bytes();
475    let (r, g, b, a) = match bytes {
476        [r, g, b] => (nibble(*r)? * 17, nibble(*g)? * 17, nibble(*b)? * 17, 255),
477        [r, g, b, a] => (
478            nibble(*r)? * 17,
479            nibble(*g)? * 17,
480            nibble(*b)? * 17,
481            nibble(*a)? * 17,
482        ),
483        [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2)?, byte(*g1, *g2)?, byte(*b1, *b2)?, 255),
484        [r1, r2, g1, g2, b1, b2, a1, a2] => (
485            byte(*r1, *r2)?,
486            byte(*g1, *g2)?,
487            byte(*b1, *b2)?,
488            byte(*a1, *a2)?,
489        ),
490        _ => return Err(fail("hex is #RGB, #RGBA, #RRGGBB or #RRGGBBAA")),
491    };
492    Ok(Hsla::from(Rgba {
493        r: r as f32 / 255.0,
494        g: g as f32 / 255.0,
495        b: b as f32 / 255.0,
496        a: a as f32 / 255.0,
497    }))
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use herogpui_core::{oklch, with_alpha};
504
505    /// `f32::from_str` accepts `NaN`, `inf` and `infinity`; a theme file is
506    /// untrusted input, so a non-finite component is a parse error rather
507    /// than a colour that poisons every blend derived from it.
508    #[test]
509    fn non_finite_color_components_are_rejected() {
510        for raw in [
511            "oklch(NaN 0.1 250)",
512            "oklch(0.5 inf 250)",
513            "oklch(0.5 0.1 -infinity)",
514            "oklch(0.5 0.1 250 / nan)",
515        ] {
516            let json = format!(r#"{{ "id": "x", "base": "light", "accent": "{raw}" }}"#);
517            let err = ThemeDocument::theme_from_json(&json).unwrap_err();
518            assert!(
519                matches!(err, ThemeDocumentError::Color { .. }),
520                "{raw}: {err}"
521            );
522        }
523    }
524
525    #[test]
526    fn an_empty_document_is_the_named_base_with_a_new_id() {
527        let theme =
528            ThemeDocument::theme_from_json(r#"{ "id": "brand", "base": "light" }"#).unwrap();
529        let base = Theme::light();
530        assert_eq!(theme.id.as_ref(), "brand");
531        assert_eq!(theme.appearance, Appearance::Light);
532        assert_eq!(theme.colors.background, base.colors.background);
533        assert_eq!(theme.colors.accent.color, base.colors.accent.color);
534        assert_eq!(theme.layout.radius, base.layout.radius);
535    }
536
537    #[test]
538    fn overrides_go_through_the_builder_so_derived_mixes_stay_live() {
539        let accent = oklch(0.55, 0.23, 295.0);
540        let via_builder = Theme::builder("violet", Theme::light())
541            .accent(accent)
542            .foreground(oklch(0.30, 0.05, 120.0))
543            .build();
544        let via_json = ThemeDocument::theme_from_json(
545            r#"{
546                "id": "violet",
547                "base": "light",
548                "accent": "oklch(0.55 0.23 295)",
549                "foreground": "oklch(0.30 0.05 120)"
550            }"#,
551        )
552        .unwrap();
553        assert_eq!(
554            via_json.colors.accent.color,
555            via_builder.colors.accent.color
556        );
557        assert_eq!(
558            via_json.colors.accent.foreground,
559            via_builder.colors.accent.foreground
560        );
561        assert_eq!(via_json.colors.scrollbar, via_builder.colors.scrollbar);
562        assert_eq!(
563            via_json.colors.scrollbar,
564            with_alpha(oklch(0.30, 0.05, 120.0), 0.15)
565        );
566        assert!((via_json.colors.accent.soft().a - 0.15).abs() < 1e-4);
567    }
568
569    #[test]
570    fn unknown_keys_are_rejected() {
571        let err =
572            ThemeDocument::from_json(r##"{ "id": "x", "base": "light", "primary": "#f00" }"##)
573                .unwrap_err();
574        let message = err.to_string();
575        assert!(
576            message.contains("primary") || message.contains("unknown"),
577            "{message}"
578        );
579    }
580
581    #[test]
582    fn accent_and_roles_accent_cannot_both_be_set() {
583        let err = ThemeDocument::theme_from_json(
584            r##"{
585                "id": "x",
586                "base": "light",
587                "accent": "#006FEE",
588                "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } }
589            }"##,
590        )
591        .unwrap_err();
592        assert!(matches!(err, ThemeDocumentError::AccentConflict));
593    }
594
595    #[test]
596    fn hex_and_percent_lightness_parse() {
597        let theme = ThemeDocument::theme_from_json(
598            r##"{
599                "id": "x",
600                "base": "dark",
601                "background": "#111",
602                "link": "oklch(55% 0.2 250 / 0.9)"
603            }"##,
604        )
605        .unwrap();
606        assert_eq!(theme.id.as_ref(), "x");
607        assert_eq!(theme.appearance, Appearance::Dark);
608        assert!((theme.colors.link.a - 0.9).abs() < 1e-4);
609    }
610
611    #[test]
612    fn customisation_tokens_apply_from_json_through_the_builder() {
613        let json = r#"{
614                "id": "x",
615                "base": "light",
616                "tabs_hover_opacity": 0.2,
617                "tooltip_cooldown_ms": 250,
618                "long_press_ms": 350,
619                "hover_fade_ms": 0,
620                "tooltip_delay_ms": 50,
621                "tooltip_close_delay_ms": 75
622            }"#;
623        let theme = ThemeDocument::theme_from_json(json).unwrap();
624        assert!((theme.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
625        assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
626        assert_eq!(theme.layout.long_press_ms, 350);
627        assert_eq!(theme.layout.hover_fade_ms, 0);
628        assert_eq!(theme.layout.tooltip_delay_ms, 50);
629        assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
630
631        // Round-trip the sparse document: serializing must not drop a token
632        // and re-parsing must apply the same values.
633        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
634        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
635        assert!((again.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
636        assert_eq!(again.layout.tooltip_cooldown_ms, 250);
637        assert_eq!(again.layout.long_press_ms, 350);
638        assert_eq!(again.layout.hover_fade_ms, 0);
639        assert_eq!(again.layout.tooltip_delay_ms, 50);
640        assert_eq!(again.layout.tooltip_close_delay_ms, 75);
641
642        let clamped = ThemeDocument::theme_from_json(
643            r#"{ "id": "x", "base": "light", "tabs_hover_opacity": 3.0 }"#,
644        )
645        .unwrap();
646        assert!((clamped.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
647    }
648
649    #[test]
650    fn the_vibrant_palette_key_round_trips_and_reaches_the_builder() {
651        let json = r#"{ "id": "x", "base": "light", "vibrant_palette": true }"#;
652        let theme = ThemeDocument::theme_from_json(json).unwrap();
653        assert!(theme.colors.vibrant_palette());
654
655        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
656        assert!(round_tripped.contains("vibrant_palette"));
657        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
658        assert!(again.colors.vibrant_palette());
659
660        // Absent means off, like a document without the attribute.
661        let plain = ThemeDocument::theme_from_json(r#"{ "id": "x", "base": "dark" }"#).unwrap();
662        assert!(!plain.colors.vibrant_palette());
663        assert!(
664            !ThemeDocument::from_json(r#"{ "id": "x", "base": "dark" }"#)
665                .unwrap()
666                .to_json()
667                .unwrap()
668                .contains("vibrant_palette")
669        );
670    }
671
672    #[test]
673    fn a_role_hover_override_round_trips_and_reaches_the_builder() {
674        let json = r##"{
675                "id": "x",
676                "base": "light",
677                "roles": { "accent": { "color": "#006FEE", "foreground": "#fff", "hover": "#0058BE" } }
678            }"##;
679        let theme = ThemeDocument::theme_from_json(json).unwrap();
680        let via_builder = Theme::builder("x", Theme::light())
681            .role(
682                "accent",
683                parse_color("c", "#006FEE").unwrap(),
684                parse_color("f", "#fff").unwrap(),
685            )
686            .role_hover("accent", parse_color("h", "#0058BE").unwrap())
687            .build();
688        assert_eq!(
689            theme.colors.accent.hover(),
690            via_builder.colors.accent.hover()
691        );
692        assert_eq!(
693            theme.colors.accent.hover_override(),
694            via_builder.colors.accent.hover_override()
695        );
696
697        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
698        assert!(round_tripped.contains("hover"));
699        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
700        assert!(again.colors.accent.hover_override().is_some());
701
702        // Absent means the derived shade, like a role without the key.
703        let plain = ThemeDocument::theme_from_json(
704            r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
705        )
706        .unwrap();
707        assert_eq!(plain.colors.accent.hover_override(), None);
708        assert!(
709            !ThemeDocument::from_json(
710                r##"{ "id": "x", "base": "light", "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } } }"##,
711            )
712            .unwrap()
713            .to_json()
714            .unwrap()
715            .contains("hover")
716        );
717    }
718
719    #[test]
720    fn a_document_round_trips_without_growing_derived_keys() {
721        let original =
722            ThemeDocument::from_json(r#"{ "id": "brand", "base": "light", "radius": 8 }"#).unwrap();
723        let json = original.to_json().unwrap();
724        assert!(!json.contains("scrollbar"));
725        assert!(!json.contains("soft"));
726        let again = ThemeDocument::from_json(&json).unwrap();
727        assert_eq!(again.id, "brand");
728        assert_eq!(again.radius, Some(8.0));
729    }
730}