1use 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 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 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 pub fn press_offset(&self) -> f32 {
465 self.motion.press_offset_px
466 }
467
468 pub fn hover_lift(&self) -> f32 {
470 self.motion.hover_lift_px
471 }
472
473 pub fn flick_velocity(&self) -> f32 {
476 self.motion.flick_velocity_px_per_sec
477 }
478
479 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
522pub 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 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#[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#[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#[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 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 Strong,
704 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 Spin,
720 Slow,
721 StaggerStep,
723 Pulse,
724 Shimmer,
726 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 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 pub press_offset_px: f32,
1108 pub hover_lift_px: f32,
1110 pub flick_velocity_px_per_sec: f32,
1113 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 pub spin: u64,
1129 pub slow: u64,
1132 pub stagger_step: u64,
1134 pub pulse: u64,
1135 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 pub focus_ring_width: f32,
1161 pub focus_ring_alpha: f32,
1162 pub glow_alpha: f32,
1165 pub glow_blur: f32,
1167 pub glass_alpha: f32,
1171 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}