feat: port el-ui vessels — rename crates→vessels, add El source + manifests

This commit is contained in:
Will Anderson
2026-05-05 04:19:22 -05:00
parent b580a63540
commit faee6fdb25
145 changed files with 4050 additions and 12 deletions
+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::*;
+287
View File
@@ -0,0 +1,287 @@
// el-style The el-ui design system.
//
// Hierarchy:
// 1. Theme single source of truth, set at experience root
// 2. Semantic tokens Color::Primary, Spacing::Lg, Radius::Md, ...
// 3. StyleModifier fluent builder applied to any component
// 4. StyleSheet named rule sets, applied with .apply("card")
//
// Resolution flow:
// component declares semantic token theme resolves to RGBA / px
// compiler emits CSS-in-JS for the browser target / NSColor for macOS / ...
// Color tokens
let COLOR_PRIMARY: String = "primary"
let COLOR_ON_PRIMARY: String = "on_primary"
let COLOR_PRIMARY_CONTAINER: String = "primary_container"
let COLOR_SECONDARY: String = "secondary"
let COLOR_ON_SECONDARY: String = "on_secondary"
let COLOR_BACKGROUND: String = "background"
let COLOR_ON_BACKGROUND: String = "on_background"
let COLOR_SURFACE: String = "surface"
let COLOR_ON_SURFACE: String = "on_surface"
let COLOR_SURFACE_VARIANT: String = "surface_variant"
let COLOR_ERROR: String = "error"
let COLOR_ON_ERROR: String = "on_error"
let COLOR_OUTLINE: String = "outline"
let COLOR_OUTLINE_VARIANT: String = "outline_variant"
// A resolved RGBA quadruple, encoded as JSON for transport across the FFI.
type Rgba {
r: Int // 0-255
g: Int // 0-255
b: Int // 0-255
a: Int // 0-255 (we store alpha *256 to keep everything integer in El today)
}
fn rgba(r: Int, g: Int, b: Int, a: Int) -> Rgba {
{ "r": r, "g": g, "b": b, "a": a }
}
fn rgba_to_css(c: Rgba) -> String {
"rgba(" + int_to_str(c.r) + "," + int_to_str(c.g) + "," + int_to_str(c.b)
+ "," + int_to_str(c.a) + ")"
}
fn parse_hex(hex: String) -> Rgba {
let s: String = hex
if str_starts_with(s, "#") { let s = str_slice(s, 1, str_len(s)) }
let r: Int = hex_to_int(str_slice(s, 0, 2))
let g: Int = hex_to_int(str_slice(s, 2, 4))
let b: Int = hex_to_int(str_slice(s, 4, 6))
let a: Int = 255
if str_len(s) > 6 { let a = hex_to_int(str_slice(s, 6, 8)) }
{ "r": r, "g": g, "b": b, "a": a }
}
// ColorScheme: semantic token -> Rgba
type ColorScheme {
primary: String // hex
on_primary: String
primary_container: String
secondary: String
on_secondary: String
background: String
on_background: String
surface: String
on_surface: String
surface_variant: String
error: String
on_error: String
outline: String
outline_variant: String
}
fn color_scheme_light() -> ColorScheme {
{ "primary": "#3b82f6", "on_primary": "#ffffff", "primary_container": "#dbeafe",
"secondary": "#a855f7", "on_secondary": "#ffffff",
"background": "#ffffff", "on_background": "#0f172a",
"surface": "#f8fafc", "on_surface": "#0f172a", "surface_variant": "#e2e8f0",
"error": "#dc2626", "on_error": "#ffffff",
"outline": "#cbd5e1", "outline_variant": "#e2e8f0" }
}
fn color_scheme_dark() -> ColorScheme {
{ "primary": "#60a5fa", "on_primary": "#0f172a", "primary_container": "#1e3a8a",
"secondary": "#c084fc", "on_secondary": "#0f172a",
"background": "#0f172a", "on_background": "#f8fafc",
"surface": "#1e293b", "on_surface": "#f8fafc", "surface_variant": "#334155",
"error": "#f87171", "on_error": "#0f172a",
"outline": "#475569", "outline_variant": "#334155" }
}
// Resolve a semantic color token to its hex value in the active scheme.
fn resolve_color(scheme: ColorScheme, token: String) -> String {
if str_eq(token, "primary") { return scheme.primary }
if str_eq(token, "on_primary") { return scheme.on_primary }
if str_eq(token, "primary_container") { return scheme.primary_container }
if str_eq(token, "secondary") { return scheme.secondary }
if str_eq(token, "on_secondary") { return scheme.on_secondary }
if str_eq(token, "background") { return scheme.background }
if str_eq(token, "on_background") { return scheme.on_background }
if str_eq(token, "surface") { return scheme.surface }
if str_eq(token, "on_surface") { return scheme.on_surface }
if str_eq(token, "surface_variant") { return scheme.surface_variant }
if str_eq(token, "error") { return scheme.error }
if str_eq(token, "on_error") { return scheme.on_error }
if str_eq(token, "outline") { return scheme.outline }
if str_eq(token, "outline_variant") { return scheme.outline_variant }
"#000000"
}
// Spacing scale
type SpacingScale {
xs: Int
sm: Int
md: Int
lg: Int
xl: Int
xxl: Int
}
fn spacing_default() -> SpacingScale {
{ "xs": 4, "sm": 8, "md": 16, "lg": 24, "xl": 32, "xxl": 48 }
}
fn resolve_spacing(s: SpacingScale, token: String) -> Int {
if str_eq(token, "xs") { return s.xs }
if str_eq(token, "sm") { return s.sm }
if str_eq(token, "md") { return s.md }
if str_eq(token, "lg") { return s.lg }
if str_eq(token, "xl") { return s.xl }
if str_eq(token, "xxl") { return s.xxl }
s.md
}
// Radius / shadow
type RadiusScale {
none: Int
sm: Int
md: Int
lg: Int
full: Int
}
fn radius_default() -> RadiusScale {
{ "none": 0, "sm": 4, "md": 8, "lg": 16, "full": 9999 }
}
type ShadowScale {
sm: String // CSS box-shadow string
md: String
lg: String
}
fn shadow_light() -> ShadowScale {
{ "sm": "0 1px 2px rgba(0,0,0,0.05)",
"md": "0 4px 6px rgba(0,0,0,0.1)",
"lg": "0 10px 15px rgba(0,0,0,0.1)" }
}
fn shadow_dark() -> ShadowScale {
{ "sm": "0 1px 2px rgba(0,0,0,0.4)",
"md": "0 4px 6px rgba(0,0,0,0.5)",
"lg": "0 10px 15px rgba(0,0,0,0.6)" }
}
// Typography
type TextStyle {
font_family: String
font_size: Int
font_weight: Int // 100..900
line_height: Int // px
letter_spacing: Int // hundredths of em
}
fn text_style_default() -> TextStyle {
{ "font_family": "system-ui, -apple-system, sans-serif",
"font_size": 16, "font_weight": 400, "line_height": 24, "letter_spacing": 0 }
}
// Theme
let THEME_MODE_LIGHT: String = "light"
let THEME_MODE_DARK: String = "dark"
let THEME_MODE_SYSTEM: String = "system"
type Theme {
mode: String // light | dark | system
colors: ColorScheme
spacing: SpacingScale
radius: RadiusScale
shadows: ShadowScale
typography: TextStyle
}
fn theme_default_light() -> Theme {
{ "mode": "light", "colors": color_scheme_light(), "spacing": spacing_default(),
"radius": radius_default(), "shadows": shadow_light(), "typography": text_style_default() }
}
fn theme_default_dark() -> Theme {
{ "mode": "dark", "colors": color_scheme_dark(), "spacing": spacing_default(),
"radius": radius_default(), "shadows": shadow_dark(), "typography": text_style_default() }
}
// StyleSheet named rule sets
//
// A StyleSheet is a JSON object mapping selector -> declarations.
// Components apply by name: element.apply("card")
fn stylesheet_new() -> String {
"{}"
}
fn stylesheet_define(sheet: String, name: String, css_decls: String) -> String {
json_set(sheet, name, css_decls)
}
fn stylesheet_resolve(sheet: String, name: String) -> String {
json_get(sheet, name)
}
// CSS emit
//
// Server-side render: theme CSS custom properties block.
// Emit as a <style> tag or inject into an existing stylesheet string.
fn theme_to_css_vars(theme: Theme) -> String {
let c: ColorScheme = theme.colors
let s: SpacingScale = theme.spacing
let r: RadiusScale = theme.radius
let sh: ShadowScale = theme.shadows
let ty: TextStyle = theme.typography
"--el-primary:" + c.primary + ";"
+ "--el-on-primary:" + c.on_primary + ";"
+ "--el-primary-container:" + c.primary_container + ";"
+ "--el-secondary:" + c.secondary + ";"
+ "--el-on-secondary:" + c.on_secondary + ";"
+ "--el-bg:" + c.background + ";"
+ "--el-on-bg:" + c.on_background + ";"
+ "--el-surface:" + c.surface + ";"
+ "--el-on-surface:" + c.on_surface + ";"
+ "--el-surface-variant:" + c.surface_variant + ";"
+ "--el-error:" + c.error + ";"
+ "--el-outline:" + c.outline + ";"
+ "--el-outline-variant:" + c.outline_variant + ";"
+ "--el-space-xs:" + int_to_str(s.xs) + "px;"
+ "--el-space-sm:" + int_to_str(s.sm) + "px;"
+ "--el-space-md:" + int_to_str(s.md) + "px;"
+ "--el-space-lg:" + int_to_str(s.lg) + "px;"
+ "--el-space-xl:" + int_to_str(s.xl) + "px;"
+ "--el-space-xxl:" + int_to_str(s.xxl) + "px;"
+ "--el-radius-none:" + int_to_str(r.none) + "px;"
+ "--el-radius-sm:" + int_to_str(r.sm) + "px;"
+ "--el-radius-md:" + int_to_str(r.md) + "px;"
+ "--el-radius-lg:" + int_to_str(r.lg) + "px;"
+ "--el-radius-full:" + int_to_str(r.full) + "px;"
+ "--el-shadow-sm:" + sh.sm + ";"
+ "--el-shadow-md:" + sh.md + ";"
+ "--el-shadow-lg:" + sh.lg + ";"
+ "--el-font-family:" + ty.font_family + ";"
+ "--el-font-size:" + int_to_str(ty.font_size) + "px;"
+ "--el-font-weight:" + int_to_str(ty.font_weight) + ";"
+ "--el-line-height:" + int_to_str(ty.line_height) + "px"
}
fn theme_to_style_tag(theme: Theme) -> String {
"<style>:root{" + theme_to_css_vars(theme) + "}</style>"
}
fn text_style_to_css(ts: TextStyle) -> String {
"font-family:" + ts.font_family
+ ";font-size:" + int_to_str(ts.font_size) + "px"
+ ";font-weight:" + int_to_str(ts.font_weight)
+ ";line-height:" + int_to_str(ts.line_height) + "px"
}
// Entry smoke test
let theme: Theme = theme_default_light()
println("[el-style] light primary = " + theme.colors.primary)
println("[el-style] css vars = " + theme_to_css_vars(theme))
+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);
}
}
}