Skip to main content

gpui_kit_tokens/
lib.rs

1//! Typed design tokens with no dependency on GPUI or a windowing system.
2//!
3//! The bundled token document is embedded in the crate, so consumers never
4//! depend on a monorepo-relative path. Applications select semantic roles
5//! instead of copying colors and metrics into view code.
6
7use std::sync::OnceLock;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13mod color;
14pub mod contrast;
15
16pub use color::{Color, Palette, contrast_ratio, over};
17
18const STUDIO_DARK_JSON: &str = include_str!("../tokens/studio-dark.json");
19const STUDIO_LIGHT_JSON: &str = include_str!("../tokens/studio-light.json");
20#[cfg(test)]
21const TOKEN_SCHEMA_JSON: &str = include_str!("../tokens/schema.json");
22
23#[derive(Debug, Error)]
24pub enum TokenError {
25    #[error("token JSON is invalid: {0}")]
26    Json(#[from] serde_json::Error),
27    #[error("token `{path}` is invalid: {message}")]
28    Invalid { path: String, message: String },
29    #[error("token contrast is invalid:\n{0}")]
30    Contrast(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum Appearance {
36    Light,
37    Dark,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct TokenDocument {
43    #[serde(rename = "$schema")]
44    pub schema: String,
45    pub meta: Metadata,
46    pub color: ColorTokens,
47    pub space: SpacingTokens,
48    pub radius: RadiusTokens,
49    pub control: ControlTokens,
50    pub border: BorderTokens,
51    pub opacity: OpacityTokens,
52    pub elevation: ElevationTokens,
53    pub z_index: ZIndexTokens,
54    pub density: DensityTokens,
55    pub typography: TypographyTokens,
56    pub motion: MotionTokens,
57    pub effect: EffectTokens,
58}
59
60impl TokenDocument {
61    pub fn parse(json: &str) -> Result<Self, TokenError> {
62        let document: Self = serde_json::from_str(json)?;
63        document.validate()?;
64        Ok(document)
65    }
66
67    pub fn validate(&self) -> Result<(), TokenError> {
68        if self.schema.trim().is_empty() {
69            return invalid("$schema", "must not be empty");
70        }
71        if self.meta.id.trim().is_empty() {
72            return invalid("meta.id", "must not be empty");
73        }
74        if self.meta.name.trim().is_empty() {
75            return invalid("meta.name", "must not be empty");
76        }
77
78        for (group, steps) in &self.color.palette {
79            for (step, value) in steps {
80                Color::parse(&format!("color.palette.{group}.{step}"), value)?;
81            }
82        }
83        for (path, value) in self.color.entries() {
84            Color::resolve(path, value, &self.color.palette)?;
85        }
86        for (path, level) in self.elevation.entries() {
87            Color::resolve(&format!("{path}.color"), &level.color, &self.color.palette)?;
88            if level.blur < 0.0 {
89                return invalid(path, "blur must not be negative");
90            }
91        }
92
93        let layers = self.z_index.ordered();
94        if layers.windows(2).any(|window| window[0].1 >= window[1].1) {
95            return invalid("zIndex", "layers must be strictly increasing");
96        }
97
98        for (path, scale) in self.density.entries() {
99            for (field, value) in [
100                ("space", scale.space),
101                ("control", scale.control),
102                ("font", scale.font),
103            ] {
104                if !(0.5..=1.5).contains(&value) {
105                    return invalid(&format!("{path}.{field}"), "must be between 0.5 and 1.5");
106                }
107            }
108        }
109        if self.density.comfortable.space != 1.0
110            || self.density.comfortable.control != 1.0
111            || self.density.comfortable.font != 1.0
112        {
113            return invalid(
114                "density.comfortable",
115                "is the reference density and must scale by exactly 1",
116            );
117        }
118
119        let spacing = [
120            self.space.xs,
121            self.space.sm,
122            self.space.md,
123            self.space.lg,
124            self.space.xl,
125            self.space.xxl,
126        ];
127        if spacing.iter().any(|step| *step < 0.0) {
128            return invalid("space", "steps must not be negative");
129        }
130        if spacing.windows(2).any(|window| window[0] >= window[1]) {
131            return invalid("space", "steps must be strictly increasing");
132        }
133
134        for (path, radius) in [
135            ("radius.small", self.radius.small),
136            ("radius.control", self.radius.control),
137            ("radius.card", self.radius.card),
138            ("radius.dialog", self.radius.dialog),
139            ("radius.bubble", self.radius.bubble),
140            ("radius.pill", self.radius.pill),
141        ] {
142            if radius < 0.0 {
143                return invalid(path, "must not be negative");
144            }
145        }
146
147        for (path, step) in self.typography.scale.entries() {
148            if step.size <= 0.0 || step.line_height < step.size {
149                return invalid(path, "requires size > 0 and lineHeight >= size");
150            }
151            if !(100.0..=900.0).contains(&step.weight) {
152                return invalid(path, "weight must be between 100 and 900");
153            }
154        }
155
156        let heights = [
157            self.control.xs.height,
158            self.control.sm.height,
159            self.control.md.height,
160            self.control.lg.height,
161        ];
162        if heights.windows(2).any(|window| window[0] >= window[1]) {
163            return invalid("control", "heights must be strictly increasing");
164        }
165        for (path, step) in self.control.entries() {
166            if step.height <= 0.0 || step.font_size <= 0.0 || step.icon_size <= 0.0 {
167                return invalid(path, "height, fontSize and iconSize must be positive");
168            }
169            if step.padding_x < 0.0 || step.gap < 0.0 {
170                return invalid(path, "paddingX and gap must not be negative");
171            }
172            if step.height < step.font_size {
173                return invalid(path, "height must not be smaller than fontSize");
174            }
175        }
176
177        if self.border.hairline <= 0.0 || self.border.thick <= self.border.hairline {
178            return invalid("border", "thick must exceed a positive hairline");
179        }
180
181        if self.effect.focus_ring_width <= 0.0 {
182            return invalid("effect.focusRingWidth", "must be positive");
183        }
184
185        for (path, value) in [
186            ("effect.edgeFadeBand", self.effect.edge_fade_band),
187            ("effect.glowBlur", self.effect.glow_blur),
188            ("effect.glassBlur", self.effect.glass_blur),
189        ] {
190            if value < 0.0 {
191                return invalid(path, "must not be negative");
192            }
193        }
194
195        for (path, value) in [
196            ("effect.selectedRingAlpha", self.effect.selected_ring_alpha),
197            ("effect.focusRingAlpha", self.effect.focus_ring_alpha),
198            ("effect.glowAlpha", self.effect.glow_alpha),
199            ("effect.glassAlpha", self.effect.glass_alpha),
200            ("opacity.disabled", self.opacity.disabled),
201            ("opacity.muted", self.opacity.muted),
202            ("opacity.scrim", self.opacity.scrim),
203        ] {
204            if !(0.0..=1.0).contains(&value) {
205                return invalid(path, "must be between 0 and 1");
206            }
207        }
208
209        for preset in SpringPreset::ALL {
210            let spring = self.spring(preset);
211            if spring.stiffness <= 0.0 || spring.mass <= 0.0 || spring.damping < 0.0 {
212                return invalid(
213                    &format!("motion.spring.{}", preset.name()),
214                    "requires positive stiffness and mass and non-negative damping",
215                );
216            }
217        }
218
219        // A press or hover response that travels further than a hairline stops
220        // reading as a response to the pointer and starts reading as a layout
221        // change the user did not ask for.
222        for (path, value) in [
223            ("motion.pressOffsetPx", self.motion.press_offset_px),
224            ("motion.hoverLiftPx", self.motion.hover_lift_px),
225        ] {
226            if !(0.0..=4.0).contains(&value) {
227                return invalid(path, "must be between 0 and 4 pixels");
228            }
229        }
230
231        if self.motion.flick_velocity_px_per_sec <= 0.0 {
232            return invalid("motion.flickVelocityPxPerSec", "must be positive");
233        }
234        // A tension of one passes the whole pull through and is no band at
235        // all; a tension of zero refuses to move and reads as a stuck view.
236        if !(0.0..=1.0).contains(&self.motion.rubber_band_tension)
237            || self.motion.rubber_band_tension == 0.0
238        {
239            return invalid("motion.rubberBandTension", "must be above 0 and at most 1");
240        }
241
242        let failures = contrast::failures(self);
243        if !failures.is_empty() {
244            return Err(TokenError::Contrast(
245                failures
246                    .iter()
247                    .map(|failure| {
248                        format!(
249                            "  {} on {} is {:.2}:1; requires {:.1}:1",
250                            failure.foreground, failure.background, failure.ratio, failure.minimum
251                        )
252                    })
253                    .collect::<Vec<_>>()
254                    .join("\n"),
255            ));
256        }
257        Ok(())
258    }
259
260    pub fn surface(&self, role: Surface) -> Color {
261        let (path, value) = match role {
262            Surface::Canvas => ("color.surface.canvas", self.color.surface.canvas.as_str()),
263            Surface::Sunken => ("color.surface.sunken", self.color.surface.sunken.as_str()),
264            Surface::Panel => ("color.surface.panel", self.color.surface.panel.as_str()),
265            Surface::Raised => ("color.surface.raised", self.color.surface.raised.as_str()),
266            Surface::Overlay => ("color.surface.overlay", self.color.surface.overlay.as_str()),
267        };
268        self.resolved(path, value)
269    }
270
271    pub fn text(&self, role: TextTone) -> Color {
272        let (path, value) = match role {
273            TextTone::Primary => ("color.text.primary", self.color.text.primary.as_str()),
274            TextTone::Muted => ("color.text.muted", self.color.text.muted.as_str()),
275            TextTone::Faint => ("color.text.faint", self.color.text.faint.as_str()),
276            TextTone::OnAccent => ("color.text.onAccent", self.color.text.on_accent.as_str()),
277        };
278        self.resolved(path, value)
279    }
280
281    pub fn interactive(&self, role: InteractiveColor) -> Color {
282        let (path, value) = match role {
283            InteractiveColor::Hover => (
284                "color.interactive.hover",
285                self.color.interactive.hover.as_str(),
286            ),
287            InteractiveColor::Active => (
288                "color.interactive.active",
289                self.color.interactive.active.as_str(),
290            ),
291            InteractiveColor::Selected => (
292                "color.interactive.selected",
293                self.color.interactive.selected.as_str(),
294            ),
295            InteractiveColor::Hairline => (
296                "color.interactive.hairline",
297                self.color.interactive.hairline.as_str(),
298            ),
299            InteractiveColor::HairlineStrong => (
300                "color.interactive.hairlineStrong",
301                self.color.interactive.hairline_strong.as_str(),
302            ),
303            InteractiveColor::Focus => (
304                "color.interactive.focus",
305                self.color.interactive.focus.as_str(),
306            ),
307        };
308        self.resolved(path, value)
309    }
310
311    pub fn semantic(&self, role: SemanticColor) -> Color {
312        let (path, value) = match role {
313            SemanticColor::Accent => ("color.semantic.accent", self.color.semantic.accent.as_str()),
314            SemanticColor::AccentStrong => (
315                "color.semantic.accentStrong",
316                self.color.semantic.accent_strong.as_str(),
317            ),
318            SemanticColor::Danger => ("color.semantic.danger", self.color.semantic.danger.as_str()),
319            SemanticColor::Warning => (
320                "color.semantic.warning",
321                self.color.semantic.warning.as_str(),
322            ),
323            SemanticColor::Success => (
324                "color.semantic.success",
325                self.color.semantic.success.as_str(),
326            ),
327            SemanticColor::Info => ("color.semantic.info", self.color.semantic.info.as_str()),
328        };
329        self.resolved(path, value)
330    }
331
332    pub fn loader_gradient(&self) -> [Color; 3] {
333        [
334            self.resolved("color.loader.gradient.0", &self.color.loader.gradient[0]),
335            self.resolved("color.loader.gradient.1", &self.color.loader.gradient[1]),
336            self.resolved("color.loader.gradient.2", &self.color.loader.gradient[2]),
337        ]
338    }
339
340    fn resolved(&self, path: &str, value: &str) -> Color {
341        Color::resolve(path, value, &self.color.palette)
342            .expect("the embedded token document is validated before release")
343    }
344
345    pub fn spacing(&self, step: Space) -> f32 {
346        match step {
347            Space::Xs => self.space.xs,
348            Space::Sm => self.space.sm,
349            Space::Md => self.space.md,
350            Space::Lg => self.space.lg,
351            Space::Xl => self.space.xl,
352            Space::Xxl => self.space.xxl,
353        }
354    }
355
356    pub fn radius(&self, step: Radius) -> f32 {
357        match step {
358            Radius::Small => self.radius.small,
359            Radius::Control => self.radius.control,
360            Radius::Card => self.radius.card,
361            Radius::Dialog => self.radius.dialog,
362            Radius::Bubble => self.radius.bubble,
363            Radius::Pill => self.radius.pill,
364        }
365    }
366
367    pub fn elevation(&self, level: Elevation) -> ResolvedElevation {
368        let (path, step) = match level {
369            Elevation::Flat => ("elevation.flat", &self.elevation.flat),
370            Elevation::Raised => ("elevation.raised", &self.elevation.raised),
371            Elevation::Overlay => ("elevation.overlay", &self.elevation.overlay),
372            Elevation::Modal => ("elevation.modal", &self.elevation.modal),
373        };
374        ResolvedElevation {
375            y: step.y,
376            blur: step.blur,
377            spread: step.spread,
378            color: self.resolved(&format!("{path}.color"), &step.color),
379        }
380    }
381
382    pub fn z_index(&self, layer: Layer) -> i32 {
383        match layer {
384            Layer::Content => self.z_index.content,
385            Layer::Sticky => self.z_index.sticky,
386            Layer::Dock => self.z_index.dock,
387            Layer::Popover => self.z_index.popover,
388            Layer::Tooltip => self.z_index.tooltip,
389            Layer::Modal => self.z_index.modal,
390            Layer::Toast => self.z_index.toast,
391        }
392    }
393
394    pub fn density(&self, density: Density) -> DensityScale {
395        match density {
396            Density::Compact => self.density.compact,
397            Density::Comfortable => self.density.comfortable,
398        }
399    }
400
401    pub fn spring(&self, spring: SpringPreset) -> SpringTokens {
402        match spring {
403            SpringPreset::Snappy => self.motion.spring.snappy,
404            SpringPreset::Smooth => self.motion.spring.smooth,
405            SpringPreset::Bouncy => self.motion.spring.bouncy,
406            SpringPreset::Grab => self.motion.spring.grab,
407        }
408    }
409
410    pub fn control(&self, size: ControlSize) -> &ControlStep {
411        match size {
412            ControlSize::Xs => &self.control.xs,
413            ControlSize::Sm => &self.control.sm,
414            ControlSize::Md => &self.control.md,
415            ControlSize::Lg => &self.control.lg,
416        }
417    }
418
419    pub fn border_width(&self, weight: BorderWeight) -> f32 {
420        match weight {
421            BorderWeight::Hairline => self.border.hairline,
422            BorderWeight::Thick => self.border.thick,
423        }
424    }
425
426    pub fn opacity(&self, role: OpacityRole) -> f32 {
427        match role {
428            OpacityRole::Disabled => self.opacity.disabled,
429            OpacityRole::Muted => self.opacity.muted,
430            OpacityRole::Scrim => self.opacity.scrim,
431        }
432    }
433
434    pub fn type_step(&self, step: TypeScale) -> &TypeStep {
435        match step {
436            TypeScale::Caption => &self.typography.scale.caption,
437            TypeScale::Label => &self.typography.scale.label,
438            TypeScale::Body => &self.typography.scale.body,
439            TypeScale::Strong => &self.typography.scale.strong,
440            TypeScale::Subtitle => &self.typography.scale.subtitle,
441            TypeScale::Title => &self.typography.scale.title,
442            TypeScale::Code => &self.typography.scale.code,
443        }
444    }
445
446    pub fn motion_duration(&self, step: MotionDuration) -> Duration {
447        Duration::from_millis(match step {
448            MotionDuration::Instant => self.motion.duration_ms.instant,
449            MotionDuration::Quick => self.motion.duration_ms.quick,
450            MotionDuration::Menu => self.motion.duration_ms.menu,
451            MotionDuration::Dialog => self.motion.duration_ms.dialog,
452            MotionDuration::Resize => self.motion.duration_ms.resize,
453            MotionDuration::Entrance => self.motion.duration_ms.entrance,
454            MotionDuration::Spin => self.motion.duration_ms.spin,
455            MotionDuration::Slow => self.motion.duration_ms.slow,
456            MotionDuration::StaggerStep => self.motion.duration_ms.stagger_step,
457            MotionDuration::Pulse => self.motion.duration_ms.pulse,
458            MotionDuration::Shimmer => self.motion.duration_ms.shimmer,
459            MotionDuration::Toast => self.motion.duration_ms.toast,
460        })
461    }
462
463    /// How far a pressed control sinks, in pixels.
464    pub fn press_offset(&self) -> f32 {
465        self.motion.press_offset_px
466    }
467
468    /// How far a hovered control rises, in pixels.
469    pub fn hover_lift(&self) -> f32 {
470        self.motion.hover_lift_px
471    }
472
473    /// The speed past which a released gesture counts as a flick, in pixels a
474    /// second.
475    pub fn flick_velocity(&self) -> f32 {
476        self.motion.flick_velocity_px_per_sec
477    }
478
479    /// How much of an overscroll is shown at the boundary.
480    pub fn rubber_band_tension(&self) -> f32 {
481        self.motion.rubber_band_tension
482    }
483
484    pub fn easing(&self, step: MotionEasing) -> [f32; 4] {
485        match step {
486            MotionEasing::Linear => self.motion.easing.linear,
487            MotionEasing::Standard => self.motion.easing.standard,
488            MotionEasing::EaseIn => self.motion.easing.ease_in,
489            MotionEasing::EaseOut => self.motion.easing.ease_out,
490            MotionEasing::EaseInOut => self.motion.easing.ease_in_out,
491            MotionEasing::Emphasized => self.motion.easing.emphasized,
492            MotionEasing::Overshoot => self.motion.easing.overshoot,
493            MotionEasing::Exit => self.motion.easing.exit,
494            MotionEasing::Settle => self.motion.easing.settle,
495        }
496    }
497}
498
499fn invalid<T>(path: &str, message: &str) -> Result<T, TokenError> {
500    Err(TokenError::Invalid {
501        path: path.into(),
502        message: message.into(),
503    })
504}
505
506pub fn studio_dark() -> &'static TokenDocument {
507    static TOKENS: OnceLock<TokenDocument> = OnceLock::new();
508    TOKENS.get_or_init(|| {
509        TokenDocument::parse(STUDIO_DARK_JSON)
510            .expect("tokens/studio-dark.json must pass TokenDocument::validate")
511    })
512}
513
514pub fn studio_light() -> &'static TokenDocument {
515    static TOKENS: OnceLock<TokenDocument> = OnceLock::new();
516    TOKENS.get_or_init(|| {
517        TokenDocument::parse(STUDIO_LIGHT_JSON)
518            .expect("tokens/studio-light.json must pass TokenDocument::validate")
519    })
520}
521
522/// Every theme shipped with the library, in registration order.
523pub fn bundled() -> [&'static TokenDocument; 2] {
524    [studio_dark(), studio_light()]
525}
526
527pub fn studio_dark_json() -> &'static str {
528    STUDIO_DARK_JSON
529}
530
531pub fn studio_light_json() -> &'static str {
532    STUDIO_LIGHT_JSON
533}
534
535pub fn bundled_json() -> [&'static str; 2] {
536    [STUDIO_DARK_JSON, STUDIO_LIGHT_JSON]
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum Surface {
541    Canvas,
542    /// Recessed below whatever carries it: the well an editable value sits in.
543    Sunken,
544    Panel,
545    Raised,
546    Overlay,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum TextTone {
551    Primary,
552    Muted,
553    Faint,
554    OnAccent,
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub enum InteractiveColor {
559    Hover,
560    Active,
561    Selected,
562    Hairline,
563    HairlineStrong,
564    Focus,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub enum SemanticColor {
569    Accent,
570    AccentStrong,
571    Danger,
572    Warning,
573    Success,
574    Info,
575}
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum Space {
579    Xs,
580    Sm,
581    Md,
582    Lg,
583    Xl,
584    Xxl,
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub enum Radius {
589    Small,
590    Control,
591    Card,
592    Dialog,
593    Bubble,
594    Pill,
595}
596
597/// The four control heights every interactive component resolves against.
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, PartialOrd, Ord)]
599pub enum ControlSize {
600    Xs,
601    Sm,
602    #[default]
603    Md,
604    Lg,
605}
606
607impl ControlSize {
608    pub const ALL: [Self; 4] = [Self::Xs, Self::Sm, Self::Md, Self::Lg];
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
612pub enum Elevation {
613    #[default]
614    Flat,
615    Raised,
616    Overlay,
617    Modal,
618}
619
620/// Painting order for floating surfaces.
621#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
622pub enum Layer {
623    Content,
624    Sticky,
625    Dock,
626    Popover,
627    Tooltip,
628    Modal,
629    Toast,
630}
631
632impl Layer {
633    pub const ALL: [Self; 7] = [
634        Self::Content,
635        Self::Sticky,
636        Self::Dock,
637        Self::Popover,
638        Self::Tooltip,
639        Self::Modal,
640        Self::Toast,
641    ];
642}
643
644/// The information-density axis applications expose as a preference.
645#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
646pub enum Density {
647    Compact,
648    #[default]
649    Comfortable,
650}
651
652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
653pub enum SpringPreset {
654    Snappy,
655    Smooth,
656    Bouncy,
657    /// Direct manipulation: tight enough that the element reads as attached to
658    /// the pointer rather than trailing it.
659    Grab,
660}
661
662impl SpringPreset {
663    pub const ALL: [Self; 4] = [Self::Snappy, Self::Smooth, Self::Bouncy, Self::Grab];
664
665    pub fn name(self) -> &'static str {
666        match self {
667            Self::Snappy => "snappy",
668            Self::Smooth => "smooth",
669            Self::Bouncy => "bouncy",
670            Self::Grab => "grab",
671        }
672    }
673}
674
675#[derive(Debug, Clone, Copy, PartialEq)]
676pub struct ResolvedElevation {
677    pub y: f32,
678    pub blur: f32,
679    pub spread: f32,
680    pub color: Color,
681}
682
683#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684pub enum BorderWeight {
685    Hairline,
686    Thick,
687}
688
689#[derive(Debug, Clone, Copy, PartialEq, Eq)]
690pub enum OpacityRole {
691    Disabled,
692    Muted,
693    Scrim,
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697pub enum TypeScale {
698    Caption,
699    Label,
700    Body,
701    /// Body at emphasis weight. Same size and line height, so a run can be
702    /// emphasised without changing the line box it sits in.
703    Strong,
704    /// A heading inside a component, between body and the component's own name.
705    Subtitle,
706    Title,
707    Code,
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq)]
711pub enum MotionDuration {
712    Instant,
713    Quick,
714    Menu,
715    Dialog,
716    Resize,
717    Entrance,
718    /// One turn of a mark that reports work nobody can size.
719    Spin,
720    Slow,
721    /// The gap between one member of a staggered group and the next.
722    StaggerStep,
723    Pulse,
724    /// How long a loading placeholder's highlight takes to cross it.
725    Shimmer,
726    /// How long a transient notification stays before it leaves on its own.
727    Toast,
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub enum MotionEasing {
732    Linear,
733    Standard,
734    EaseIn,
735    EaseOut,
736    EaseInOut,
737    Emphasized,
738    Overshoot,
739    Exit,
740    Settle,
741}
742
743impl MotionEasing {
744    pub const ALL: [Self; 9] = [
745        Self::Linear,
746        Self::Standard,
747        Self::EaseIn,
748        Self::EaseOut,
749        Self::EaseInOut,
750        Self::Emphasized,
751        Self::Overshoot,
752        Self::Exit,
753        Self::Settle,
754    ];
755
756    pub fn name(self) -> &'static str {
757        match self {
758            Self::Linear => "linear",
759            Self::Standard => "standard",
760            Self::EaseIn => "easeIn",
761            Self::EaseOut => "easeOut",
762            Self::EaseInOut => "easeInOut",
763            Self::Emphasized => "emphasized",
764            Self::Overshoot => "overshoot",
765            Self::Exit => "exit",
766            Self::Settle => "settle",
767        }
768    }
769}
770
771#[derive(Debug, Clone, Deserialize, Serialize)]
772#[serde(deny_unknown_fields)]
773pub struct Metadata {
774    pub id: String,
775    pub name: String,
776    pub appearance: Appearance,
777}
778
779#[derive(Debug, Clone, Deserialize, Serialize)]
780#[serde(deny_unknown_fields)]
781pub struct ElevationTokens {
782    pub flat: ElevationStep,
783    pub raised: ElevationStep,
784    pub overlay: ElevationStep,
785    pub modal: ElevationStep,
786}
787
788impl ElevationTokens {
789    fn entries(&self) -> [(&'static str, &ElevationStep); 4] {
790        [
791            ("elevation.flat", &self.flat),
792            ("elevation.raised", &self.raised),
793            ("elevation.overlay", &self.overlay),
794            ("elevation.modal", &self.modal),
795        ]
796    }
797}
798
799#[derive(Debug, Clone, Deserialize, Serialize)]
800#[serde(deny_unknown_fields)]
801pub struct ElevationStep {
802    pub y: f32,
803    pub blur: f32,
804    pub spread: f32,
805    pub color: String,
806}
807
808#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
809#[serde(rename_all = "camelCase", deny_unknown_fields)]
810pub struct ZIndexTokens {
811    pub content: i32,
812    pub sticky: i32,
813    pub dock: i32,
814    pub popover: i32,
815    pub tooltip: i32,
816    pub modal: i32,
817    pub toast: i32,
818}
819
820impl ZIndexTokens {
821    fn ordered(&self) -> [(&'static str, i32); 7] {
822        [
823            ("content", self.content),
824            ("sticky", self.sticky),
825            ("dock", self.dock),
826            ("popover", self.popover),
827            ("tooltip", self.tooltip),
828            ("modal", self.modal),
829            ("toast", self.toast),
830        ]
831    }
832}
833
834#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
835#[serde(deny_unknown_fields)]
836pub struct DensityTokens {
837    pub compact: DensityScale,
838    pub comfortable: DensityScale,
839}
840
841impl DensityTokens {
842    fn entries(&self) -> [(&'static str, DensityScale); 2] {
843        [
844            ("density.compact", self.compact),
845            ("density.comfortable", self.comfortable),
846        ]
847    }
848}
849
850#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
851#[serde(deny_unknown_fields)]
852pub struct DensityScale {
853    pub space: f32,
854    pub control: f32,
855    pub font: f32,
856}
857
858#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
859#[serde(deny_unknown_fields)]
860pub struct SpringTokens {
861    pub stiffness: f32,
862    pub damping: f32,
863    pub mass: f32,
864}
865
866#[derive(Debug, Clone, Deserialize, Serialize)]
867#[serde(deny_unknown_fields)]
868pub struct SpringPresetTokens {
869    pub snappy: SpringTokens,
870    pub smooth: SpringTokens,
871    pub bouncy: SpringTokens,
872    pub grab: SpringTokens,
873}
874
875#[derive(Debug, Clone, Deserialize, Serialize)]
876#[serde(deny_unknown_fields)]
877pub struct ColorTokens {
878    pub palette: color::Palette,
879    pub surface: SurfaceColors,
880    pub text: TextColors,
881    pub interactive: InteractiveColors,
882    pub semantic: SemanticColors,
883    pub loader: LoaderColors,
884}
885
886impl ColorTokens {
887    fn entries(&self) -> [(&'static str, &str); 24] {
888        [
889            ("color.surface.canvas", &self.surface.canvas),
890            ("color.surface.sunken", &self.surface.sunken),
891            ("color.surface.panel", &self.surface.panel),
892            ("color.surface.raised", &self.surface.raised),
893            ("color.surface.overlay", &self.surface.overlay),
894            ("color.text.primary", &self.text.primary),
895            ("color.text.muted", &self.text.muted),
896            ("color.text.faint", &self.text.faint),
897            ("color.text.onAccent", &self.text.on_accent),
898            ("color.interactive.hover", &self.interactive.hover),
899            ("color.interactive.active", &self.interactive.active),
900            ("color.interactive.selected", &self.interactive.selected),
901            ("color.interactive.hairline", &self.interactive.hairline),
902            (
903                "color.interactive.hairlineStrong",
904                &self.interactive.hairline_strong,
905            ),
906            ("color.interactive.focus", &self.interactive.focus),
907            ("color.semantic.accent", &self.semantic.accent),
908            ("color.semantic.accentStrong", &self.semantic.accent_strong),
909            ("color.semantic.danger", &self.semantic.danger),
910            ("color.semantic.warning", &self.semantic.warning),
911            ("color.semantic.success", &self.semantic.success),
912            ("color.semantic.info", &self.semantic.info),
913            ("color.loader.gradient.0", &self.loader.gradient[0]),
914            ("color.loader.gradient.1", &self.loader.gradient[1]),
915            ("color.loader.gradient.2", &self.loader.gradient[2]),
916        ]
917    }
918}
919
920#[derive(Debug, Clone, Deserialize, Serialize)]
921#[serde(deny_unknown_fields)]
922pub struct SurfaceColors {
923    pub canvas: String,
924    /// The well an editable value sits in, recessed below the surface that
925    /// carries it. It is what a text field is made of once the field has no
926    /// border to be made of instead.
927    pub sunken: String,
928    pub panel: String,
929    pub raised: String,
930    pub overlay: String,
931}
932
933#[derive(Debug, Clone, Deserialize, Serialize)]
934#[serde(rename_all = "camelCase", deny_unknown_fields)]
935pub struct TextColors {
936    pub primary: String,
937    pub muted: String,
938    pub faint: String,
939    pub on_accent: String,
940}
941
942#[derive(Debug, Clone, Deserialize, Serialize)]
943#[serde(rename_all = "camelCase", deny_unknown_fields)]
944pub struct InteractiveColors {
945    pub hover: String,
946    pub active: String,
947    pub selected: String,
948    pub hairline: String,
949    pub hairline_strong: String,
950    pub focus: String,
951}
952
953#[derive(Debug, Clone, Deserialize, Serialize)]
954#[serde(rename_all = "camelCase", deny_unknown_fields)]
955pub struct SemanticColors {
956    pub accent: String,
957    pub accent_strong: String,
958    pub danger: String,
959    pub warning: String,
960    pub success: String,
961    pub info: String,
962}
963
964#[derive(Debug, Clone, Deserialize, Serialize)]
965#[serde(deny_unknown_fields)]
966pub struct LoaderColors {
967    pub gradient: [String; 3],
968}
969
970#[derive(Debug, Clone, Deserialize, Serialize)]
971#[serde(deny_unknown_fields)]
972pub struct SpacingTokens {
973    pub xs: f32,
974    pub sm: f32,
975    pub md: f32,
976    pub lg: f32,
977    pub xl: f32,
978    pub xxl: f32,
979}
980
981#[derive(Debug, Clone, Deserialize, Serialize)]
982#[serde(deny_unknown_fields)]
983pub struct RadiusTokens {
984    pub small: f32,
985    pub control: f32,
986    pub card: f32,
987    pub dialog: f32,
988    pub bubble: f32,
989    pub pill: f32,
990}
991
992#[derive(Debug, Clone, Deserialize, Serialize)]
993#[serde(deny_unknown_fields)]
994pub struct ControlTokens {
995    pub xs: ControlStep,
996    pub sm: ControlStep,
997    pub md: ControlStep,
998    pub lg: ControlStep,
999}
1000
1001impl ControlTokens {
1002    fn entries(&self) -> [(&'static str, &ControlStep); 4] {
1003        [
1004            ("control.xs", &self.xs),
1005            ("control.sm", &self.sm),
1006            ("control.md", &self.md),
1007            ("control.lg", &self.lg),
1008        ]
1009    }
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
1013#[serde(rename_all = "camelCase", deny_unknown_fields)]
1014pub struct ControlStep {
1015    pub height: f32,
1016    pub padding_x: f32,
1017    pub gap: f32,
1018    pub font_size: f32,
1019    pub icon_size: f32,
1020}
1021
1022#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
1023#[serde(deny_unknown_fields)]
1024pub struct BorderTokens {
1025    pub hairline: f32,
1026    pub thick: f32,
1027}
1028
1029#[derive(Debug, Clone, Copy, PartialEq, Deserialize, Serialize)]
1030#[serde(deny_unknown_fields)]
1031pub struct OpacityTokens {
1032    pub disabled: f32,
1033    pub muted: f32,
1034    pub scrim: f32,
1035}
1036
1037#[derive(Debug, Clone, Deserialize, Serialize)]
1038#[serde(deny_unknown_fields)]
1039pub struct TypographyTokens {
1040    pub sans: FontTokens,
1041    pub mono: FontTokens,
1042    pub scale: TypeScaleTokens,
1043}
1044
1045#[derive(Debug, Clone, Deserialize, Serialize)]
1046#[serde(rename_all = "camelCase", deny_unknown_fields)]
1047pub struct FontTokens {
1048    pub family: String,
1049    pub fallback_macos: String,
1050    pub fallback_windows: String,
1051    pub fallback_linux: String,
1052}
1053
1054impl FontTokens {
1055    pub fn platform_fallback(&self) -> &str {
1056        if cfg!(target_os = "macos") {
1057            &self.fallback_macos
1058        } else if cfg!(target_os = "windows") {
1059            &self.fallback_windows
1060        } else {
1061            &self.fallback_linux
1062        }
1063    }
1064}
1065
1066#[derive(Debug, Clone, Deserialize, Serialize)]
1067#[serde(deny_unknown_fields)]
1068pub struct TypeScaleTokens {
1069    pub caption: TypeStep,
1070    pub label: TypeStep,
1071    pub body: TypeStep,
1072    pub strong: TypeStep,
1073    pub subtitle: TypeStep,
1074    pub title: TypeStep,
1075    pub code: TypeStep,
1076}
1077
1078impl TypeScaleTokens {
1079    fn entries(&self) -> [(&'static str, &TypeStep); 7] {
1080        [
1081            ("typography.scale.caption", &self.caption),
1082            ("typography.scale.label", &self.label),
1083            ("typography.scale.body", &self.body),
1084            ("typography.scale.strong", &self.strong),
1085            ("typography.scale.subtitle", &self.subtitle),
1086            ("typography.scale.title", &self.title),
1087            ("typography.scale.code", &self.code),
1088        ]
1089    }
1090}
1091
1092#[derive(Debug, Clone, Deserialize, Serialize)]
1093#[serde(rename_all = "camelCase", deny_unknown_fields)]
1094pub struct TypeStep {
1095    pub size: f32,
1096    pub line_height: f32,
1097    pub weight: f32,
1098}
1099
1100#[derive(Debug, Clone, Deserialize, Serialize)]
1101#[serde(rename_all = "camelCase", deny_unknown_fields)]
1102pub struct MotionTokens {
1103    pub duration_ms: DurationTokens,
1104    pub easing: EasingTokens,
1105    pub spring: SpringPresetTokens,
1106    /// How far a control sinks while the pointer is held on it.
1107    pub press_offset_px: f32,
1108    /// How far a control rises under the pointer.
1109    pub hover_lift_px: f32,
1110    /// The speed, in pixels a second, past which a released gesture is a
1111    /// flick rather than a drag that happened to end.
1112    pub flick_velocity_px_per_sec: f32,
1113    /// The fraction of an overscroll that is shown at the boundary, before
1114    /// the band starts tightening.
1115    pub rubber_band_tension: f32,
1116}
1117
1118#[derive(Debug, Clone, Deserialize, Serialize)]
1119#[serde(rename_all = "camelCase", deny_unknown_fields)]
1120pub struct DurationTokens {
1121    pub instant: u64,
1122    pub quick: u64,
1123    pub menu: u64,
1124    pub dialog: u64,
1125    pub resize: u64,
1126    pub entrance: u64,
1127    /// How long one full turn of a spinning mark takes.
1128    pub spin: u64,
1129    /// The longest step a user waits through deliberately: a panel taking a
1130    /// region over, or a graph laying itself out.
1131    pub slow: u64,
1132    /// The gap between one member of a staggered group and the next.
1133    pub stagger_step: u64,
1134    pub pulse: u64,
1135    /// How long a loading placeholder's highlight takes to cross it.
1136    pub shimmer: u64,
1137    pub toast: u64,
1138}
1139
1140#[derive(Debug, Clone, Deserialize, Serialize)]
1141#[serde(rename_all = "camelCase", deny_unknown_fields)]
1142pub struct EasingTokens {
1143    pub linear: [f32; 4],
1144    pub standard: [f32; 4],
1145    pub ease_in: [f32; 4],
1146    pub ease_out: [f32; 4],
1147    pub ease_in_out: [f32; 4],
1148    pub emphasized: [f32; 4],
1149    pub overshoot: [f32; 4],
1150    pub exit: [f32; 4],
1151    pub settle: [f32; 4],
1152}
1153
1154#[derive(Debug, Clone, Deserialize, Serialize)]
1155#[serde(rename_all = "camelCase", deny_unknown_fields)]
1156pub struct EffectTokens {
1157    pub edge_fade_band: f32,
1158    pub selected_ring_alpha: f32,
1159    /// How wide the ring around the focused control is drawn, in pixels.
1160    pub focus_ring_width: f32,
1161    pub focus_ring_alpha: f32,
1162    /// How strongly a surface that carries a state colour bleeds it into the
1163    /// pixels around its edge.
1164    pub glow_alpha: f32,
1165    /// How far that bleed reaches, in pixels.
1166    pub glow_blur: f32,
1167    /// How opaque a frosted surface's own fill is over what it blurs. A theme
1168    /// that sets this to 1 declares itself opaque, and a frosted surface then
1169    /// paints no blur at all rather than blurring pixels nobody can see.
1170    pub glass_alpha: f32,
1171    /// How far a frosted surface blurs what is behind it, in pixels.
1172    pub glass_blur: f32,
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178
1179    #[test]
1180    fn bundled_document_is_valid_and_typed() {
1181        let tokens = studio_dark();
1182        assert_eq!(tokens.meta.id, "studio-dark");
1183        assert_eq!(tokens.meta.appearance, Appearance::Dark);
1184        assert_eq!(tokens.spacing(Space::Lg), 16.0);
1185        assert_eq!(tokens.radius(Radius::Dialog), 16.0);
1186        assert_eq!(
1187            tokens.motion_duration(MotionDuration::Menu),
1188            Duration::from_millis(140)
1189        );
1190    }
1191
1192    #[test]
1193    fn palette_references_preserve_the_literal_values_they_replaced() {
1194        let tokens = studio_dark();
1195        assert_eq!(
1196            tokens.surface(Surface::Canvas),
1197            Color::parse("literal", "#0a0a0a").expect("literal")
1198        );
1199        assert_eq!(
1200            tokens.interactive(InteractiveColor::Hover),
1201            Color::parse("literal", "#ebebeb24").expect("literal")
1202        );
1203        assert_eq!(
1204            tokens.semantic(SemanticColor::Accent),
1205            Color::parse("literal", "#7c86ff").expect("literal")
1206        );
1207    }
1208
1209    #[test]
1210    fn every_bundled_theme_parses_and_declares_its_appearance() {
1211        let ids: Vec<&str> = bundled().iter().map(|doc| doc.meta.id.as_str()).collect();
1212        assert_eq!(ids, vec!["studio-dark", "studio-light"]);
1213        assert_eq!(studio_light().meta.appearance, Appearance::Light);
1214    }
1215
1216    #[test]
1217    fn portable_schema_accepts_every_bundled_theme() {
1218        let schema: serde_json::Value =
1219            serde_json::from_str(TOKEN_SCHEMA_JSON).expect("valid JSON schema");
1220        let validator = jsonschema::validator_for(&schema).expect("valid token schema");
1221        for json in bundled_json() {
1222            let document: serde_json::Value = serde_json::from_str(json).expect("theme JSON");
1223            if let Err(error) = validator.validate(&document) {
1224                panic!("{} does not match schema: {error}", document["meta"]["id"]);
1225            }
1226        }
1227    }
1228
1229    #[test]
1230    fn themes_agree_on_every_metric_that_is_not_a_color() {
1231        let dark = studio_dark();
1232        let light = studio_light();
1233        for size in ControlSize::ALL {
1234            assert_eq!(dark.control(size).height, light.control(size).height);
1235            assert_eq!(dark.control(size).font_size, light.control(size).font_size);
1236        }
1237        for step in [Space::Xs, Space::Md, Space::Xxl] {
1238            assert_eq!(dark.spacing(step), light.spacing(step));
1239        }
1240        for layer in Layer::ALL {
1241            assert_eq!(dark.z_index(layer), light.z_index(layer));
1242        }
1243        for easing in MotionEasing::ALL {
1244            assert_eq!(dark.easing(easing), light.easing(easing));
1245        }
1246    }
1247
1248    #[test]
1249    fn layers_paint_in_a_fixed_order() {
1250        let tokens = studio_dark();
1251        assert!(tokens.z_index(Layer::Popover) < tokens.z_index(Layer::Modal));
1252        assert!(tokens.z_index(Layer::Modal) < tokens.z_index(Layer::Toast));
1253    }
1254
1255    #[test]
1256    fn compact_density_shrinks_and_comfortable_is_the_reference() {
1257        let tokens = studio_dark();
1258        let compact = tokens.density(Density::Compact);
1259        let comfortable = tokens.density(Density::Comfortable);
1260        assert_eq!(comfortable.space, 1.0);
1261        assert!(compact.space < comfortable.space);
1262        assert!(compact.font < comfortable.font);
1263    }
1264
1265    #[test]
1266    fn elevation_grows_with_the_layer_it_serves() {
1267        let tokens = studio_dark();
1268        assert_eq!(tokens.elevation(Elevation::Flat).blur, 0.0);
1269        assert_eq!(tokens.elevation(Elevation::Flat).color.alpha, 0.0);
1270        assert!(tokens.elevation(Elevation::Modal).blur > tokens.elevation(Elevation::Raised).blur);
1271    }
1272
1273    #[test]
1274    fn control_steps_are_ordered_and_complete() {
1275        let tokens = studio_dark();
1276        let heights: Vec<f32> = ControlSize::ALL
1277            .iter()
1278            .map(|size| tokens.control(*size).height)
1279            .collect();
1280        assert!(heights.windows(2).all(|window| window[0] < window[1]));
1281        assert_eq!(tokens.control(ControlSize::Md).padding_x, 12.0);
1282        assert_eq!(tokens.border_width(BorderWeight::Hairline), 1.0);
1283        assert!(tokens.opacity(OpacityRole::Disabled) < 1.0);
1284    }
1285
1286    #[test]
1287    fn out_of_order_control_heights_fail_validation() {
1288        let mut value: serde_json::Value =
1289            serde_json::from_str(studio_dark_json()).expect("bundled JSON");
1290        value["control"]["lg"]["height"] = serde_json::json!(10);
1291        let error = TokenDocument::parse(&value.to_string()).expect_err("unordered heights");
1292        assert!(error.to_string().contains("control"));
1293    }
1294
1295    #[test]
1296    fn colors_accept_rgb_and_rgba_hex() {
1297        assert_eq!(Color::parse("opaque", "#ffffff").expect("color").alpha, 1.0);
1298        let translucent = Color::parse("wash", "#ffffff14").expect("color");
1299        assert!((translucent.alpha - 20.0 / 255.0).abs() < f32::EPSILON);
1300    }
1301
1302    #[test]
1303    fn invalid_external_documents_fail_loudly() {
1304        let mut value: serde_json::Value =
1305            serde_json::from_str(studio_dark_json()).expect("bundled JSON");
1306        value["color"]["surface"]["canvas"] = serde_json::json!("black");
1307        let error = TokenDocument::parse(&value.to_string()).expect_err("invalid color");
1308        assert!(error.to_string().contains("color.surface.canvas"));
1309    }
1310
1311    #[test]
1312    fn external_documents_report_every_contrast_failure() {
1313        let mut value: serde_json::Value =
1314            serde_json::from_str(studio_dark_json()).expect("bundled JSON");
1315        value["color"]["text"]["primary"] = value["color"]["surface"]["canvas"].clone();
1316        let error = TokenDocument::parse(&value.to_string()).expect_err("invisible primary text");
1317        let message = error.to_string();
1318        assert!(message.contains("token contrast is invalid"));
1319        assert!(message.contains("text.primary on surface.canvas is 1.00:1; requires 4.5:1"));
1320        assert!(message.contains("text.primary on surface.overlay"));
1321    }
1322
1323    #[test]
1324    fn unknown_and_legacy_fields_are_rejected() {
1325        let mut value: serde_json::Value =
1326            serde_json::from_str(studio_dark_json()).expect("bundled JSON");
1327        value["version"] = serde_json::json!(1);
1328        let error = TokenDocument::parse(&value.to_string()).expect_err("unknown root field");
1329        assert!(error.to_string().contains("unknown field `version`"));
1330
1331        let mut value: serde_json::Value =
1332            serde_json::from_str(studio_dark_json()).expect("bundled JSON");
1333        value["color"]["surface"]["legacyCanvas"] = serde_json::json!("#000000");
1334        let error = TokenDocument::parse(&value.to_string()).expect_err("unknown nested field");
1335        assert!(error.to_string().contains("unknown field `legacyCanvas`"));
1336    }
1337
1338    #[test]
1339    fn semantic_colors_are_not_layout_surfaces() {
1340        let tokens = studio_dark();
1341        assert_ne!(
1342            tokens.semantic(SemanticColor::Accent),
1343            tokens.surface(Surface::Canvas)
1344        );
1345        assert_ne!(
1346            tokens.semantic(SemanticColor::Danger),
1347            tokens.surface(Surface::Raised)
1348        );
1349    }
1350}