Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "el-style"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui design system — theme-driven, platform-agnostic style representation"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_style"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,21 @@
|
||||
// el-style — The el-ui design system.
|
||||
//
|
||||
// Platform-agnostic, theme-driven style representation. Every value is a
|
||||
// semantic token — the theme maps tokens to actual colors, sizes, and shadows.
|
||||
// Components never hardcode visual values.
|
||||
|
||||
vessel "el-style" {
|
||||
version "0.1.0"
|
||||
description "Design tokens, themes, and the StyleModifier fluent API"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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,231 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let theme: Theme = theme_default_light()
|
||||
println("[el-style] light primary = " + theme.colors.primary)
|
||||
@@ -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