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