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:
Will Anderson
2026-04-30 18:18:39 -05:00
parent f09803c317
commit f4abfe6fdc
138 changed files with 5445 additions and 900 deletions
+17
View File
@@ -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]
+21
View File
@@ -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/"
}
+255
View File
@@ -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 0255, alpha 0.01.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);
}
}
+51
View File
@@ -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::*;
+231
View File
@@ -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)
+382
View File
@@ -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.01.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)));
}
}
+116
View File
@@ -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);
}
}
+190
View File
@@ -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);
}
}
+137
View File
@@ -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);
}
}
+160
View File
@@ -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());
}
}
+144
View File
@@ -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);
}
}
+235
View File
@@ -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);
}
}
}