feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
/// Semantic color tokens and the Color type.
|
||||
///
|
||||
/// Prefer semantic tokens (Primary, Surface, etc.) over explicit values.
|
||||
/// The theme maps tokens to actual colors — swap the theme, everything updates.
|
||||
/// Use Hex/Rgba only as an escape hatch when you need a one-off value.
|
||||
|
||||
/// A color value — either a semantic token or an explicit value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Color {
|
||||
// --- Semantic tokens (preferred) ---
|
||||
/// Brand primary color. Use for key actions and highlights.
|
||||
Primary,
|
||||
/// Content drawn on top of Primary.
|
||||
OnPrimary,
|
||||
/// A less prominent container for primary-branded content.
|
||||
PrimaryContainer,
|
||||
|
||||
/// Secondary accent color.
|
||||
Secondary,
|
||||
/// Content drawn on top of Secondary.
|
||||
OnSecondary,
|
||||
|
||||
/// The page/screen background.
|
||||
Background,
|
||||
/// Content drawn on top of Background.
|
||||
OnBackground,
|
||||
|
||||
/// Surface color for cards, sheets, dialogs.
|
||||
Surface,
|
||||
/// Content drawn on top of Surface.
|
||||
OnSurface,
|
||||
/// A surface variant, slightly different from Surface.
|
||||
SurfaceVariant,
|
||||
|
||||
/// Error state color.
|
||||
Error,
|
||||
/// Content drawn on top of Error.
|
||||
OnError,
|
||||
|
||||
/// Outline/border color.
|
||||
Outline,
|
||||
/// Muted outline (dividers, subtle borders).
|
||||
OutlineVariant,
|
||||
|
||||
// --- Escape hatches ---
|
||||
/// An explicit hex color string (e.g. "#3b82f6" or "#3b82f6ff").
|
||||
Hex(String),
|
||||
/// An explicit RGBA color (channels 0–255, alpha 0.0–1.0).
|
||||
Rgba(u8, u8, u8, f32),
|
||||
|
||||
/// Apply opacity to any color.
|
||||
Opacity(Box<Color>, f32),
|
||||
}
|
||||
|
||||
impl Color {
|
||||
/// Create an Opacity variant.
|
||||
pub fn with_opacity(self, opacity: f32) -> Self {
|
||||
Color::Opacity(Box::new(self), opacity.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Convenience: fully transparent.
|
||||
pub fn transparent() -> Self {
|
||||
Color::Rgba(0, 0, 0, 0.0)
|
||||
}
|
||||
|
||||
/// Convenience: pure white.
|
||||
pub fn white() -> Self {
|
||||
Color::Rgba(255, 255, 255, 1.0)
|
||||
}
|
||||
|
||||
/// Convenience: pure black.
|
||||
pub fn black() -> Self {
|
||||
Color::Rgba(0, 0, 0, 1.0)
|
||||
}
|
||||
|
||||
/// Resolve a hex string like "#rrggbb" or "#rrggbbaa" to Rgba.
|
||||
/// Returns None if the string is not a valid hex color.
|
||||
pub fn parse_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim_start_matches('#');
|
||||
match s.len() {
|
||||
6 => {
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
Some(Color::Rgba(r, g, b, 1.0))
|
||||
}
|
||||
8 => {
|
||||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||||
let a = u8::from_str_radix(&s[6..8], 16).ok()?;
|
||||
Some(Color::Rgba(r, g, b, a as f32 / 255.0))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps semantic color tokens to actual RGBA values.
|
||||
/// One ColorScheme per theme mode (light/dark).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorScheme {
|
||||
pub primary: (u8, u8, u8, f32),
|
||||
pub on_primary: (u8, u8, u8, f32),
|
||||
pub primary_container: (u8, u8, u8, f32),
|
||||
pub secondary: (u8, u8, u8, f32),
|
||||
pub on_secondary: (u8, u8, u8, f32),
|
||||
pub background: (u8, u8, u8, f32),
|
||||
pub on_background: (u8, u8, u8, f32),
|
||||
pub surface: (u8, u8, u8, f32),
|
||||
pub on_surface: (u8, u8, u8, f32),
|
||||
pub surface_variant: (u8, u8, u8, f32),
|
||||
pub error: (u8, u8, u8, f32),
|
||||
pub on_error: (u8, u8, u8, f32),
|
||||
pub outline: (u8, u8, u8, f32),
|
||||
pub outline_variant: (u8, u8, u8, f32),
|
||||
}
|
||||
|
||||
impl ColorScheme {
|
||||
/// Default light color scheme.
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
primary: (59, 130, 246, 1.0), // blue-500
|
||||
on_primary: (255, 255, 255, 1.0),
|
||||
primary_container: (219, 234, 254, 1.0), // blue-100
|
||||
secondary: (100, 116, 139, 1.0), // slate-500
|
||||
on_secondary: (255, 255, 255, 1.0),
|
||||
background: (255, 255, 255, 1.0),
|
||||
on_background: (15, 23, 42, 1.0), // slate-900
|
||||
surface: (248, 250, 252, 1.0), // slate-50
|
||||
on_surface: (15, 23, 42, 1.0),
|
||||
surface_variant: (241, 245, 249, 1.0), // slate-100
|
||||
error: (239, 68, 68, 1.0), // red-500
|
||||
on_error: (255, 255, 255, 1.0),
|
||||
outline: (203, 213, 225, 1.0), // slate-300
|
||||
outline_variant: (226, 232, 240, 1.0), // slate-200
|
||||
}
|
||||
}
|
||||
|
||||
/// Default dark color scheme.
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
primary: (96, 165, 250, 1.0), // blue-400
|
||||
on_primary: (15, 23, 42, 1.0),
|
||||
primary_container: (30, 58, 138, 1.0), // blue-900
|
||||
secondary: (148, 163, 184, 1.0), // slate-400
|
||||
on_secondary: (15, 23, 42, 1.0),
|
||||
background: (15, 23, 42, 1.0), // slate-900
|
||||
on_background: (248, 250, 252, 1.0),
|
||||
surface: (30, 41, 59, 1.0), // slate-800
|
||||
on_surface: (248, 250, 252, 1.0),
|
||||
surface_variant: (51, 65, 85, 1.0), // slate-700
|
||||
error: (248, 113, 113, 1.0), // red-400
|
||||
on_error: (15, 23, 42, 1.0),
|
||||
outline: (71, 85, 105, 1.0), // slate-600
|
||||
outline_variant: (51, 65, 85, 1.0), // slate-700
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a semantic Color token to its RGBA tuple.
|
||||
pub fn resolve(&self, color: &Color) -> (u8, u8, u8, f32) {
|
||||
match color {
|
||||
Color::Primary => self.primary,
|
||||
Color::OnPrimary => self.on_primary,
|
||||
Color::PrimaryContainer => self.primary_container,
|
||||
Color::Secondary => self.secondary,
|
||||
Color::OnSecondary => self.on_secondary,
|
||||
Color::Background => self.background,
|
||||
Color::OnBackground => self.on_background,
|
||||
Color::Surface => self.surface,
|
||||
Color::OnSurface => self.on_surface,
|
||||
Color::SurfaceVariant => self.surface_variant,
|
||||
Color::Error => self.error,
|
||||
Color::OnError => self.on_error,
|
||||
Color::Outline => self.outline,
|
||||
Color::OutlineVariant => self.outline_variant,
|
||||
Color::Hex(s) => Color::parse_hex(s)
|
||||
.map(|c| self.resolve(&c))
|
||||
.unwrap_or((0, 0, 0, 1.0)),
|
||||
Color::Rgba(r, g, b, a) => (*r, *g, *b, *a),
|
||||
Color::Opacity(inner, opacity) => {
|
||||
let (r, g, b, a) = self.resolve(inner);
|
||||
(r, g, b, a * opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_6_chars() {
|
||||
let c = Color::parse_hex("#3b82f6").unwrap();
|
||||
assert_eq!(c, Color::Rgba(0x3b, 0x82, 0xf6, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_8_chars() {
|
||||
let c = Color::parse_hex("#3b82f680").unwrap();
|
||||
if let Color::Rgba(r, g, b, a) = c {
|
||||
assert_eq!(r, 0x3b);
|
||||
assert_eq!(g, 0x82);
|
||||
assert_eq!(b, 0xf6);
|
||||
assert!((a - 0x80 as f32 / 255.0).abs() < 0.01);
|
||||
} else {
|
||||
panic!("expected Rgba");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_invalid() {
|
||||
assert!(Color::parse_hex("not-a-color").is_none());
|
||||
assert!(Color::parse_hex("#gg0000").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_opacity() {
|
||||
let c = Color::Primary.with_opacity(0.5);
|
||||
assert!(matches!(c, Color::Opacity(_, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_light_resolves_primary() {
|
||||
let scheme = ColorScheme::light();
|
||||
let (r, g, b, a) = scheme.resolve(&Color::Primary);
|
||||
assert_eq!((r, g, b), (59, 130, 246));
|
||||
assert!((a - 1.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_dark_resolves_background() {
|
||||
let scheme = ColorScheme::dark();
|
||||
let (r, g, b, _) = scheme.resolve(&Color::Background);
|
||||
assert_eq!((r, g, b), (15, 23, 42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_modifies_alpha() {
|
||||
let scheme = ColorScheme::light();
|
||||
let color = Color::Primary.with_opacity(0.5);
|
||||
let (_, _, _, a) = scheme.resolve(&color);
|
||||
assert!((a - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgba_passthrough() {
|
||||
let scheme = ColorScheme::light();
|
||||
let color = Color::Rgba(10, 20, 30, 0.8);
|
||||
let (r, g, b, a) = scheme.resolve(&color);
|
||||
assert_eq!((r, g, b), (10, 20, 30));
|
||||
assert!((a - 0.8).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! el-style — The el-ui design system.
|
||||
//!
|
||||
//! Platform-agnostic, theme-driven style representation. Every value in the
|
||||
//! system is a semantic token — the theme maps tokens to actual colors, sizes,
|
||||
//! and shadows. Components never hardcode visual values.
|
||||
//!
|
||||
//! ## Hierarchy
|
||||
//!
|
||||
//! 1. **Theme** — the single source of truth. Set at the experience root.
|
||||
//! 2. **Semantic tokens** — Color::Primary, Spacing::Lg, Radius::Md, etc.
|
||||
//! 3. **StyleModifier** — fluent builder trait on every component.
|
||||
//! 4. **StyleSheet** — named rule sets, applied with `.apply("card")`.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```
|
||||
//! use el_style::prelude::*;
|
||||
//!
|
||||
//! // Components use semantic tokens, not raw values:
|
||||
//! // button.background(Color::Primary).foreground(Color::OnPrimary).padding(16)
|
||||
//!
|
||||
//! // Theme provides the actual values at render time:
|
||||
//! let theme = Theme::default_light();
|
||||
//! let (r, g, b, a) = theme.colors.resolve(&Color::Primary);
|
||||
//! assert!(r > 0 || g > 0 || b > 0 || a > 0.0);
|
||||
//! ```
|
||||
|
||||
#![deny(warnings)]
|
||||
|
||||
pub mod color;
|
||||
pub mod modifier;
|
||||
pub mod radius;
|
||||
pub mod shadow;
|
||||
pub mod spacing;
|
||||
pub mod stylesheet;
|
||||
pub mod theme;
|
||||
pub mod typography;
|
||||
|
||||
/// Everything you need in one import.
|
||||
pub mod prelude {
|
||||
pub use crate::color::{Color, ColorScheme};
|
||||
pub use crate::modifier::{ButtonVariant, CardStyle, Dimension, InputStyle, StyleModifier, StyleSet};
|
||||
pub use crate::radius::Radius;
|
||||
pub use crate::shadow::Shadow;
|
||||
pub use crate::spacing::Spacing;
|
||||
pub use crate::stylesheet::StyleSheet;
|
||||
pub use crate::theme::{Theme, ThemeMode};
|
||||
pub use crate::typography::{FontWeight, TextAlign, TextDecoration, TextOverflow, TextStyle};
|
||||
}
|
||||
|
||||
pub use prelude::*;
|
||||
@@ -0,0 +1,287 @@
|
||||
// el-style — The el-ui design system.
|
||||
//
|
||||
// Hierarchy:
|
||||
// 1. Theme — single source of truth, set at experience root
|
||||
// 2. Semantic tokens — Color::Primary, Spacing::Lg, Radius::Md, ...
|
||||
// 3. StyleModifier — fluent builder applied to any component
|
||||
// 4. StyleSheet — named rule sets, applied with .apply("card")
|
||||
//
|
||||
// Resolution flow:
|
||||
// component declares semantic token → theme resolves to RGBA / px →
|
||||
// compiler emits CSS-in-JS for the browser target / NSColor for macOS / ...
|
||||
|
||||
// ── Color tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
let COLOR_PRIMARY: String = "primary"
|
||||
let COLOR_ON_PRIMARY: String = "on_primary"
|
||||
let COLOR_PRIMARY_CONTAINER: String = "primary_container"
|
||||
let COLOR_SECONDARY: String = "secondary"
|
||||
let COLOR_ON_SECONDARY: String = "on_secondary"
|
||||
let COLOR_BACKGROUND: String = "background"
|
||||
let COLOR_ON_BACKGROUND: String = "on_background"
|
||||
let COLOR_SURFACE: String = "surface"
|
||||
let COLOR_ON_SURFACE: String = "on_surface"
|
||||
let COLOR_SURFACE_VARIANT: String = "surface_variant"
|
||||
let COLOR_ERROR: String = "error"
|
||||
let COLOR_ON_ERROR: String = "on_error"
|
||||
let COLOR_OUTLINE: String = "outline"
|
||||
let COLOR_OUTLINE_VARIANT: String = "outline_variant"
|
||||
|
||||
// A resolved RGBA quadruple, encoded as JSON for transport across the FFI.
|
||||
type Rgba {
|
||||
r: Int // 0-255
|
||||
g: Int // 0-255
|
||||
b: Int // 0-255
|
||||
a: Int // 0-255 (we store alpha *256 to keep everything integer in El today)
|
||||
}
|
||||
|
||||
fn rgba(r: Int, g: Int, b: Int, a: Int) -> Rgba {
|
||||
{ "r": r, "g": g, "b": b, "a": a }
|
||||
}
|
||||
|
||||
fn rgba_to_css(c: Rgba) -> String {
|
||||
"rgba(" + int_to_str(c.r) + "," + int_to_str(c.g) + "," + int_to_str(c.b)
|
||||
+ "," + int_to_str(c.a) + ")"
|
||||
}
|
||||
|
||||
fn parse_hex(hex: String) -> Rgba {
|
||||
let s: String = hex
|
||||
if str_starts_with(s, "#") { let s = str_slice(s, 1, str_len(s)) }
|
||||
let r: Int = hex_to_int(str_slice(s, 0, 2))
|
||||
let g: Int = hex_to_int(str_slice(s, 2, 4))
|
||||
let b: Int = hex_to_int(str_slice(s, 4, 6))
|
||||
let a: Int = 255
|
||||
if str_len(s) > 6 { let a = hex_to_int(str_slice(s, 6, 8)) }
|
||||
{ "r": r, "g": g, "b": b, "a": a }
|
||||
}
|
||||
|
||||
// ── ColorScheme: semantic token -> Rgba ──────────────────────────────────────
|
||||
|
||||
type ColorScheme {
|
||||
primary: String // hex
|
||||
on_primary: String
|
||||
primary_container: String
|
||||
secondary: String
|
||||
on_secondary: String
|
||||
background: String
|
||||
on_background: String
|
||||
surface: String
|
||||
on_surface: String
|
||||
surface_variant: String
|
||||
error: String
|
||||
on_error: String
|
||||
outline: String
|
||||
outline_variant: String
|
||||
}
|
||||
|
||||
fn color_scheme_light() -> ColorScheme {
|
||||
{ "primary": "#3b82f6", "on_primary": "#ffffff", "primary_container": "#dbeafe",
|
||||
"secondary": "#a855f7", "on_secondary": "#ffffff",
|
||||
"background": "#ffffff", "on_background": "#0f172a",
|
||||
"surface": "#f8fafc", "on_surface": "#0f172a", "surface_variant": "#e2e8f0",
|
||||
"error": "#dc2626", "on_error": "#ffffff",
|
||||
"outline": "#cbd5e1", "outline_variant": "#e2e8f0" }
|
||||
}
|
||||
|
||||
fn color_scheme_dark() -> ColorScheme {
|
||||
{ "primary": "#60a5fa", "on_primary": "#0f172a", "primary_container": "#1e3a8a",
|
||||
"secondary": "#c084fc", "on_secondary": "#0f172a",
|
||||
"background": "#0f172a", "on_background": "#f8fafc",
|
||||
"surface": "#1e293b", "on_surface": "#f8fafc", "surface_variant": "#334155",
|
||||
"error": "#f87171", "on_error": "#0f172a",
|
||||
"outline": "#475569", "outline_variant": "#334155" }
|
||||
}
|
||||
|
||||
// Resolve a semantic color token to its hex value in the active scheme.
|
||||
fn resolve_color(scheme: ColorScheme, token: String) -> String {
|
||||
if str_eq(token, "primary") { return scheme.primary }
|
||||
if str_eq(token, "on_primary") { return scheme.on_primary }
|
||||
if str_eq(token, "primary_container") { return scheme.primary_container }
|
||||
if str_eq(token, "secondary") { return scheme.secondary }
|
||||
if str_eq(token, "on_secondary") { return scheme.on_secondary }
|
||||
if str_eq(token, "background") { return scheme.background }
|
||||
if str_eq(token, "on_background") { return scheme.on_background }
|
||||
if str_eq(token, "surface") { return scheme.surface }
|
||||
if str_eq(token, "on_surface") { return scheme.on_surface }
|
||||
if str_eq(token, "surface_variant") { return scheme.surface_variant }
|
||||
if str_eq(token, "error") { return scheme.error }
|
||||
if str_eq(token, "on_error") { return scheme.on_error }
|
||||
if str_eq(token, "outline") { return scheme.outline }
|
||||
if str_eq(token, "outline_variant") { return scheme.outline_variant }
|
||||
"#000000"
|
||||
}
|
||||
|
||||
// ── Spacing scale ────────────────────────────────────────────────────────────
|
||||
|
||||
type SpacingScale {
|
||||
xs: Int
|
||||
sm: Int
|
||||
md: Int
|
||||
lg: Int
|
||||
xl: Int
|
||||
xxl: Int
|
||||
}
|
||||
|
||||
fn spacing_default() -> SpacingScale {
|
||||
{ "xs": 4, "sm": 8, "md": 16, "lg": 24, "xl": 32, "xxl": 48 }
|
||||
}
|
||||
|
||||
fn resolve_spacing(s: SpacingScale, token: String) -> Int {
|
||||
if str_eq(token, "xs") { return s.xs }
|
||||
if str_eq(token, "sm") { return s.sm }
|
||||
if str_eq(token, "md") { return s.md }
|
||||
if str_eq(token, "lg") { return s.lg }
|
||||
if str_eq(token, "xl") { return s.xl }
|
||||
if str_eq(token, "xxl") { return s.xxl }
|
||||
s.md
|
||||
}
|
||||
|
||||
// ── Radius / shadow ──────────────────────────────────────────────────────────
|
||||
|
||||
type RadiusScale {
|
||||
none: Int
|
||||
sm: Int
|
||||
md: Int
|
||||
lg: Int
|
||||
full: Int
|
||||
}
|
||||
|
||||
fn radius_default() -> RadiusScale {
|
||||
{ "none": 0, "sm": 4, "md": 8, "lg": 16, "full": 9999 }
|
||||
}
|
||||
|
||||
type ShadowScale {
|
||||
sm: String // CSS box-shadow string
|
||||
md: String
|
||||
lg: String
|
||||
}
|
||||
|
||||
fn shadow_light() -> ShadowScale {
|
||||
{ "sm": "0 1px 2px rgba(0,0,0,0.05)",
|
||||
"md": "0 4px 6px rgba(0,0,0,0.1)",
|
||||
"lg": "0 10px 15px rgba(0,0,0,0.1)" }
|
||||
}
|
||||
|
||||
fn shadow_dark() -> ShadowScale {
|
||||
{ "sm": "0 1px 2px rgba(0,0,0,0.4)",
|
||||
"md": "0 4px 6px rgba(0,0,0,0.5)",
|
||||
"lg": "0 10px 15px rgba(0,0,0,0.6)" }
|
||||
}
|
||||
|
||||
// ── Typography ───────────────────────────────────────────────────────────────
|
||||
|
||||
type TextStyle {
|
||||
font_family: String
|
||||
font_size: Int
|
||||
font_weight: Int // 100..900
|
||||
line_height: Int // px
|
||||
letter_spacing: Int // hundredths of em
|
||||
}
|
||||
|
||||
fn text_style_default() -> TextStyle {
|
||||
{ "font_family": "system-ui, -apple-system, sans-serif",
|
||||
"font_size": 16, "font_weight": 400, "line_height": 24, "letter_spacing": 0 }
|
||||
}
|
||||
|
||||
// ── Theme ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let THEME_MODE_LIGHT: String = "light"
|
||||
let THEME_MODE_DARK: String = "dark"
|
||||
let THEME_MODE_SYSTEM: String = "system"
|
||||
|
||||
type Theme {
|
||||
mode: String // light | dark | system
|
||||
colors: ColorScheme
|
||||
spacing: SpacingScale
|
||||
radius: RadiusScale
|
||||
shadows: ShadowScale
|
||||
typography: TextStyle
|
||||
}
|
||||
|
||||
fn theme_default_light() -> Theme {
|
||||
{ "mode": "light", "colors": color_scheme_light(), "spacing": spacing_default(),
|
||||
"radius": radius_default(), "shadows": shadow_light(), "typography": text_style_default() }
|
||||
}
|
||||
|
||||
fn theme_default_dark() -> Theme {
|
||||
{ "mode": "dark", "colors": color_scheme_dark(), "spacing": spacing_default(),
|
||||
"radius": radius_default(), "shadows": shadow_dark(), "typography": text_style_default() }
|
||||
}
|
||||
|
||||
// ── StyleSheet — named rule sets ─────────────────────────────────────────────
|
||||
//
|
||||
// A StyleSheet is a JSON object mapping selector -> declarations.
|
||||
// Components apply by name: element.apply("card")
|
||||
|
||||
fn stylesheet_new() -> String {
|
||||
"{}"
|
||||
}
|
||||
|
||||
fn stylesheet_define(sheet: String, name: String, css_decls: String) -> String {
|
||||
json_set(sheet, name, css_decls)
|
||||
}
|
||||
|
||||
fn stylesheet_resolve(sheet: String, name: String) -> String {
|
||||
json_get(sheet, name)
|
||||
}
|
||||
|
||||
// ── CSS emit ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Server-side render: theme → CSS custom properties block.
|
||||
// Emit as a <style> tag or inject into an existing stylesheet string.
|
||||
|
||||
fn theme_to_css_vars(theme: Theme) -> String {
|
||||
let c: ColorScheme = theme.colors
|
||||
let s: SpacingScale = theme.spacing
|
||||
let r: RadiusScale = theme.radius
|
||||
let sh: ShadowScale = theme.shadows
|
||||
let ty: TextStyle = theme.typography
|
||||
"--el-primary:" + c.primary + ";"
|
||||
+ "--el-on-primary:" + c.on_primary + ";"
|
||||
+ "--el-primary-container:" + c.primary_container + ";"
|
||||
+ "--el-secondary:" + c.secondary + ";"
|
||||
+ "--el-on-secondary:" + c.on_secondary + ";"
|
||||
+ "--el-bg:" + c.background + ";"
|
||||
+ "--el-on-bg:" + c.on_background + ";"
|
||||
+ "--el-surface:" + c.surface + ";"
|
||||
+ "--el-on-surface:" + c.on_surface + ";"
|
||||
+ "--el-surface-variant:" + c.surface_variant + ";"
|
||||
+ "--el-error:" + c.error + ";"
|
||||
+ "--el-outline:" + c.outline + ";"
|
||||
+ "--el-outline-variant:" + c.outline_variant + ";"
|
||||
+ "--el-space-xs:" + int_to_str(s.xs) + "px;"
|
||||
+ "--el-space-sm:" + int_to_str(s.sm) + "px;"
|
||||
+ "--el-space-md:" + int_to_str(s.md) + "px;"
|
||||
+ "--el-space-lg:" + int_to_str(s.lg) + "px;"
|
||||
+ "--el-space-xl:" + int_to_str(s.xl) + "px;"
|
||||
+ "--el-space-xxl:" + int_to_str(s.xxl) + "px;"
|
||||
+ "--el-radius-none:" + int_to_str(r.none) + "px;"
|
||||
+ "--el-radius-sm:" + int_to_str(r.sm) + "px;"
|
||||
+ "--el-radius-md:" + int_to_str(r.md) + "px;"
|
||||
+ "--el-radius-lg:" + int_to_str(r.lg) + "px;"
|
||||
+ "--el-radius-full:" + int_to_str(r.full) + "px;"
|
||||
+ "--el-shadow-sm:" + sh.sm + ";"
|
||||
+ "--el-shadow-md:" + sh.md + ";"
|
||||
+ "--el-shadow-lg:" + sh.lg + ";"
|
||||
+ "--el-font-family:" + ty.font_family + ";"
|
||||
+ "--el-font-size:" + int_to_str(ty.font_size) + "px;"
|
||||
+ "--el-font-weight:" + int_to_str(ty.font_weight) + ";"
|
||||
+ "--el-line-height:" + int_to_str(ty.line_height) + "px"
|
||||
}
|
||||
|
||||
fn theme_to_style_tag(theme: Theme) -> String {
|
||||
"<style>:root{" + theme_to_css_vars(theme) + "}</style>"
|
||||
}
|
||||
|
||||
fn text_style_to_css(ts: TextStyle) -> String {
|
||||
"font-family:" + ts.font_family
|
||||
+ ";font-size:" + int_to_str(ts.font_size) + "px"
|
||||
+ ";font-weight:" + int_to_str(ts.font_weight)
|
||||
+ ";line-height:" + int_to_str(ts.line_height) + "px"
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let theme: Theme = theme_default_light()
|
||||
println("[el-style] light primary = " + theme.colors.primary)
|
||||
println("[el-style] css vars = " + theme_to_css_vars(theme))
|
||||
@@ -0,0 +1,382 @@
|
||||
/// StyleModifier trait — fluent, zero-cost style chaining.
|
||||
///
|
||||
/// Every el-ui component implements StyleModifier so callers can chain style
|
||||
/// adjustments in a natural builder pattern. The trait is compile-time only;
|
||||
/// there is no runtime allocation or dynamic dispatch.
|
||||
|
||||
use crate::color::Color;
|
||||
use crate::radius::Radius;
|
||||
use crate::shadow::Shadow;
|
||||
use crate::typography::TextStyle;
|
||||
|
||||
/// A dimension — how wide or tall something should be.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Dimension {
|
||||
/// Exact density-independent pixels.
|
||||
Fixed(u32),
|
||||
/// Fill all available space from the parent.
|
||||
Fill,
|
||||
/// Shrink to fit the content.
|
||||
Wrap,
|
||||
/// Fraction of the parent's dimension (0.0–1.0).
|
||||
Fraction(f32),
|
||||
/// Minimum of two dimensions.
|
||||
Min(Box<Dimension>, Box<Dimension>),
|
||||
/// Maximum of two dimensions.
|
||||
Max(Box<Dimension>, Box<Dimension>),
|
||||
}
|
||||
|
||||
impl Dimension {
|
||||
pub fn fraction(f: f32) -> Self {
|
||||
Dimension::Fraction(f.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
pub fn half() -> Self {
|
||||
Dimension::Fraction(0.5)
|
||||
}
|
||||
|
||||
pub fn full() -> Self {
|
||||
Dimension::Fill
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete set of style properties that can be applied to a component.
|
||||
///
|
||||
/// All fields are optional — only the ones set by the caller are applied.
|
||||
/// The platform backend reads these when rendering and maps them to native
|
||||
/// style properties.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StyleSet {
|
||||
pub padding: Option<[u32; 4]>, // top, right, bottom, left
|
||||
pub margin: Option<[u32; 4]>,
|
||||
pub background: Option<Color>,
|
||||
pub foreground: Option<Color>,
|
||||
pub font: Option<TextStyle>,
|
||||
pub radius: Option<[u32; 4]>, // top-left, top-right, bottom-right, bottom-left
|
||||
pub shadow: Option<Shadow>,
|
||||
pub opacity: Option<f32>,
|
||||
pub border_width: Option<u32>,
|
||||
pub border_color: Option<Color>,
|
||||
pub width: Option<Dimension>,
|
||||
pub height: Option<Dimension>,
|
||||
pub max_width: Option<Dimension>,
|
||||
pub max_height: Option<Dimension>,
|
||||
pub min_width: Option<Dimension>,
|
||||
pub min_height: Option<Dimension>,
|
||||
pub flex_grow: Option<f32>,
|
||||
pub flex_shrink: Option<f32>,
|
||||
pub z_index: Option<i32>,
|
||||
pub hidden: bool,
|
||||
pub clip: bool,
|
||||
}
|
||||
|
||||
/// The core trait every styled el-ui component implements.
|
||||
///
|
||||
/// Returns `Self` — the methods consume and return the component for
|
||||
/// fluent chaining without allocation.
|
||||
pub trait StyleModifier: Sized {
|
||||
/// Access the mutable StyleSet for this component.
|
||||
fn style_mut(&mut self) -> &mut StyleSet;
|
||||
|
||||
/// Apply uniform padding on all four sides.
|
||||
fn padding(mut self, value: u32) -> Self {
|
||||
self.style_mut().padding = Some([value; 4]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Apply horizontal (x) and vertical (y) padding.
|
||||
fn padding_xy(mut self, x: u32, y: u32) -> Self {
|
||||
self.style_mut().padding = Some([y, x, y, x]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Apply padding individually: top, right, bottom, left.
|
||||
fn padding_sides(mut self, top: u32, right: u32, bottom: u32, left: u32) -> Self {
|
||||
self.style_mut().padding = Some([top, right, bottom, left]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Apply uniform margin on all four sides.
|
||||
fn margin(mut self, value: u32) -> Self {
|
||||
self.style_mut().margin = Some([value; 4]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Apply horizontal and vertical margin.
|
||||
fn margin_xy(mut self, x: u32, y: u32) -> Self {
|
||||
self.style_mut().margin = Some([y, x, y, x]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the background color.
|
||||
fn background(mut self, color: Color) -> Self {
|
||||
self.style_mut().background = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the foreground (text/icon) color.
|
||||
fn foreground(mut self, color: Color) -> Self {
|
||||
self.style_mut().foreground = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the text/font style.
|
||||
fn font(mut self, style: TextStyle) -> Self {
|
||||
self.style_mut().font = Some(style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a uniform border radius on all four corners.
|
||||
fn radius(mut self, radius: Radius) -> Self {
|
||||
let r = radius.dp();
|
||||
self.style_mut().radius = Some([r; 4]);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the shadow/elevation.
|
||||
fn shadow(mut self, elevation: Shadow) -> Self {
|
||||
self.style_mut().shadow = Some(elevation);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the opacity (0.0 = invisible, 1.0 = fully visible).
|
||||
fn opacity(mut self, value: f32) -> Self {
|
||||
self.style_mut().opacity = Some(value.clamp(0.0, 1.0));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a border with width and color.
|
||||
fn border(mut self, width: u32, color: Color) -> Self {
|
||||
let s = self.style_mut();
|
||||
s.border_width = Some(width);
|
||||
s.border_color = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the width.
|
||||
fn width(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().width = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the height.
|
||||
fn height(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().height = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum width.
|
||||
fn max_width(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().max_width = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum height.
|
||||
fn max_height(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().max_height = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the minimum width.
|
||||
fn min_width(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().min_width = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the minimum height.
|
||||
fn min_height(mut self, value: Dimension) -> Self {
|
||||
self.style_mut().min_height = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
/// How much this component grows to fill available space (flex-grow).
|
||||
fn grow(mut self, factor: f32) -> Self {
|
||||
self.style_mut().flex_grow = Some(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Z-index for layering.
|
||||
fn z_index(mut self, z: i32) -> Self {
|
||||
self.style_mut().z_index = Some(z);
|
||||
self
|
||||
}
|
||||
|
||||
/// Clip content that overflows the component's bounds.
|
||||
fn clip(mut self) -> Self {
|
||||
self.style_mut().clip = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Hide this component (still occupies space in layout).
|
||||
fn hidden(mut self, value: bool) -> Self {
|
||||
self.style_mut().hidden = value;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-composed style variants for common component types.
|
||||
|
||||
/// Button style variants.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
/// Filled primary-color button (the main CTA).
|
||||
Primary,
|
||||
/// Filled secondary-color button.
|
||||
Secondary,
|
||||
/// Destructive action (red).
|
||||
Destructive,
|
||||
/// Text-only, no fill or border.
|
||||
Ghost,
|
||||
/// Inline hyperlink style.
|
||||
Link,
|
||||
}
|
||||
|
||||
/// Card style variants.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CardStyle {
|
||||
/// Card with a drop shadow.
|
||||
Elevated,
|
||||
/// Card with an outline border, no shadow.
|
||||
Outlined,
|
||||
/// Card with a filled background color, no shadow.
|
||||
Filled,
|
||||
}
|
||||
|
||||
/// Text input style variants.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InputStyle {
|
||||
/// Standard input (platform default).
|
||||
Default,
|
||||
/// Outlined/bordered input.
|
||||
Outlined,
|
||||
/// Filled background input.
|
||||
Filled,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::color::Color;
|
||||
use crate::radius::Radius;
|
||||
use crate::shadow::Shadow;
|
||||
use crate::typography::TextStyle;
|
||||
|
||||
/// A minimal test component that implements StyleModifier.
|
||||
#[derive(Default)]
|
||||
struct TestWidget {
|
||||
style: StyleSet,
|
||||
}
|
||||
|
||||
impl StyleModifier for TestWidget {
|
||||
fn style_mut(&mut self) -> &mut StyleSet {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_sets_all_sides() {
|
||||
let w = TestWidget::default().padding(16);
|
||||
assert_eq!(w.style.padding, Some([16; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_xy_sets_correctly() {
|
||||
let w = TestWidget::default().padding_xy(8, 16);
|
||||
// [top, right, bottom, left] = [y, x, y, x]
|
||||
assert_eq!(w.style.padding, Some([16, 8, 16, 8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_stored() {
|
||||
let w = TestWidget::default().background(Color::Primary);
|
||||
assert_eq!(w.style.background, Some(Color::Primary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreground_stored() {
|
||||
let w = TestWidget::default().foreground(Color::OnPrimary);
|
||||
assert_eq!(w.style.foreground, Some(Color::OnPrimary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_stored() {
|
||||
let w = TestWidget::default().font(TextStyle::Body);
|
||||
assert_eq!(w.style.font, Some(TextStyle::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_stored() {
|
||||
let w = TestWidget::default().radius(Radius::Md);
|
||||
assert_eq!(w.style.radius, Some([8; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_stored() {
|
||||
let w = TestWidget::default().shadow(Shadow::Md);
|
||||
assert_eq!(w.style.shadow, Some(Shadow::Md));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_clamped() {
|
||||
let w = TestWidget::default().opacity(2.5);
|
||||
assert_eq!(w.style.opacity, Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_valid() {
|
||||
let w = TestWidget::default().opacity(0.5);
|
||||
assert!((w.style.opacity.unwrap() - 0.5).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_stored() {
|
||||
let w = TestWidget::default().border(2, Color::Outline);
|
||||
assert_eq!(w.style.border_width, Some(2));
|
||||
assert_eq!(w.style.border_color, Some(Color::Outline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_multiple_modifiers() {
|
||||
let w = TestWidget::default()
|
||||
.padding(16)
|
||||
.background(Color::Surface)
|
||||
.radius(Radius::Lg)
|
||||
.shadow(Shadow::Sm)
|
||||
.opacity(0.9);
|
||||
assert!(w.style.padding.is_some());
|
||||
assert!(w.style.background.is_some());
|
||||
assert!(w.style.radius.is_some());
|
||||
assert!(w.style.shadow.is_some());
|
||||
assert!(w.style.opacity.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_flag() {
|
||||
let w = TestWidget::default().hidden(true);
|
||||
assert!(w.style.hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_flag() {
|
||||
let w = TestWidget::default().clip();
|
||||
assert!(w.style.clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_fraction_clamped() {
|
||||
let d = Dimension::fraction(1.5);
|
||||
assert_eq!(d, Dimension::Fraction(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_stored() {
|
||||
let w = TestWidget::default().width(Dimension::Fill);
|
||||
assert_eq!(w.style.width, Some(Dimension::Fill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_width_stored() {
|
||||
let w = TestWidget::default().max_width(Dimension::Fixed(600));
|
||||
assert_eq!(w.style.max_width, Some(Dimension::Fixed(600)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/// Border radius scale.
|
||||
///
|
||||
/// Use named tokens, not raw pixel values. Swap out the RadiusScale
|
||||
/// in the theme to change the visual "softness" of the entire UI at once.
|
||||
|
||||
/// Named border radius tokens.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Radius {
|
||||
/// 0 — sharp corners
|
||||
None,
|
||||
/// 4dp — subtle rounding (list items, small chips)
|
||||
Sm,
|
||||
/// 8dp — default card / button rounding
|
||||
Md,
|
||||
/// 12dp — more prominent rounding (modals, drawers)
|
||||
Lg,
|
||||
/// 16dp — very rounded (bottom sheets, large cards)
|
||||
Xl,
|
||||
/// 9999dp — fully pill-shaped
|
||||
Full,
|
||||
/// Explicit dp value (escape hatch)
|
||||
Custom(u32),
|
||||
}
|
||||
|
||||
impl Radius {
|
||||
/// Resolve to dp.
|
||||
pub fn dp(&self) -> u32 {
|
||||
match self {
|
||||
Radius::None => 0,
|
||||
Radius::Sm => 4,
|
||||
Radius::Md => 8,
|
||||
Radius::Lg => 12,
|
||||
Radius::Xl => 16,
|
||||
Radius::Full => 9999,
|
||||
Radius::Custom(v) => *v,
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS border-radius string.
|
||||
pub fn to_css(&self) -> String {
|
||||
match self {
|
||||
Radius::Full => "9999px".to_string(),
|
||||
other => format!("{}px", other.dp()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme-level radius scale.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RadiusScale {
|
||||
pub sm: u32,
|
||||
pub md: u32,
|
||||
pub lg: u32,
|
||||
pub xl: u32,
|
||||
}
|
||||
|
||||
impl RadiusScale {
|
||||
pub fn default() -> Self {
|
||||
Self {
|
||||
sm: 4,
|
||||
md: 8,
|
||||
lg: 12,
|
||||
xl: 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a Radius token to dp using this scale.
|
||||
pub fn resolve(&self, radius: &Radius) -> u32 {
|
||||
match radius {
|
||||
Radius::None => 0,
|
||||
Radius::Sm => self.sm,
|
||||
Radius::Md => self.md,
|
||||
Radius::Lg => self.lg,
|
||||
Radius::Xl => self.xl,
|
||||
Radius::Full => 9999,
|
||||
Radius::Custom(v) => *v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn radius_none_is_zero() {
|
||||
assert_eq!(Radius::None.dp(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_full_is_large() {
|
||||
assert_eq!(Radius::Full.dp(), 9999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_css_full() {
|
||||
assert_eq!(Radius::Full.to_css(), "9999px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_css_md() {
|
||||
assert_eq!(Radius::Md.to_css(), "8px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_custom() {
|
||||
assert_eq!(Radius::Custom(20).dp(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radius_scale_default() {
|
||||
let scale = RadiusScale::default();
|
||||
assert_eq!(scale.resolve(&Radius::Md), 8);
|
||||
assert_eq!(scale.resolve(&Radius::Lg), 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/// Elevation shadow scale.
|
||||
///
|
||||
/// Shadows communicate visual hierarchy and z-depth. Use the named scale —
|
||||
/// it maps to appropriate platform-native elevation on each backend.
|
||||
|
||||
/// Named shadow/elevation tokens.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Shadow {
|
||||
/// No shadow — flat, on-surface elements.
|
||||
None,
|
||||
/// Subtle drop shadow — slightly elevated cards.
|
||||
Sm,
|
||||
/// Standard card shadow — interactive elements.
|
||||
Md,
|
||||
/// Prominent shadow — dialogs, dropdowns, popovers.
|
||||
Lg,
|
||||
/// Maximum elevation — toasts, context menus, tooltips.
|
||||
Xl,
|
||||
}
|
||||
|
||||
/// A fully-resolved shadow specification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShadowSpec {
|
||||
/// X offset in dp.
|
||||
pub offset_x: f32,
|
||||
/// Y offset in dp (positive = down).
|
||||
pub offset_y: f32,
|
||||
/// Blur radius in dp.
|
||||
pub blur: f32,
|
||||
/// Spread radius in dp.
|
||||
pub spread: f32,
|
||||
/// Shadow color (RGBA).
|
||||
pub color: (u8, u8, u8, f32),
|
||||
}
|
||||
|
||||
impl ShadowSpec {
|
||||
/// CSS box-shadow string for this spec.
|
||||
pub fn to_css(&self) -> String {
|
||||
format!(
|
||||
"{}px {}px {}px {}px rgba({},{},{},{})",
|
||||
self.offset_x,
|
||||
self.offset_y,
|
||||
self.blur,
|
||||
self.spread,
|
||||
self.color.0,
|
||||
self.color.1,
|
||||
self.color.2,
|
||||
self.color.3,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps Shadow tokens to ShadowSpecs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShadowScale {
|
||||
pub sm: ShadowSpec,
|
||||
pub md: ShadowSpec,
|
||||
pub lg: ShadowSpec,
|
||||
pub xl: ShadowSpec,
|
||||
}
|
||||
|
||||
impl ShadowScale {
|
||||
/// Default light-mode shadow scale.
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
sm: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 1.0,
|
||||
blur: 3.0,
|
||||
spread: 0.0,
|
||||
color: (0, 0, 0, 0.12),
|
||||
},
|
||||
md: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 4.0,
|
||||
blur: 12.0,
|
||||
spread: -2.0,
|
||||
color: (0, 0, 0, 0.15),
|
||||
},
|
||||
lg: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 8.0,
|
||||
blur: 24.0,
|
||||
spread: -4.0,
|
||||
color: (0, 0, 0, 0.18),
|
||||
},
|
||||
xl: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 16.0,
|
||||
blur: 48.0,
|
||||
spread: -8.0,
|
||||
color: (0, 0, 0, 0.22),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Default dark-mode shadow scale (more subtle, less visible on dark bg).
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
sm: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 1.0,
|
||||
blur: 3.0,
|
||||
spread: 0.0,
|
||||
color: (0, 0, 0, 0.3),
|
||||
},
|
||||
md: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 4.0,
|
||||
blur: 12.0,
|
||||
spread: -2.0,
|
||||
color: (0, 0, 0, 0.4),
|
||||
},
|
||||
lg: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 8.0,
|
||||
blur: 24.0,
|
||||
spread: -4.0,
|
||||
color: (0, 0, 0, 0.5),
|
||||
},
|
||||
xl: ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 16.0,
|
||||
blur: 48.0,
|
||||
spread: -8.0,
|
||||
color: (0, 0, 0, 0.6),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a Shadow token to a ShadowSpec.
|
||||
/// Returns None for Shadow::None (no spec needed).
|
||||
pub fn resolve(&self, shadow: &Shadow) -> Option<&ShadowSpec> {
|
||||
match shadow {
|
||||
Shadow::None => None,
|
||||
Shadow::Sm => Some(&self.sm),
|
||||
Shadow::Md => Some(&self.md),
|
||||
Shadow::Lg => Some(&self.lg),
|
||||
Shadow::Xl => Some(&self.xl),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shadow_none_resolves_to_none() {
|
||||
let scale = ShadowScale::light();
|
||||
assert!(scale.resolve(&Shadow::None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_sm_resolves() {
|
||||
let scale = ShadowScale::light();
|
||||
let spec = scale.resolve(&Shadow::Sm).unwrap();
|
||||
assert!(spec.blur > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_xl_has_larger_blur_than_sm() {
|
||||
let scale = ShadowScale::light();
|
||||
let sm = scale.resolve(&Shadow::Sm).unwrap();
|
||||
let xl = scale.resolve(&Shadow::Xl).unwrap();
|
||||
assert!(xl.blur > sm.blur);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_spec_to_css() {
|
||||
let spec = ShadowSpec {
|
||||
offset_x: 0.0,
|
||||
offset_y: 4.0,
|
||||
blur: 12.0,
|
||||
spread: -2.0,
|
||||
color: (0, 0, 0, 0.15),
|
||||
};
|
||||
let css = spec.to_css();
|
||||
assert!(css.contains("rgba(0,0,0,0.15)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_shadows_are_more_opaque() {
|
||||
let light = ShadowScale::light();
|
||||
let dark = ShadowScale::dark();
|
||||
let l_alpha = light.resolve(&Shadow::Md).unwrap().color.3;
|
||||
let d_alpha = dark.resolve(&Shadow::Md).unwrap().color.3;
|
||||
assert!(d_alpha > l_alpha);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/// Spacing scale — 4px base grid.
|
||||
///
|
||||
/// Use named scale values, not raw numbers. This ensures visual consistency
|
||||
/// and makes it easy to tweak the entire system by changing the base unit.
|
||||
|
||||
/// Named spacing scale values.
|
||||
///
|
||||
/// The base unit is 4dp/pt. All values are multiples of 4.
|
||||
/// Use these for padding, margin, gap, and any other spatial measurement.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Spacing {
|
||||
/// 0 — no spacing
|
||||
None,
|
||||
/// 4dp — hairline gap, tight list items
|
||||
Xs,
|
||||
/// 8dp — default tight padding, icon margins
|
||||
Sm,
|
||||
/// 12dp — compact component padding
|
||||
Md,
|
||||
/// 16dp — standard component padding (the workhorse)
|
||||
Lg,
|
||||
/// 24dp — generous padding, card internal spacing
|
||||
Xl,
|
||||
/// 32dp — section separation
|
||||
Xxl,
|
||||
/// 48dp — hero sections, major layout gaps
|
||||
Xxxl,
|
||||
/// 64dp — page-level margins, maximum separation
|
||||
Max,
|
||||
/// Custom value in dp (escape hatch)
|
||||
Custom(u32),
|
||||
}
|
||||
|
||||
impl Spacing {
|
||||
/// Resolve to a concrete dp/pt value.
|
||||
pub fn dp(&self) -> u32 {
|
||||
match self {
|
||||
Spacing::None => 0,
|
||||
Spacing::Xs => 4,
|
||||
Spacing::Sm => 8,
|
||||
Spacing::Md => 12,
|
||||
Spacing::Lg => 16,
|
||||
Spacing::Xl => 24,
|
||||
Spacing::Xxl => 32,
|
||||
Spacing::Xxxl => 48,
|
||||
Spacing::Max => 64,
|
||||
Spacing::Custom(v) => *v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve to a concrete CSS pixel string.
|
||||
pub fn to_css(&self) -> String {
|
||||
format!("{}px", self.dp())
|
||||
}
|
||||
}
|
||||
|
||||
/// The spacing scale exposed by a theme.
|
||||
/// Provides the mapping from scale names to concrete values.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpacingScale {
|
||||
/// Base unit in dp (default: 4).
|
||||
pub base: u32,
|
||||
}
|
||||
|
||||
impl SpacingScale {
|
||||
pub fn default() -> Self {
|
||||
Self { base: 4 }
|
||||
}
|
||||
|
||||
/// Resolve a Spacing token to dp.
|
||||
pub fn resolve(&self, spacing: &Spacing) -> u32 {
|
||||
match spacing {
|
||||
Spacing::Custom(v) => *v,
|
||||
other => {
|
||||
let multiplier = match other {
|
||||
Spacing::None => 0,
|
||||
Spacing::Xs => 1,
|
||||
Spacing::Sm => 2,
|
||||
Spacing::Md => 3,
|
||||
Spacing::Lg => 4,
|
||||
Spacing::Xl => 6,
|
||||
Spacing::Xxl => 8,
|
||||
Spacing::Xxxl => 12,
|
||||
Spacing::Max => 16,
|
||||
Spacing::Custom(_) => unreachable!(),
|
||||
};
|
||||
self.base * multiplier
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spacing_none_is_zero() {
|
||||
assert_eq!(Spacing::None.dp(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_lg_is_16() {
|
||||
assert_eq!(Spacing::Lg.dp(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_max_is_64() {
|
||||
assert_eq!(Spacing::Max.dp(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_custom() {
|
||||
assert_eq!(Spacing::Custom(20).dp(), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_scale_resolves_base_unit() {
|
||||
let scale = SpacingScale { base: 4 };
|
||||
assert_eq!(scale.resolve(&Spacing::Xs), 4);
|
||||
assert_eq!(scale.resolve(&Spacing::Sm), 8);
|
||||
assert_eq!(scale.resolve(&Spacing::Lg), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_to_css() {
|
||||
assert_eq!(Spacing::Lg.to_css(), "16px");
|
||||
assert_eq!(Spacing::None.to_css(), "0px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_scale_custom_scale() {
|
||||
let scale = SpacingScale { base: 8 };
|
||||
assert_eq!(scale.resolve(&Spacing::Xs), 8);
|
||||
assert_eq!(scale.resolve(&Spacing::Sm), 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/// StyleSheet — named style rules for components.
|
||||
///
|
||||
/// Instead of applying modifiers inline everywhere, you can define named
|
||||
/// style rules in a StyleSheet and apply them with `.apply("card")`.
|
||||
/// This is the el-ui equivalent of CSS class names, but type-safe and
|
||||
/// resolved at compile time.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use crate::modifier::StyleSet;
|
||||
|
||||
/// A stylesheet: a named collection of StyleSet rules.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StyleSheet {
|
||||
rules: HashMap<String, StyleSet>,
|
||||
}
|
||||
|
||||
impl StyleSheet {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Define a named style rule.
|
||||
pub fn define(&mut self, name: impl Into<String>, style: StyleSet) -> &mut Self {
|
||||
self.rules.insert(name.into(), style);
|
||||
self
|
||||
}
|
||||
|
||||
/// Look up a named style rule.
|
||||
/// Returns None if the rule doesn't exist.
|
||||
pub fn get(&self, name: &str) -> Option<&StyleSet> {
|
||||
self.rules.get(name)
|
||||
}
|
||||
|
||||
/// Apply a named style to a StyleSet by merging — caller's explicit
|
||||
/// values win, the stylesheet fills in the rest.
|
||||
///
|
||||
/// This is an additive operation: fields that are Some in the named
|
||||
/// rule overwrite fields that are None in the target.
|
||||
pub fn apply(&self, name: &str, target: &mut StyleSet) {
|
||||
if let Some(rule) = self.rules.get(name) {
|
||||
if target.padding.is_none() {
|
||||
target.padding = rule.padding;
|
||||
}
|
||||
if target.margin.is_none() {
|
||||
target.margin = rule.margin;
|
||||
}
|
||||
if target.background.is_none() {
|
||||
target.background = rule.background.clone();
|
||||
}
|
||||
if target.foreground.is_none() {
|
||||
target.foreground = rule.foreground.clone();
|
||||
}
|
||||
if target.font.is_none() {
|
||||
target.font = rule.font.clone();
|
||||
}
|
||||
if target.radius.is_none() {
|
||||
target.radius = rule.radius;
|
||||
}
|
||||
if target.shadow.is_none() {
|
||||
target.shadow = rule.shadow;
|
||||
}
|
||||
if target.opacity.is_none() {
|
||||
target.opacity = rule.opacity;
|
||||
}
|
||||
if target.border_width.is_none() {
|
||||
target.border_width = rule.border_width;
|
||||
}
|
||||
if target.border_color.is_none() {
|
||||
target.border_color = rule.border_color.clone();
|
||||
}
|
||||
if target.width.is_none() {
|
||||
target.width = rule.width.clone();
|
||||
}
|
||||
if target.height.is_none() {
|
||||
target.height = rule.height.clone();
|
||||
}
|
||||
if target.max_width.is_none() {
|
||||
target.max_width = rule.max_width.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of rules defined.
|
||||
pub fn len(&self) -> usize {
|
||||
self.rules.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::color::Color;
|
||||
use crate::modifier::StyleSet;
|
||||
|
||||
fn card_style() -> StyleSet {
|
||||
let mut s = StyleSet::default();
|
||||
s.padding = Some([16; 4]);
|
||||
s.radius = Some([8; 4]);
|
||||
s.background = Some(Color::Surface);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_define_and_get() {
|
||||
let mut ss = StyleSheet::new();
|
||||
ss.define("card", card_style());
|
||||
assert!(ss.get("card").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_missing_rule_returns_none() {
|
||||
let ss = StyleSheet::new();
|
||||
assert!(ss.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_apply_fills_missing_values() {
|
||||
let mut ss = StyleSheet::new();
|
||||
ss.define("card", card_style());
|
||||
|
||||
let mut target = StyleSet::default();
|
||||
ss.apply("card", &mut target);
|
||||
|
||||
assert_eq!(target.padding, Some([16; 4]));
|
||||
assert_eq!(target.background, Some(Color::Surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_apply_does_not_overwrite_existing() {
|
||||
let mut ss = StyleSheet::new();
|
||||
ss.define("card", card_style());
|
||||
|
||||
let mut target = StyleSet::default();
|
||||
target.background = Some(Color::Primary); // already set
|
||||
ss.apply("card", &mut target);
|
||||
|
||||
// Should NOT be overwritten by the stylesheet's Surface
|
||||
assert_eq!(target.background, Some(Color::Primary));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_len() {
|
||||
let mut ss = StyleSheet::new();
|
||||
assert_eq!(ss.len(), 0);
|
||||
ss.define("card", card_style());
|
||||
assert_eq!(ss.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_apply_nonexistent_is_noop() {
|
||||
let ss = StyleSheet::new();
|
||||
let mut target = StyleSet::default();
|
||||
ss.apply("does-not-exist", &mut target); // should not panic
|
||||
assert!(target.padding.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/// Theme — the single source of truth for all visual values.
|
||||
///
|
||||
/// Components never hardcode colors, sizes, or shadows. They reference
|
||||
/// semantic tokens; the Theme resolves them. Swap the theme node at the
|
||||
/// root of the experience and every component updates automatically.
|
||||
|
||||
use crate::color::ColorScheme;
|
||||
use crate::radius::RadiusScale;
|
||||
use crate::shadow::ShadowScale;
|
||||
use crate::spacing::SpacingScale;
|
||||
use crate::typography::TypographyScheme;
|
||||
|
||||
/// Whether the theme follows the OS preference or is explicitly light/dark.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ThemeMode {
|
||||
/// Explicit light mode.
|
||||
Light,
|
||||
/// Explicit dark mode.
|
||||
Dark,
|
||||
/// Follow the OS preference (default).
|
||||
System,
|
||||
}
|
||||
|
||||
/// The complete design system for an experience.
|
||||
///
|
||||
/// One Theme per experience, flowing down through the component tree.
|
||||
/// Components read from it; they do not hold their own style values.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
pub mode: ThemeMode,
|
||||
pub colors: ColorScheme,
|
||||
pub typography: TypographyScheme,
|
||||
pub spacing: SpacingScale,
|
||||
pub radius: RadiusScale,
|
||||
pub shadows: ShadowScale,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The default light theme.
|
||||
pub fn default_light() -> Self {
|
||||
Self {
|
||||
mode: ThemeMode::Light,
|
||||
colors: ColorScheme::light(),
|
||||
typography: TypographyScheme::default(),
|
||||
spacing: SpacingScale::default(),
|
||||
radius: RadiusScale::default(),
|
||||
shadows: ShadowScale::light(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The default dark theme.
|
||||
pub fn default_dark() -> Self {
|
||||
Self {
|
||||
mode: ThemeMode::Dark,
|
||||
colors: ColorScheme::dark(),
|
||||
typography: TypographyScheme::default(),
|
||||
spacing: SpacingScale::default(),
|
||||
radius: RadiusScale::default(),
|
||||
shadows: ShadowScale::dark(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A system-following theme (uses light values as the base;
|
||||
/// the runtime swaps to dark when the OS signals dark mode).
|
||||
pub fn system() -> Self {
|
||||
Self {
|
||||
mode: ThemeMode::System,
|
||||
colors: ColorScheme::light(),
|
||||
typography: TypographyScheme::default(),
|
||||
spacing: SpacingScale::default(),
|
||||
radius: RadiusScale::default(),
|
||||
shadows: ShadowScale::light(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a copy of this theme in the specified mode,
|
||||
/// updating colors and shadows to match.
|
||||
pub fn with_mode(&self, mode: ThemeMode) -> Self {
|
||||
let (colors, shadows) = match &mode {
|
||||
ThemeMode::Dark => (ColorScheme::dark(), ShadowScale::dark()),
|
||||
ThemeMode::Light | ThemeMode::System => {
|
||||
(ColorScheme::light(), ShadowScale::light())
|
||||
}
|
||||
};
|
||||
Self {
|
||||
mode,
|
||||
colors,
|
||||
shadows,
|
||||
typography: self.typography.clone(),
|
||||
spacing: self.spacing.clone(),
|
||||
radius: self.radius.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether dark mode is currently active.
|
||||
pub fn is_dark(&self) -> bool {
|
||||
matches!(self.mode, ThemeMode::Dark)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::color::Color;
|
||||
|
||||
#[test]
|
||||
fn light_theme_not_dark() {
|
||||
assert!(!Theme::default_light().is_dark());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_is_dark() {
|
||||
assert!(Theme::default_dark().is_dark());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_theme_mode() {
|
||||
assert_eq!(Theme::system().mode, ThemeMode::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_mode_switches_colors() {
|
||||
let light = Theme::default_light();
|
||||
let dark = light.with_mode(ThemeMode::Dark);
|
||||
let (_, _, _, _la) = light.colors.resolve(&Color::Background);
|
||||
let (r, g, b, _) = dark.colors.resolve(&Color::Background);
|
||||
// Dark background should be dark (low values)
|
||||
assert!(r < 50 && g < 50 && b < 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_spacing_resolves() {
|
||||
use crate::spacing::Spacing;
|
||||
let theme = Theme::default_light();
|
||||
assert_eq!(theme.spacing.resolve(&Spacing::Lg), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_radius_resolves() {
|
||||
use crate::radius::Radius;
|
||||
let theme = Theme::default_light();
|
||||
assert_eq!(theme.radius.resolve(&Radius::Md), 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/// Typography scale — named text styles with semantic meaning.
|
||||
///
|
||||
/// Don't hardcode font sizes. Use the named scale. The theme maps each
|
||||
/// style to the right size, weight, and line height for the platform.
|
||||
|
||||
/// Named text style levels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum TextStyle {
|
||||
/// Largest — hero headings, splash screens.
|
||||
Display,
|
||||
/// Page-level headings.
|
||||
Headline,
|
||||
/// Section headings, dialog titles.
|
||||
Title,
|
||||
/// Default readable body copy.
|
||||
Body,
|
||||
/// UI labels, button text, captions.
|
||||
Label,
|
||||
/// Monospaced — code, terminal output.
|
||||
Code,
|
||||
/// Fine print, footnotes, timestamps.
|
||||
Caption,
|
||||
}
|
||||
|
||||
/// Font weight.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FontWeight {
|
||||
Thin, // 100
|
||||
Light, // 300
|
||||
Regular, // 400
|
||||
Medium, // 500
|
||||
SemiBold, // 600
|
||||
Bold, // 700
|
||||
ExtraBold, // 800
|
||||
Black, // 900
|
||||
}
|
||||
|
||||
impl FontWeight {
|
||||
/// Numeric CSS/platform font weight value.
|
||||
pub fn value(&self) -> u32 {
|
||||
match self {
|
||||
FontWeight::Thin => 100,
|
||||
FontWeight::Light => 300,
|
||||
FontWeight::Regular => 400,
|
||||
FontWeight::Medium => 500,
|
||||
FontWeight::SemiBold => 600,
|
||||
FontWeight::Bold => 700,
|
||||
FontWeight::ExtraBold => 800,
|
||||
FontWeight::Black => 900,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text alignment — logical (RTL-aware), not physical.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TextAlign {
|
||||
/// Aligns to the start of reading direction (left in LTR, right in RTL).
|
||||
Start,
|
||||
/// Center.
|
||||
Center,
|
||||
/// Aligns to the end of reading direction.
|
||||
End,
|
||||
/// Justify.
|
||||
Justify,
|
||||
}
|
||||
|
||||
/// Text decoration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TextDecoration {
|
||||
None,
|
||||
Underline,
|
||||
LineThrough,
|
||||
Overline,
|
||||
}
|
||||
|
||||
/// Overflow behavior for text that doesn't fit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TextOverflow {
|
||||
Clip,
|
||||
Ellipsis,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
/// Fully-resolved spec for a single text style level.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextSpec {
|
||||
/// Font size in density-independent pixels.
|
||||
pub size: f32,
|
||||
/// Line height multiplier (1.5 = 150% of font size).
|
||||
pub line_height: f32,
|
||||
/// Letter spacing in em units.
|
||||
pub letter_spacing: f32,
|
||||
pub weight: FontWeight,
|
||||
}
|
||||
|
||||
/// Maps each TextStyle level to a concrete TextSpec.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TypographyScheme {
|
||||
pub display: TextSpec,
|
||||
pub headline: TextSpec,
|
||||
pub title: TextSpec,
|
||||
pub body: TextSpec,
|
||||
pub label: TextSpec,
|
||||
pub code: TextSpec,
|
||||
pub caption: TextSpec,
|
||||
}
|
||||
|
||||
impl TypographyScheme {
|
||||
/// Default typography scale (4px base grid, 16px body).
|
||||
pub fn default() -> Self {
|
||||
Self {
|
||||
display: TextSpec {
|
||||
size: 57.0,
|
||||
line_height: 1.12,
|
||||
letter_spacing: -0.025,
|
||||
weight: FontWeight::Regular,
|
||||
},
|
||||
headline: TextSpec {
|
||||
size: 32.0,
|
||||
line_height: 1.25,
|
||||
letter_spacing: -0.015,
|
||||
weight: FontWeight::SemiBold,
|
||||
},
|
||||
title: TextSpec {
|
||||
size: 22.0,
|
||||
line_height: 1.3,
|
||||
letter_spacing: -0.01,
|
||||
weight: FontWeight::SemiBold,
|
||||
},
|
||||
body: TextSpec {
|
||||
size: 16.0,
|
||||
line_height: 1.5,
|
||||
letter_spacing: 0.0,
|
||||
weight: FontWeight::Regular,
|
||||
},
|
||||
label: TextSpec {
|
||||
size: 14.0,
|
||||
line_height: 1.4,
|
||||
letter_spacing: 0.005,
|
||||
weight: FontWeight::Medium,
|
||||
},
|
||||
code: TextSpec {
|
||||
size: 14.0,
|
||||
line_height: 1.6,
|
||||
letter_spacing: 0.0,
|
||||
weight: FontWeight::Regular,
|
||||
},
|
||||
caption: TextSpec {
|
||||
size: 12.0,
|
||||
line_height: 1.33,
|
||||
letter_spacing: 0.01,
|
||||
weight: FontWeight::Regular,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a TextStyle variant to its TextSpec.
|
||||
pub fn resolve(&self, style: &TextStyle) -> &TextSpec {
|
||||
match style {
|
||||
TextStyle::Display => &self.display,
|
||||
TextStyle::Headline => &self.headline,
|
||||
TextStyle::Title => &self.title,
|
||||
TextStyle::Body => &self.body,
|
||||
TextStyle::Label => &self.label,
|
||||
TextStyle::Code => &self.code,
|
||||
TextStyle::Caption => &self.caption,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn typography_scheme_body_size() {
|
||||
let scheme = TypographyScheme::default();
|
||||
let spec = scheme.resolve(&TextStyle::Body);
|
||||
assert_eq!(spec.size, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_scheme_display_largest() {
|
||||
let scheme = TypographyScheme::default();
|
||||
let display = scheme.resolve(&TextStyle::Display);
|
||||
let caption = scheme.resolve(&TextStyle::Caption);
|
||||
assert!(display.size > caption.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_weight_values() {
|
||||
assert_eq!(FontWeight::Regular.value(), 400);
|
||||
assert_eq!(FontWeight::Bold.value(), 700);
|
||||
assert_eq!(FontWeight::SemiBold.value(), 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_minimum_size() {
|
||||
// All text styles must be >= 11pt (accessibility minimum)
|
||||
let scheme = TypographyScheme::default();
|
||||
for style in [
|
||||
TextStyle::Display,
|
||||
TextStyle::Headline,
|
||||
TextStyle::Title,
|
||||
TextStyle::Body,
|
||||
TextStyle::Label,
|
||||
TextStyle::Code,
|
||||
TextStyle::Caption,
|
||||
] {
|
||||
let spec = scheme.resolve(&style);
|
||||
assert!(
|
||||
spec.size >= 11.0,
|
||||
"{:?} size {} is below 11pt minimum",
|
||||
style,
|
||||
spec.size
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_code_is_readable_size() {
|
||||
let scheme = TypographyScheme::default();
|
||||
let spec = scheme.resolve(&TextStyle::Code);
|
||||
assert!(spec.size >= 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_line_heights_positive() {
|
||||
let scheme = TypographyScheme::default();
|
||||
for style in [TextStyle::Body, TextStyle::Caption, TextStyle::Title] {
|
||||
let spec = scheme.resolve(&style);
|
||||
assert!(spec.line_height > 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user