1use std::fmt;
12
13use gpui::{px, Hsla, Rgba};
14use herogpui_core::oklcha;
15use serde::{Deserialize, Serialize};
16
17use crate::{Appearance, Theme, ThemeBuilder};
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct ThemeDocument {
26 pub id: String,
27 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 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub cursor_interactive: Option<gpui::CursorStyle>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub tabs_hover_opacity: Option<f32>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub tooltip_cooldown_ms: Option<u64>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub long_press_ms: Option<u64>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub hover_fade_ms: Option<u64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tooltip_delay_ms: Option<u64>,
60 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub vibrant_palette: Option<bool>,
103}
104
105#[derive(Clone, Debug, Deserialize, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct ColorPair {
109 pub background: String,
110 pub foreground: String,
111}
112
113#[derive(Clone, Debug, Deserialize, Serialize)]
115#[serde(deny_unknown_fields)]
116pub struct SurfaceLevels {
117 pub secondary: String,
118 pub tertiary: String,
119}
120
121#[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#[derive(Clone, Debug, Deserialize, Serialize)]
139#[serde(deny_unknown_fields)]
140pub struct RoleOverride {
141 pub color: String,
142 pub foreground: String,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub hover: Option<String>,
148}
149
150#[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 pub fn from_json(json: &str) -> Result<Self, ThemeDocumentError> {
202 Ok(serde_json::from_str(json)?)
203 }
204
205 pub fn theme_from_json(json: &str) -> Result<Theme, ThemeDocumentError> {
207 Self::from_json(json)?.to_theme()
208 }
209
210 pub fn to_json(&self) -> Result<String, ThemeDocumentError> {
212 Ok(serde_json::to_string_pretty(self)?)
213 }
214
215 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
389fn 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 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 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_eq!(again.colors.accent.hover_override().is_some(), true);
676
677 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}