Archived
f4abfe6fdc
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/.
256 lines
8.4 KiB
Rust
256 lines
8.4 KiB
Rust
/// Semantic color tokens and the Color type.
|
||
///
|
||
/// Prefer semantic tokens (Primary, Surface, etc.) over explicit values.
|
||
/// The theme maps tokens to actual colors — swap the theme, everything updates.
|
||
/// Use Hex/Rgba only as an escape hatch when you need a one-off value.
|
||
|
||
/// A color value — either a semantic token or an explicit value.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum Color {
|
||
// --- Semantic tokens (preferred) ---
|
||
/// Brand primary color. Use for key actions and highlights.
|
||
Primary,
|
||
/// Content drawn on top of Primary.
|
||
OnPrimary,
|
||
/// A less prominent container for primary-branded content.
|
||
PrimaryContainer,
|
||
|
||
/// Secondary accent color.
|
||
Secondary,
|
||
/// Content drawn on top of Secondary.
|
||
OnSecondary,
|
||
|
||
/// The page/screen background.
|
||
Background,
|
||
/// Content drawn on top of Background.
|
||
OnBackground,
|
||
|
||
/// Surface color for cards, sheets, dialogs.
|
||
Surface,
|
||
/// Content drawn on top of Surface.
|
||
OnSurface,
|
||
/// A surface variant, slightly different from Surface.
|
||
SurfaceVariant,
|
||
|
||
/// Error state color.
|
||
Error,
|
||
/// Content drawn on top of Error.
|
||
OnError,
|
||
|
||
/// Outline/border color.
|
||
Outline,
|
||
/// Muted outline (dividers, subtle borders).
|
||
OutlineVariant,
|
||
|
||
// --- Escape hatches ---
|
||
/// An explicit hex color string (e.g. "#3b82f6" or "#3b82f6ff").
|
||
Hex(String),
|
||
/// An explicit RGBA color (channels 0–255, alpha 0.0–1.0).
|
||
Rgba(u8, u8, u8, f32),
|
||
|
||
/// Apply opacity to any color.
|
||
Opacity(Box<Color>, f32),
|
||
}
|
||
|
||
impl Color {
|
||
/// Create an Opacity variant.
|
||
pub fn with_opacity(self, opacity: f32) -> Self {
|
||
Color::Opacity(Box::new(self), opacity.clamp(0.0, 1.0))
|
||
}
|
||
|
||
/// Convenience: fully transparent.
|
||
pub fn transparent() -> Self {
|
||
Color::Rgba(0, 0, 0, 0.0)
|
||
}
|
||
|
||
/// Convenience: pure white.
|
||
pub fn white() -> Self {
|
||
Color::Rgba(255, 255, 255, 1.0)
|
||
}
|
||
|
||
/// Convenience: pure black.
|
||
pub fn black() -> Self {
|
||
Color::Rgba(0, 0, 0, 1.0)
|
||
}
|
||
|
||
/// Resolve a hex string like "#rrggbb" or "#rrggbbaa" to Rgba.
|
||
/// Returns None if the string is not a valid hex color.
|
||
pub fn parse_hex(s: &str) -> Option<Self> {
|
||
let s = s.trim_start_matches('#');
|
||
match s.len() {
|
||
6 => {
|
||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||
Some(Color::Rgba(r, g, b, 1.0))
|
||
}
|
||
8 => {
|
||
let r = u8::from_str_radix(&s[0..2], 16).ok()?;
|
||
let g = u8::from_str_radix(&s[2..4], 16).ok()?;
|
||
let b = u8::from_str_radix(&s[4..6], 16).ok()?;
|
||
let a = u8::from_str_radix(&s[6..8], 16).ok()?;
|
||
Some(Color::Rgba(r, g, b, a as f32 / 255.0))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Maps semantic color tokens to actual RGBA values.
|
||
/// One ColorScheme per theme mode (light/dark).
|
||
#[derive(Debug, Clone)]
|
||
pub struct ColorScheme {
|
||
pub primary: (u8, u8, u8, f32),
|
||
pub on_primary: (u8, u8, u8, f32),
|
||
pub primary_container: (u8, u8, u8, f32),
|
||
pub secondary: (u8, u8, u8, f32),
|
||
pub on_secondary: (u8, u8, u8, f32),
|
||
pub background: (u8, u8, u8, f32),
|
||
pub on_background: (u8, u8, u8, f32),
|
||
pub surface: (u8, u8, u8, f32),
|
||
pub on_surface: (u8, u8, u8, f32),
|
||
pub surface_variant: (u8, u8, u8, f32),
|
||
pub error: (u8, u8, u8, f32),
|
||
pub on_error: (u8, u8, u8, f32),
|
||
pub outline: (u8, u8, u8, f32),
|
||
pub outline_variant: (u8, u8, u8, f32),
|
||
}
|
||
|
||
impl ColorScheme {
|
||
/// Default light color scheme.
|
||
pub fn light() -> Self {
|
||
Self {
|
||
primary: (59, 130, 246, 1.0), // blue-500
|
||
on_primary: (255, 255, 255, 1.0),
|
||
primary_container: (219, 234, 254, 1.0), // blue-100
|
||
secondary: (100, 116, 139, 1.0), // slate-500
|
||
on_secondary: (255, 255, 255, 1.0),
|
||
background: (255, 255, 255, 1.0),
|
||
on_background: (15, 23, 42, 1.0), // slate-900
|
||
surface: (248, 250, 252, 1.0), // slate-50
|
||
on_surface: (15, 23, 42, 1.0),
|
||
surface_variant: (241, 245, 249, 1.0), // slate-100
|
||
error: (239, 68, 68, 1.0), // red-500
|
||
on_error: (255, 255, 255, 1.0),
|
||
outline: (203, 213, 225, 1.0), // slate-300
|
||
outline_variant: (226, 232, 240, 1.0), // slate-200
|
||
}
|
||
}
|
||
|
||
/// Default dark color scheme.
|
||
pub fn dark() -> Self {
|
||
Self {
|
||
primary: (96, 165, 250, 1.0), // blue-400
|
||
on_primary: (15, 23, 42, 1.0),
|
||
primary_container: (30, 58, 138, 1.0), // blue-900
|
||
secondary: (148, 163, 184, 1.0), // slate-400
|
||
on_secondary: (15, 23, 42, 1.0),
|
||
background: (15, 23, 42, 1.0), // slate-900
|
||
on_background: (248, 250, 252, 1.0),
|
||
surface: (30, 41, 59, 1.0), // slate-800
|
||
on_surface: (248, 250, 252, 1.0),
|
||
surface_variant: (51, 65, 85, 1.0), // slate-700
|
||
error: (248, 113, 113, 1.0), // red-400
|
||
on_error: (15, 23, 42, 1.0),
|
||
outline: (71, 85, 105, 1.0), // slate-600
|
||
outline_variant: (51, 65, 85, 1.0), // slate-700
|
||
}
|
||
}
|
||
|
||
/// Resolve a semantic Color token to its RGBA tuple.
|
||
pub fn resolve(&self, color: &Color) -> (u8, u8, u8, f32) {
|
||
match color {
|
||
Color::Primary => self.primary,
|
||
Color::OnPrimary => self.on_primary,
|
||
Color::PrimaryContainer => self.primary_container,
|
||
Color::Secondary => self.secondary,
|
||
Color::OnSecondary => self.on_secondary,
|
||
Color::Background => self.background,
|
||
Color::OnBackground => self.on_background,
|
||
Color::Surface => self.surface,
|
||
Color::OnSurface => self.on_surface,
|
||
Color::SurfaceVariant => self.surface_variant,
|
||
Color::Error => self.error,
|
||
Color::OnError => self.on_error,
|
||
Color::Outline => self.outline,
|
||
Color::OutlineVariant => self.outline_variant,
|
||
Color::Hex(s) => Color::parse_hex(s)
|
||
.map(|c| self.resolve(&c))
|
||
.unwrap_or((0, 0, 0, 1.0)),
|
||
Color::Rgba(r, g, b, a) => (*r, *g, *b, *a),
|
||
Color::Opacity(inner, opacity) => {
|
||
let (r, g, b, a) = self.resolve(inner);
|
||
(r, g, b, a * opacity)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parse_hex_6_chars() {
|
||
let c = Color::parse_hex("#3b82f6").unwrap();
|
||
assert_eq!(c, Color::Rgba(0x3b, 0x82, 0xf6, 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_hex_8_chars() {
|
||
let c = Color::parse_hex("#3b82f680").unwrap();
|
||
if let Color::Rgba(r, g, b, a) = c {
|
||
assert_eq!(r, 0x3b);
|
||
assert_eq!(g, 0x82);
|
||
assert_eq!(b, 0xf6);
|
||
assert!((a - 0x80 as f32 / 255.0).abs() < 0.01);
|
||
} else {
|
||
panic!("expected Rgba");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_hex_invalid() {
|
||
assert!(Color::parse_hex("not-a-color").is_none());
|
||
assert!(Color::parse_hex("#gg0000").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn with_opacity() {
|
||
let c = Color::Primary.with_opacity(0.5);
|
||
assert!(matches!(c, Color::Opacity(_, _)));
|
||
}
|
||
|
||
#[test]
|
||
fn color_scheme_light_resolves_primary() {
|
||
let scheme = ColorScheme::light();
|
||
let (r, g, b, a) = scheme.resolve(&Color::Primary);
|
||
assert_eq!((r, g, b), (59, 130, 246));
|
||
assert!((a - 1.0).abs() < 0.001);
|
||
}
|
||
|
||
#[test]
|
||
fn color_scheme_dark_resolves_background() {
|
||
let scheme = ColorScheme::dark();
|
||
let (r, g, b, _) = scheme.resolve(&Color::Background);
|
||
assert_eq!((r, g, b), (15, 23, 42));
|
||
}
|
||
|
||
#[test]
|
||
fn opacity_modifies_alpha() {
|
||
let scheme = ColorScheme::light();
|
||
let color = Color::Primary.with_opacity(0.5);
|
||
let (_, _, _, a) = scheme.resolve(&color);
|
||
assert!((a - 0.5).abs() < 0.001);
|
||
}
|
||
|
||
#[test]
|
||
fn rgba_passthrough() {
|
||
let scheme = ColorScheme::light();
|
||
let color = Color::Rgba(10, 20, 30, 0.8);
|
||
let (r, g, b, a) = scheme.resolve(&color);
|
||
assert_eq!((r, g, b), (10, 20, 30));
|
||
assert!((a - 0.8).abs() < 0.001);
|
||
}
|
||
}
|