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}
144
145/// Why a document could not become a [`Theme`].
146#[derive(Debug)]
147pub enum ThemeDocumentError {
148    Json(serde_json::Error),
149    Color {
150        field: String,
151        value: String,
152        detail: String,
153    },
154    AccentConflict,
155}
156
157impl fmt::Display for ThemeDocumentError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            Self::Json(err) => write!(f, "theme document: {err}"),
161            Self::Color {
162                field,
163                value,
164                detail,
165            } => {
166                write!(
167                    f,
168                    "theme document: {field} value {value:?} is not a colour ({detail})"
169                )
170            }
171            Self::AccentConflict => write!(
172                f,
173                "theme document: `accent` and `roles.accent` cannot both be set"
174            ),
175        }
176    }
177}
178
179impl std::error::Error for ThemeDocumentError {
180    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
181        match self {
182            Self::Json(err) => Some(err),
183            Self::Color { .. } | Self::AccentConflict => None,
184        }
185    }
186}
187
188impl From<serde_json::Error> for ThemeDocumentError {
189    fn from(err: serde_json::Error) -> Self {
190        Self::Json(err)
191    }
192}
193
194impl ThemeDocument {
195    /// Parse a JSON document.
196    pub fn from_json(json: &str) -> Result<Self, ThemeDocumentError> {
197        Ok(serde_json::from_str(json)?)
198    }
199
200    /// Parse a JSON document and apply it through [`ThemeBuilder`].
201    pub fn theme_from_json(json: &str) -> Result<Theme, ThemeDocumentError> {
202        Self::from_json(json)?.to_theme()
203    }
204
205    /// Serialize this sparse document. Derived tokens are not written.
206    pub fn to_json(&self) -> Result<String, ThemeDocumentError> {
207        Ok(serde_json::to_string_pretty(self)?)
208    }
209
210    /// Apply the overrides through [`ThemeBuilder`].
211    pub fn to_theme(&self) -> Result<Theme, ThemeDocumentError> {
212        let base = match self.base {
213            Appearance::Light => Theme::light(),
214            Appearance::Dark => Theme::dark(),
215        };
216        let mut builder = Theme::builder(self.id.clone(), base);
217        if let Some(appearance) = self.appearance {
218            builder = builder.appearance(appearance);
219        }
220        if let Some(radius) = self.radius {
221            builder = builder.radius(px(radius));
222        }
223        if let Some(radius) = self.field_radius {
224            builder = builder.field_radius(px(radius));
225        }
226        if let Some(width) = self.border_width {
227            builder = builder.border_width(px(width));
228        }
229        if let Some(opacity) = self.disabled_opacity {
230            builder = builder.disabled_opacity(opacity);
231        }
232        if let Some(cursor) = self.cursor_interactive {
233            builder = builder.cursor_interactive(cursor);
234        }
235        if let Some(opacity) = self.tabs_hover_opacity {
236            builder = builder.tabs_hover_opacity(opacity);
237        }
238        if let Some(ms) = self.tooltip_cooldown_ms {
239            builder = builder.tooltip_cooldown_ms(ms);
240        }
241        if let Some(ms) = self.long_press_ms {
242            builder = builder.long_press_ms(ms);
243        }
244        if let Some(ms) = self.hover_fade_ms {
245            builder = builder.hover_fade_ms(ms);
246        }
247        if let Some(ms) = self.tooltip_delay_ms {
248            builder = builder.tooltip_delay_ms(ms);
249        }
250        if let Some(vibrant) = self.vibrant_palette {
251            builder = builder.vibrant_palette(vibrant);
252        }
253        if let Some(ms) = self.tooltip_close_delay_ms {
254            builder = builder.tooltip_close_delay_ms(ms);
255        }
256        builder = apply_color(
257            builder,
258            "background",
259            self.background.as_deref(),
260            ThemeBuilder::background,
261        )?;
262        builder = apply_color(
263            builder,
264            "foreground",
265            self.foreground.as_deref(),
266            ThemeBuilder::foreground,
267        )?;
268        builder = apply_color(builder, "muted", self.muted.as_deref(), ThemeBuilder::muted)?;
269        builder = apply_color(
270            builder,
271            "border",
272            self.border.as_deref(),
273            ThemeBuilder::border,
274        )?;
275        builder = apply_color(
276            builder,
277            "separator",
278            self.separator.as_deref(),
279            ThemeBuilder::separator,
280        )?;
281        builder = apply_color(builder, "focus", self.focus.as_deref(), ThemeBuilder::focus)?;
282        builder = apply_color(builder, "link", self.link.as_deref(), ThemeBuilder::link)?;
283        builder = apply_color(
284            builder,
285            "backdrop",
286            self.backdrop.as_deref(),
287            ThemeBuilder::backdrop,
288        )?;
289        if let Some(pair) = &self.surface {
290            builder = builder.surface(
291                parse_color("surface.background", &pair.background)?,
292                parse_color("surface.foreground", &pair.foreground)?,
293            );
294        }
295        if let Some(levels) = &self.surface_levels {
296            builder = builder.surface_levels(
297                parse_color("surface_levels.secondary", &levels.secondary)?,
298                parse_color("surface_levels.tertiary", &levels.tertiary)?,
299            );
300        }
301        if let Some(pair) = &self.overlay {
302            builder = builder.overlay(
303                parse_color("overlay.background", &pair.background)?,
304                parse_color("overlay.foreground", &pair.foreground)?,
305            );
306        }
307        if let Some(pair) = &self.segment {
308            builder = builder.segment(
309                parse_color("segment.background", &pair.background)?,
310                parse_color("segment.foreground", &pair.foreground)?,
311            );
312        }
313        if self.accent.is_some() && self.roles.as_ref().is_some_and(|r| r.accent.is_some()) {
314            return Err(ThemeDocumentError::AccentConflict);
315        }
316        if let Some(accent) = &self.accent {
317            builder = builder.accent(parse_color("accent", accent)?);
318        }
319        if let Some(roles) = &self.roles {
320            builder = apply_role(builder, "default", roles.default.as_ref())?;
321            builder = apply_role(builder, "accent", roles.accent.as_ref())?;
322            builder = apply_role(builder, "success", roles.success.as_ref())?;
323            builder = apply_role(builder, "warning", roles.warning.as_ref())?;
324            builder = apply_role(builder, "danger", roles.danger.as_ref())?;
325        }
326        if let Some(pair) = &self.field {
327            builder = builder.field(
328                parse_color("field.background", &pair.background)?,
329                parse_color("field.foreground", &pair.foreground)?,
330            );
331        }
332        builder = apply_color(
333            builder,
334            "field_placeholder",
335            self.field_placeholder.as_deref(),
336            ThemeBuilder::field_placeholder,
337        )?;
338        builder = apply_color(
339            builder,
340            "field_border",
341            self.field_border.as_deref(),
342            ThemeBuilder::field_border,
343        )?;
344        Ok(builder.build())
345    }
346}
347
348fn apply_color(
349    builder: ThemeBuilder,
350    field: &str,
351    raw: Option<&str>,
352    apply: fn(ThemeBuilder, Hsla) -> ThemeBuilder,
353) -> Result<ThemeBuilder, ThemeDocumentError> {
354    match raw {
355        Some(value) => Ok(apply(builder, parse_color(field, value)?)),
356        None => Ok(builder),
357    }
358}
359
360fn apply_role(
361    builder: ThemeBuilder,
362    name: &str,
363    role: Option<&RoleOverride>,
364) -> Result<ThemeBuilder, ThemeDocumentError> {
365    match role {
366        Some(role) => Ok(builder.role(
367            name,
368            parse_color(&format!("roles.{name}.color"), &role.color)?,
369            parse_color(&format!("roles.{name}.foreground"), &role.foreground)?,
370        )),
371        None => Ok(builder),
372    }
373}
374
375/// Colours are CSS `oklch()` / `oklcha()` or `#RGB` / `#RRGGBB` / `#RRGGBBAA`.
376fn parse_color(field: &str, raw: &str) -> Result<Hsla, ThemeDocumentError> {
377    let value = raw.trim();
378    if let Some(hex) = value.strip_prefix('#') {
379        return parse_hex(field, value, hex);
380    }
381    if let Some(inner) = value
382        .strip_prefix("oklch(")
383        .and_then(|rest| rest.strip_suffix(')'))
384    {
385        return parse_oklch(field, value, inner);
386    }
387    if let Some(inner) = value
388        .strip_prefix("oklcha(")
389        .and_then(|rest| rest.strip_suffix(')'))
390    {
391        return parse_oklch(field, value, inner);
392    }
393    Err(ThemeDocumentError::Color {
394        field: field.to_owned(),
395        value: value.to_owned(),
396        detail: "expected oklch(...), oklcha(...) or #hex".into(),
397    })
398}
399
400fn parse_oklch(field: &str, raw: &str, inner: &str) -> Result<Hsla, ThemeDocumentError> {
401    let normalized = inner.replace('/', " ");
402    let parts: Vec<&str> = normalized.split_whitespace().collect();
403    if parts.len() < 3 || parts.len() > 4 {
404        return Err(ThemeDocumentError::Color {
405            field: field.to_owned(),
406            value: raw.to_owned(),
407            detail: "oklch takes L C H, optionally / alpha".into(),
408        });
409    }
410    let l = parse_component(field, raw, parts[0], true)?;
411    let c = parse_component(field, raw, parts[1], false)?;
412    let h = parse_component(field, raw, parts[2], false)?;
413    let a = match parts.get(3) {
414        Some(part) => parse_component(field, raw, part, false)?,
415        None => 1.0,
416    };
417    Ok(oklcha(l, c, h, a))
418}
419
420fn parse_component(
421    field: &str,
422    raw: &str,
423    part: &str,
424    lightness: bool,
425) -> Result<f32, ThemeDocumentError> {
426    let percent = part.ends_with('%');
427    let number = part.trim_end_matches('%');
428    let value: f32 = number.parse().map_err(|_| ThemeDocumentError::Color {
429        field: field.to_owned(),
430        value: raw.to_owned(),
431        detail: format!("cannot parse {part:?} as a number"),
432    })?;
433    if percent || (lightness && value > 1.0) {
434        Ok(value / 100.0)
435    } else {
436        Ok(value)
437    }
438}
439
440fn parse_hex(field: &str, raw: &str, hex: &str) -> Result<Hsla, ThemeDocumentError> {
441    let hex = hex.trim();
442    let fail = |detail: &str| ThemeDocumentError::Color {
443        field: field.to_owned(),
444        value: raw.to_owned(),
445        detail: detail.into(),
446    };
447    let nibble = |ch: u8| match ch {
448        b'0'..=b'9' => Ok(ch - b'0'),
449        b'a'..=b'f' => Ok(ch - b'a' + 10),
450        b'A'..=b'F' => Ok(ch - b'A' + 10),
451        _ => Err(fail("hex digit is not 0-9A-F")),
452    };
453    let byte =
454        |hi: u8, lo: u8| -> Result<u8, ThemeDocumentError> { Ok((nibble(hi)? << 4) | nibble(lo)?) };
455    let bytes = hex.as_bytes();
456    let (r, g, b, a) = match bytes {
457        [r, g, b] => (nibble(*r)? * 17, nibble(*g)? * 17, nibble(*b)? * 17, 255),
458        [r, g, b, a] => (
459            nibble(*r)? * 17,
460            nibble(*g)? * 17,
461            nibble(*b)? * 17,
462            nibble(*a)? * 17,
463        ),
464        [r1, r2, g1, g2, b1, b2] => (byte(*r1, *r2)?, byte(*g1, *g2)?, byte(*b1, *b2)?, 255),
465        [r1, r2, g1, g2, b1, b2, a1, a2] => (
466            byte(*r1, *r2)?,
467            byte(*g1, *g2)?,
468            byte(*b1, *b2)?,
469            byte(*a1, *a2)?,
470        ),
471        _ => return Err(fail("hex is #RGB, #RGBA, #RRGGBB or #RRGGBBAA")),
472    };
473    Ok(Hsla::from(Rgba {
474        r: r as f32 / 255.0,
475        g: g as f32 / 255.0,
476        b: b as f32 / 255.0,
477        a: a as f32 / 255.0,
478    }))
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use herogpui_core::{oklch, with_alpha};
485
486    #[test]
487    fn an_empty_document_is_the_named_base_with_a_new_id() {
488        let theme =
489            ThemeDocument::theme_from_json(r#"{ "id": "brand", "base": "light" }"#).unwrap();
490        let base = Theme::light();
491        assert_eq!(theme.id.as_ref(), "brand");
492        assert_eq!(theme.appearance, Appearance::Light);
493        assert_eq!(theme.colors.background, base.colors.background);
494        assert_eq!(theme.colors.accent.color, base.colors.accent.color);
495        assert_eq!(theme.layout.radius, base.layout.radius);
496    }
497
498    #[test]
499    fn overrides_go_through_the_builder_so_derived_mixes_stay_live() {
500        let accent = oklch(0.55, 0.23, 295.0);
501        let via_builder = Theme::builder("violet", Theme::light())
502            .accent(accent)
503            .foreground(oklch(0.30, 0.05, 120.0))
504            .build();
505        let via_json = ThemeDocument::theme_from_json(
506            r#"{
507                "id": "violet",
508                "base": "light",
509                "accent": "oklch(0.55 0.23 295)",
510                "foreground": "oklch(0.30 0.05 120)"
511            }"#,
512        )
513        .unwrap();
514        assert_eq!(
515            via_json.colors.accent.color,
516            via_builder.colors.accent.color
517        );
518        assert_eq!(
519            via_json.colors.accent.foreground,
520            via_builder.colors.accent.foreground
521        );
522        assert_eq!(via_json.colors.scrollbar, via_builder.colors.scrollbar);
523        assert_eq!(
524            via_json.colors.scrollbar,
525            with_alpha(oklch(0.30, 0.05, 120.0), 0.15)
526        );
527        assert!((via_json.colors.accent.soft().a - 0.15).abs() < 1e-4);
528    }
529
530    #[test]
531    fn unknown_keys_are_rejected() {
532        let err =
533            ThemeDocument::from_json(r##"{ "id": "x", "base": "light", "primary": "#f00" }"##)
534                .unwrap_err();
535        let message = err.to_string();
536        assert!(
537            message.contains("primary") || message.contains("unknown"),
538            "{message}"
539        );
540    }
541
542    #[test]
543    fn accent_and_roles_accent_cannot_both_be_set() {
544        let err = ThemeDocument::theme_from_json(
545            r##"{
546                "id": "x",
547                "base": "light",
548                "accent": "#006FEE",
549                "roles": { "accent": { "color": "#006FEE", "foreground": "#fff" } }
550            }"##,
551        )
552        .unwrap_err();
553        assert!(matches!(err, ThemeDocumentError::AccentConflict));
554    }
555
556    #[test]
557    fn hex_and_percent_lightness_parse() {
558        let theme = ThemeDocument::theme_from_json(
559            r##"{
560                "id": "x",
561                "base": "dark",
562                "background": "#111",
563                "link": "oklch(55% 0.2 250 / 0.9)"
564            }"##,
565        )
566        .unwrap();
567        assert_eq!(theme.id.as_ref(), "x");
568        assert_eq!(theme.appearance, Appearance::Dark);
569        assert!((theme.colors.link.a - 0.9).abs() < 1e-4);
570    }
571
572    #[test]
573    fn customisation_tokens_apply_from_json_through_the_builder() {
574        let json = r#"{
575                "id": "x",
576                "base": "light",
577                "tabs_hover_opacity": 0.2,
578                "tooltip_cooldown_ms": 250,
579                "long_press_ms": 350,
580                "hover_fade_ms": 0,
581                "tooltip_delay_ms": 50,
582                "tooltip_close_delay_ms": 75
583            }"#;
584        let theme = ThemeDocument::theme_from_json(json).unwrap();
585        assert!((theme.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
586        assert_eq!(theme.layout.tooltip_cooldown_ms, 250);
587        assert_eq!(theme.layout.long_press_ms, 350);
588        assert_eq!(theme.layout.hover_fade_ms, 0);
589        assert_eq!(theme.layout.tooltip_delay_ms, 50);
590        assert_eq!(theme.layout.tooltip_close_delay_ms, 75);
591
592        // Round-trip the sparse document: serializing must not drop a token
593        // and re-parsing must apply the same values.
594        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
595        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
596        assert!((again.layout.tabs_hover_opacity - 0.2).abs() < 1e-6);
597        assert_eq!(again.layout.tooltip_cooldown_ms, 250);
598        assert_eq!(again.layout.long_press_ms, 350);
599        assert_eq!(again.layout.hover_fade_ms, 0);
600        assert_eq!(again.layout.tooltip_delay_ms, 50);
601        assert_eq!(again.layout.tooltip_close_delay_ms, 75);
602
603        let clamped = ThemeDocument::theme_from_json(
604            r#"{ "id": "x", "base": "light", "tabs_hover_opacity": 3.0 }"#,
605        )
606        .unwrap();
607        assert!((clamped.layout.tabs_hover_opacity - 1.0).abs() < 1e-6);
608    }
609
610    #[test]
611    fn the_vibrant_palette_key_round_trips_and_reaches_the_builder() {
612        let json = r#"{ "id": "x", "base": "light", "vibrant_palette": true }"#;
613        let theme = ThemeDocument::theme_from_json(json).unwrap();
614        assert!(theme.colors.vibrant_palette());
615
616        let round_tripped = ThemeDocument::from_json(json).unwrap().to_json().unwrap();
617        assert!(round_tripped.contains("vibrant_palette"));
618        let again = ThemeDocument::theme_from_json(&round_tripped).unwrap();
619        assert!(again.colors.vibrant_palette());
620
621        // Absent means off, like a document without the attribute.
622        let plain = ThemeDocument::theme_from_json(r#"{ "id": "x", "base": "dark" }"#).unwrap();
623        assert!(!plain.colors.vibrant_palette());
624        assert!(
625            !ThemeDocument::from_json(r#"{ "id": "x", "base": "dark" }"#)
626                .unwrap()
627                .to_json()
628                .unwrap()
629                .contains("vibrant_palette")
630        );
631    }
632
633    #[test]
634    fn a_document_round_trips_without_growing_derived_keys() {
635        let original =
636            ThemeDocument::from_json(r#"{ "id": "brand", "base": "light", "radius": 8 }"#).unwrap();
637        let json = original.to_json().unwrap();
638        assert!(!json.contains("scrollbar"));
639        assert!(!json.contains("soft"));
640        let again = ThemeDocument::from_json(&json).unwrap();
641        assert_eq!(again.id, "brand");
642        assert_eq!(again.radius, Some(8.0));
643    }
644}