Skip to main content

fenestra_core/
tokens.rs

1//! Mode-independent design tokens: spacing, radii, typography, and motion.
2//! These numbers are the spec; kit widgets and examples never hardcode them.
3
4/// CSS-style cubic-bezier easing, shared with `fenestra-motion`'s offline
5/// sampler and any other frame/tick-based consumer — see
6/// [`fenestra_anim::CubicBezier`] for the accuracy bound and the solver.
7pub use fenestra_anim::CubicBezier;
8
9/// Spacing constants on a 4px grid, in logical pixels.
10pub const SP0: f32 = 0.0;
11/// 2px.
12pub const SP0_5: f32 = 2.0;
13/// 4px.
14pub const SP1: f32 = 4.0;
15/// 8px.
16pub const SP2: f32 = 8.0;
17/// 12px.
18pub const SP3: f32 = 12.0;
19/// 16px.
20pub const SP4: f32 = 16.0;
21/// 20px.
22pub const SP5: f32 = 20.0;
23/// 24px.
24pub const SP6: f32 = 24.0;
25/// 32px.
26pub const SP8: f32 = 32.0;
27/// 40px.
28pub const SP10: f32 = 40.0;
29/// 48px.
30pub const SP12: f32 = 48.0;
31/// 64px.
32pub const SP16: f32 = 64.0;
33
34/// The default reading measure, in CSS `ch` units (1ch = the advance of the
35/// digit `'0'`; see [`crate::Length::Ch`]). Set to 52 so a proportional body
36/// face renders ~66 characters per line — the classic optimum for sustained
37/// reading (the comfortable band is 45–75). The value is below 66 on purpose:
38/// `'0'` is wider than the average glyph, so a column of N `ch` holds somewhat
39/// more than N real characters; 52ch lands the rendered line near 66. A wider
40/// face fits fewer characters per line, a narrower one more. Prose containers
41/// cap their width here via [`crate::Style::measure`].
42pub const MEASURE_CH: f32 = 52.0;
43
44/// Small radius (badges, small chips): 6px.
45pub const R_SM: f32 = 6.0;
46/// Medium radius (controls: buttons, inputs): 10px.
47pub const R_MD: f32 = 10.0;
48/// Large radius (cards): 14px.
49pub const R_LG: f32 = 14.0;
50/// Extra-large radius (modals): 20px.
51pub const R_XL: f32 = 20.0;
52/// Fully-rounded (pills, avatars); clamped to half the box size at paint.
53pub const R_FULL: f32 = f32::INFINITY;
54
55/// A corner-radius family derived from one base measure, so a theme can be
56/// tighter or rounder from a single knob. The ratios (0.6 / 1.0 / 1.4 / 2.0 ×
57/// base) are fenestra's: a base of `10` reproduces [`R_SM`] / [`R_MD`] /
58/// [`R_LG`] / [`R_XL`] exactly, which is why the kit's constants and the
59/// default scale agree.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct RadiusScale {
62    /// Small radius (badges, chips).
63    pub sm: f32,
64    /// Medium radius (controls).
65    pub md: f32,
66    /// Large radius (cards).
67    pub lg: f32,
68    /// Extra-large radius (modals).
69    pub xl: f32,
70}
71
72impl RadiusScale {
73    /// The family derived from a base radius: `0.6 / 1.0 / 1.4 / 2.0 × base`.
74    #[must_use]
75    pub fn from_base(base: f32) -> Self {
76        let b = base.max(0.0);
77        Self {
78            sm: b * 0.6,
79            md: b,
80            lg: b * 1.4,
81            xl: b * 2.0,
82        }
83    }
84
85    /// A tight family for sharp / minimal "tech" chrome — corners read crisp and
86    /// near-square: `sm 1 / md 2 / lg 3 / xl 4`. Set it on a theme with
87    /// [`Theme::with_radius`](crate::Theme::with_radius) to un-round the whole
88    /// kit at once; pills and avatars ([`R_FULL`]) stay round regardless. For a
89    /// fully-square look, use `RadiusScale::from_base(0.0)`.
90    #[must_use]
91    pub fn sharp() -> Self {
92        Self {
93            sm: 1.0,
94            md: 2.0,
95            lg: 3.0,
96            xl: 4.0,
97        }
98    }
99
100    /// A rounder, friendlier family (base `16`): `sm 9.6 / md 16 / lg 22.4 / xl 32`
101    /// — for soft, consumer/whiteboard-class tools.
102    #[must_use]
103    pub fn soft() -> Self {
104        Self::from_base(16.0)
105    }
106}
107
108impl Default for RadiusScale {
109    /// The stock family (base `R_MD` = 10): `R_SM` / `R_MD` / `R_LG` / `R_XL`.
110    fn default() -> Self {
111        Self::from_base(R_MD)
112    }
113}
114
115/// How resting, same-plane surfaces (cards) convey separation. Floating
116/// surfaces — menus, popovers, modals, tooltips — always cast a shadow; this
117/// only governs cards that rest in the page. Set on a theme with
118/// [`Theme::with_elevation`](crate::Theme::with_elevation).
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum Elevation {
121    /// Resting cards cast a subtle tinted shadow — the stock look.
122    #[default]
123    Shadowed,
124    /// Resting cards lean on a border + surface tone-step instead of a shadow
125    /// — sharper, and the honest choice in dark mode where shadows barely
126    /// register. Shadows stay reserved for surfaces that truly float. Pairs
127    /// naturally with [`RadiusScale::sharp`].
128    Flat,
129}
130
131/// The typographic scale. Sizes are logical px.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
133pub enum TextSize {
134    /// 12px.
135    Xs,
136    /// 14px.
137    Sm,
138    /// 16px (body default).
139    #[default]
140    Base,
141    /// 20px.
142    Lg,
143    /// 25px.
144    Xl,
145    /// 31px.
146    Xl2,
147    /// 39px.
148    Xl3,
149}
150
151impl TextSize {
152    /// Font size in logical pixels.
153    pub const fn px(self) -> f32 {
154        match self {
155            Self::Xs => 12.0,
156            Self::Sm => 14.0,
157            Self::Base => 16.0,
158            Self::Lg => 20.0,
159            Self::Xl => 25.0,
160            Self::Xl2 => 31.0,
161            Self::Xl3 => 39.0,
162        }
163    }
164
165    /// Line height as a multiple of the font size.
166    pub const fn line_height(self) -> f32 {
167        match self {
168            Self::Xs | Self::Sm | Self::Base => 1.5,
169            Self::Lg => 1.4,
170            Self::Xl | Self::Xl2 => 1.25,
171            Self::Xl3 => 1.15,
172        }
173    }
174
175    /// Letter spacing in em, from Inter's dynamic-metrics tracking curve
176    /// ([`tracking_em`]). Multiply by `px()` for logical pixels.
177    pub fn letter_spacing(self) -> f32 {
178        tracking_em(self.px())
179    }
180}
181
182/// Optical tracking (letter spacing) in em for a font size in logical px,
183/// from Inter's published dynamic-metrics formula
184/// `-0.0223 + 0.185·e^(-0.1745·px)`: a hair positive at caption sizes,
185/// tightening smoothly as text grows. Applies to any size, including
186/// free-form display sizes, instead of a handful of hand-set steps.
187#[must_use]
188pub fn tracking_em(px: f32) -> f32 {
189    -0.0223 + 0.185 * (-0.1745 * px).exp()
190}
191
192/// Font weights shipped with the embedded Inter family.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
194pub enum Weight {
195    /// 400: body text.
196    #[default]
197    Regular,
198    /// 500: labels and buttons.
199    Medium,
200    /// 600: headings.
201    Semibold,
202}
203
204impl Weight {
205    /// Numeric OpenType weight.
206    pub const fn value(self) -> f32 {
207        match self {
208            Self::Regular => 400.0,
209            Self::Medium => 500.0,
210            Self::Semibold => 600.0,
211        }
212    }
213}
214
215/// Font family roles resolved through fontique.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
217pub enum FamilyRole {
218    /// Inter, with system fallback.
219    #[default]
220    Sans,
221    /// SF Mono / Cascadia Code / JetBrains Mono / monospace fallback list.
222    Mono,
223    /// A display face registered via `Fonts::register` (falls back to Sans
224    /// until one is registered). Editorial headlines. High-contrast "display"
225    /// cuts — Playfair and other Didones — are drawn for large sizes; their
226    /// thin strokes shimmer or drop out below ~24px, so keep this role to
227    /// headings and decks and set body text in [`Sans`](Self::Sans) or a
228    /// *text* [`Serif`](Self::Serif).
229    Display,
230    /// A serif face registered via `Fonts::register` (falls back to Sans). For
231    /// reading prose register a *text*-optical serif here and keep runs at
232    /// ≥20px with generous leading; a Didone *display* face (e.g. Playfair)
233    /// works as a large pull-quote but bands below ~18–20px, so it belongs
234    /// under [`Display`](Self::Display), not as small body text.
235    Serif,
236}
237
238/// Shadow elevation tokens. Resolved to concrete layered shadows by the
239/// theme (dark mode multiplies alphas by 1.6). Ordered by depth
240/// (`Xs < Sm < Md < Lg < Xl`, in declaration order) so elevation roles can be
241/// compared — e.g. a floating surface's shadow must out-rank a resting one's.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
243pub enum ShadowToken {
244    /// Single hairline shadow.
245    Xs,
246    /// Card shadow; pairs with a 1px `border_subtle` (the signature look).
247    Sm,
248    /// Raised controls and popovers.
249    Md,
250    /// Menus and dropdowns.
251    Lg,
252    /// Deep overlays: modals and dialogs (a three-layer ramp).
253    Xl,
254}
255
256/// Motion duration tokens, in milliseconds. Interaction feedback lives in the
257/// 100–200ms band; larger surfaces take longer. Exits are quicker than the
258/// matching entrance (see [`MotionDuration::exit_ms`]).
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
260pub enum MotionDuration {
261    /// 100ms: the smallest interaction feedback (press, state-layer fade).
262    Micro,
263    /// 120ms: hover color and background changes.
264    Fast,
265    /// 200ms: most state changes, overlay enter.
266    #[default]
267    Base,
268    /// 300ms: large surfaces.
269    Slow,
270}
271
272impl MotionDuration {
273    /// Duration in milliseconds.
274    pub const fn ms(self) -> f32 {
275        match self {
276            Self::Micro => 100.0,
277            Self::Fast => 120.0,
278            Self::Base => 200.0,
279            Self::Slow => 300.0,
280        }
281    }
282
283    /// Exit duration: an element leaving should clear ~25% quicker than it
284    /// arrived (Material 3), so dismissals feel crisp rather than draggy.
285    pub const fn exit_ms(self) -> f32 {
286        self.ms() * 0.75
287    }
288}
289
290/// The Material 3 easing families. Use [`EASE_STANDARD`] for two-way state
291/// changes (hover, press, color), [`EASE_DECELERATE`] for entrances (an
292/// element flying in and settling), and [`EASE_ACCELERATE`] for exits (an
293/// element leaving the screen). Entrances ease *out* (fast then gentle); exits
294/// ease *in* (gentle then fast) so they clear quickly.
295///
296/// Standard easing for entrances and two-way state changes: (0.2, 0, 0, 1).
297pub const EASE_STANDARD: CubicBezier = CubicBezier {
298    x1: 0.2,
299    y1: 0.0,
300    x2: 0.0,
301    y2: 1.0,
302};
303
304/// Decelerate easing for entrances (Material 3): (0, 0, 0.2, 1) — quick to
305/// arrive, easing to rest. Pair with an entrance transition.
306pub const EASE_DECELERATE: CubicBezier = CubicBezier {
307    x1: 0.0,
308    y1: 0.0,
309    x2: 0.2,
310    y2: 1.0,
311};
312
313/// Accelerate easing for exits (Material 3): (0.4, 0, 1, 1) — easing away,
314/// then leaving briskly. Pair with [`MotionDuration::exit_ms`].
315pub const EASE_ACCELERATE: CubicBezier = CubicBezier {
316    x1: 0.4,
317    y1: 0.0,
318    x2: 1.0,
319    y2: 1.0,
320};
321
322/// Exit easing; the historical name for [`EASE_ACCELERATE`].
323pub const EASE_EXIT: CubicBezier = EASE_ACCELERATE;
324
325/// Symmetric ease for camera moves and large surface transitions (CSS
326/// `easeInOutCubic`): (0.65, 0, 0.35, 1). The canvas camera
327/// ([`crate::canvas`]) eases zoom-to-fit and zoom-to-selection with it — the
328/// curve Figma and tldraw use for canvas motion.
329pub const EASE_IN_OUT_CUBIC: CubicBezier = CubicBezier {
330    x1: 0.65,
331    y1: 0.0,
332    x2: 0.35,
333    y2: 1.0,
334};
335
336/// The focus-ring spec (shadcn v4 model). On keyboard focus a control swaps
337/// its border to the ring color and draws a soft halo `width` px wide at
338/// `alpha`, `offset` px outside the border (0 = flush). The ring color is the
339/// accent by default and the danger hue when the control is marked invalid.
340/// Painted only when focus arrived via keyboard ([`crate`]'s `focus_visible`).
341#[derive(Debug, Clone, Copy)]
342pub struct FocusRing {
343    /// Halo stroke width in logical px.
344    pub width: f32,
345    /// Gap between the element edge and the halo (0 = flush).
346    pub offset: f32,
347    /// Halo alpha applied to the ring color.
348    pub alpha: f32,
349}
350
351/// The focus ring token: a 3px halo at 0.5 alpha, flush outside the border.
352pub const FOCUS_RING: FocusRing = FocusRing {
353    width: 3.0,
354    offset: 0.0,
355    alpha: 0.5,
356};
357
358/// Pressed controls scale to this factor for tactile press feedback (Material
359/// 3 uses 0.96–0.97). Applied as a paint-time transform about the control's
360/// center, so it never disturbs layout or hit-testing, and it animates through
361/// the same transition as the press color.
362pub const PRESS_SCALE: f32 = 0.97;
363
364/// The fixed light azimuth for the glass specular *rim*, in CSS gradient degrees
365/// (`0` = up, `90` = right). fenestra puts the light at the top so a floating
366/// glass pane catches light along its upper edge and shades toward the bottom.
367/// Used by [`SpecularEdge::glass`](crate::SpecularEdge::glass); the body
368/// [`Sheen`](crate::Sheen) rakes from the upper-left on its own diagonal axis.
369pub const GLASS_LIGHT_DEG: f32 = 0.0;
370
371/// Sub-segments generated per anchor pair when expanding an OKLCH gradient
372/// ([`linear_gradient`](crate::linear_gradient) /
373/// [`radial_gradient`](crate::radial_gradient)). Calibrated so a full hue-arc
374/// ramp shows no perceptible banding once vello resamples the stops into its
375/// ~512-texel sRGB ramp LUT (≈32 texels per sub-segment at 16). A perceptual
376/// target, not a hard spec number: raise it if a wide-hue ramp ever bands at a
377/// sub-segment joint.
378pub const GRADIENT_STEPS: usize = 16;
379
380/// The Material state-layer recipe: a translucent veil of a control's *content*
381/// color, laid over its container to signal interaction — one set of opacities
382/// shared across the whole kit instead of per-widget hover colors. Hover is the
383/// lightest; focus and press share a stronger value; an in-progress drag is
384/// strongest. Disabled is expressed separately as a faint container plus dimmed
385/// content rather than an overlay.
386#[derive(Debug, Clone, Copy)]
387pub struct StateLayer {
388    /// Hovered (pointer over an idle control).
389    pub hover: f32,
390    /// Focused via keyboard.
391    pub focus: f32,
392    /// Pressed (pointer down).
393    pub press: f32,
394    /// Being dragged.
395    pub drag: f32,
396    /// Disabled container: the share of the content color blended into the
397    /// resting surface so the control reads as inert.
398    pub disabled_container: f32,
399    /// Disabled content: the opacity a disabled label/icon is dimmed to.
400    pub disabled_content: f32,
401}
402
403/// The state-layer token.
404pub const STATE_LAYER: StateLayer = StateLayer {
405    hover: 0.08,
406    focus: 0.12,
407    press: 0.12,
408    drag: 0.16,
409    disabled_container: 0.12,
410    disabled_content: 0.38,
411};
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn tracking_curve_tightens_with_size_toward_its_asymptote() {
419        // Inter's curve: a hair positive at caption sizes, monotonically
420        // tightening toward the -0.0223em asymptote as size grows.
421        assert!(tracking_em(12.0) > tracking_em(16.0));
422        assert!(tracking_em(16.0) > tracking_em(31.0));
423        assert!((tracking_em(12.0) - 0.0005).abs() < 0.002);
424        assert!((tracking_em(16.0) - -0.0110).abs() < 0.002);
425        // Large sizes approach but never cross the asymptote.
426        assert!(tracking_em(96.0) > -0.0223);
427        assert!((tracking_em(96.0) - -0.0223).abs() < 0.0005);
428    }
429
430    #[test]
431    fn text_size_tracking_matches_the_formula() {
432        for size in [TextSize::Xs, TextSize::Base, TextSize::Xl3] {
433            assert_eq!(size.letter_spacing(), tracking_em(size.px()));
434        }
435    }
436
437    #[test]
438    fn easing_families_have_the_right_curvature() {
439        // Decelerate (enter) eases out: it is ahead of the diagonal early.
440        assert!(EASE_DECELERATE.eval(0.25) > 0.25);
441        // Accelerate (exit) eases in: it lags the diagonal early.
442        assert!(EASE_ACCELERATE.eval(0.25) < 0.25);
443        // Standard is a gentle ease that also leads at the start.
444        assert!(EASE_STANDARD.eval(0.25) > 0.25);
445        // Endpoints are pinned for every curve.
446        for e in [EASE_STANDARD, EASE_DECELERATE, EASE_ACCELERATE] {
447            assert_eq!(e.eval(0.0), 0.0);
448            assert_eq!(e.eval(1.0), 1.0);
449        }
450    }
451
452    #[test]
453    fn exit_is_a_quarter_quicker_than_entrance() {
454        for d in [
455            MotionDuration::Micro,
456            MotionDuration::Fast,
457            MotionDuration::Base,
458            MotionDuration::Slow,
459        ] {
460            assert!((d.exit_ms() - d.ms() * 0.75).abs() < 1e-3);
461        }
462        assert_eq!(MotionDuration::Micro.ms(), 100.0);
463    }
464
465    #[test]
466    fn press_scale_is_a_subtle_shrink() {
467        assert!((0.96..=0.97).contains(&PRESS_SCALE));
468    }
469
470    #[test]
471    fn radius_scale_default_matches_the_constants() {
472        // The default family (base R_MD) must reproduce the kit's constants, so
473        // a theme on the default scale looks identical to one using R_SM..R_XL.
474        let r = RadiusScale::default();
475        assert_eq!(r.sm, R_SM);
476        assert_eq!(r.md, R_MD);
477        assert_eq!(r.lg, R_LG);
478        assert_eq!(r.xl, R_XL);
479        // A tighter base scales the whole family down proportionally.
480        let tight = RadiusScale::from_base(5.0);
481        assert_eq!(tight.md, 5.0);
482        assert!(tight.sm < tight.md && tight.md < tight.lg && tight.lg < tight.xl);
483    }
484}