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}