use uzor::fonts::FontFamily;
use uzor_figures::FigureTheme;
use uzor_text::FontSpec;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontFileRef {
pub family: FontFamily,
pub bold: bool,
pub italic: bool,
}
impl FontFileRef {
pub fn new(family: FontFamily, bold: bool, italic: bool) -> Self {
Self { family, bold, italic }
}
}
#[derive(Debug, Clone)]
pub struct BrandTokens {
pub ink: u32,
pub muted: u32,
pub accent: u32,
pub background: u32,
pub categorical_palette: Vec<u32>,
pub font_files: Vec<FontFileRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontRole {
Heading,
Body,
Caption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ColorRole {
Ink,
Muted,
Accent,
Background,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DesignTokens {
pub heading_font: usize,
pub heading_size_px: f64,
pub body_font: usize,
pub body_size_px: f64,
pub caption_font: usize,
pub caption_size_px: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ResolvedFontRole {
file: FontFileRef,
size_px: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FigureThemeTokens {
pub background: ColorRole,
pub axis_color: ColorRole,
pub grid_color: ColorRole,
pub label_color: ColorRole,
pub label_font: FontRole,
pub label_size_px: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextStyle {
pub font: FontRole,
pub size_px: f64,
pub color: ColorRole,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentStyle {
pub figure_theme: FigureThemeTokens,
pub table_header: TextStyle,
}
#[derive(Debug, Clone)]
pub struct Theme {
pub brand: BrandTokens,
pub design: DesignTokens,
pub components: ComponentStyle,
}
const FALLBACK_FONT: FontFileRef = FontFileRef { family: FontFamily::Roboto, bold: false, italic: false };
fn font_family_name(family: FontFamily) -> &'static str {
match family {
FontFamily::Roboto => "Roboto",
FontFamily::PtRootUi => "PT Root UI",
FontFamily::JetBrainsMono => "JetBrains Mono",
}
}
impl Theme {
fn resolve_font_role(&self, role: FontRole) -> ResolvedFontRole {
let (idx, size_px) = match role {
FontRole::Heading => (self.design.heading_font, self.design.heading_size_px),
FontRole::Body => (self.design.body_font, self.design.body_size_px),
FontRole::Caption => (self.design.caption_font, self.design.caption_size_px),
};
let file = self.brand.font_files.get(idx).copied().unwrap_or(FALLBACK_FONT);
ResolvedFontRole { file, size_px }
}
pub fn color_rgb(&self, role: ColorRole) -> u32 {
match role {
ColorRole::Ink => self.brand.ink,
ColorRole::Muted => self.brand.muted,
ColorRole::Accent => self.brand.accent,
ColorRole::Background => self.brand.background,
}
}
pub fn color_hex(&self, role: ColorRole) -> String {
format!("#{:06x}", self.color_rgb(role) & 0xff_ffff)
}
pub fn font_spec(&self, role: FontRole) -> FontSpec {
let resolved = self.resolve_font_role(role);
let mut spec = FontSpec::new(resolved.file.family, resolved.size_px);
if resolved.file.bold {
spec = spec.bold();
}
if resolved.file.italic {
spec = spec.italic();
}
spec
}
pub fn figure_theme(&self) -> FigureTheme {
let tokens = &self.components.figure_theme;
let label = self.resolve_font_role(tokens.label_font);
let mut label_spec = FontSpec::new(label.file.family, tokens.label_size_px);
if label.file.bold {
label_spec = label_spec.bold();
}
if label.file.italic {
label_spec = label_spec.italic();
}
FigureTheme {
background: self.color_hex(tokens.background),
axis_color: self.color_hex(tokens.axis_color),
grid_color: self.color_hex(tokens.grid_color),
label_color: self.color_hex(tokens.label_color),
label_font: label_spec.to_css_font(),
label_font_family: font_family_name(label.file.family).to_owned(),
palette: self.brand.categorical_palette.iter().map(|c| format!("#{:06x}", c & 0xff_ffff)).collect(),
positive: self.brand.categorical_palette.get(2).map(|c| format!("#{:06x}", c & 0xff_ffff)).unwrap_or_else(|| "#5cb87a".to_owned()),
negative: self.brand.categorical_palette.get(7).map(|c| format!("#{:06x}", c & 0xff_ffff)).unwrap_or_else(|| "#e0555a".to_owned()),
highlight: self.color_hex(ColorRole::Accent),
}
}
fn shared_categorical_palette() -> Vec<u32> {
vec![0x4d90fe, 0xe0703c, 0x5cb87a, 0xc94f7c, 0xd9b64e, 0x7e6bd9, 0x3fb6c9, 0xe0555a, 0x8fbf5f, 0xc78bd9]
}
fn shared_design_tokens() -> DesignTokens {
DesignTokens {
heading_font: 0,
heading_size_px: 22.0,
body_font: 1,
body_size_px: 14.0,
caption_font: 1,
caption_size_px: 11.0,
}
}
fn shared_font_files() -> Vec<FontFileRef> {
vec![FontFileRef::new(FontFamily::Roboto, true, false), FontFileRef::new(FontFamily::Roboto, false, false)]
}
fn shared_components() -> ComponentStyle {
ComponentStyle {
figure_theme: FigureThemeTokens {
background: ColorRole::Background,
axis_color: ColorRole::Muted,
grid_color: ColorRole::Muted,
label_color: ColorRole::Ink,
label_font: FontRole::Caption,
label_size_px: 11.0,
},
table_header: TextStyle { font: FontRole::Heading, size_px: 13.0, color: ColorRole::Ink },
}
}
pub fn light_report() -> Self {
Self {
brand: BrandTokens {
ink: 0x111111,
muted: 0x4a5060,
accent: 0x0b5fff,
background: 0xffffff,
categorical_palette: Self::shared_categorical_palette(),
font_files: Self::shared_font_files(),
},
design: Self::shared_design_tokens(),
components: Self::shared_components(),
}
}
pub fn dark_deck() -> Self {
Self {
brand: BrandTokens {
ink: 0xe9ecf5,
muted: 0x9aa0ac,
accent: 0x5cc9ff,
background: 0x14161c,
categorical_palette: Self::shared_categorical_palette(),
font_files: Self::shared_font_files(),
},
design: Self::shared_design_tokens(),
components: Self::shared_components(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn light_report_and_dark_deck_share_identical_font_sizes_but_different_colors() {
let light = Theme::light_report();
let dark = Theme::dark_deck();
assert_eq!(light.design.heading_size_px, dark.design.heading_size_px);
assert_eq!(light.design.body_size_px, dark.design.body_size_px);
assert_eq!(
light.font_spec(FontRole::Body).size_px,
dark.font_spec(FontRole::Body).size_px,
"a theme must never change layout-affecting metrics on its own"
);
assert_ne!(light.color_hex(ColorRole::Background), dark.color_hex(ColorRole::Background));
assert_ne!(light.color_hex(ColorRole::Ink), dark.color_hex(ColorRole::Ink));
}
#[test]
fn font_spec_resolves_bold_heading_and_regular_body_from_the_same_theme() {
let theme = Theme::light_report();
assert!(theme.font_spec(FontRole::Heading).bold);
assert!(!theme.font_spec(FontRole::Body).bold);
}
#[test]
fn color_hex_is_a_six_digit_css_hash_string() {
let theme = Theme::light_report();
let hex = theme.color_hex(ColorRole::Ink);
assert_eq!(hex.len(), 7);
assert!(hex.starts_with('#'));
assert_eq!(hex, "#111111");
}
#[test]
fn figure_theme_palette_length_matches_brand_categorical_palette_length() {
let theme = Theme::light_report();
assert_eq!(theme.figure_theme().palette.len(), theme.brand.categorical_palette.len());
}
#[test]
fn figure_theme_never_constructs_its_own_colors_it_always_resolves_through_the_theme() {
let light = Theme::light_report();
let dark = Theme::dark_deck();
assert_ne!(light.figure_theme().background, dark.figure_theme().background, "figure theme must follow the page theme");
}
#[test]
fn an_out_of_bounds_design_token_font_index_falls_back_rather_than_panicking() {
let mut theme = Theme::light_report();
theme.design.body_font = 99; let spec = theme.font_spec(FontRole::Body); assert_eq!(spec.family, FontFamily::Roboto);
}
#[test]
fn figure_theme_label_font_family_is_bare_never_carries_a_size_token() {
let theme = Theme::light_report();
let figure_theme = theme.figure_theme();
assert!(!figure_theme.label_font_family.contains("px"), "label_font_family must be a bare family name, got {}", figure_theme.label_font_family);
assert!(figure_theme.label_font.ends_with(&figure_theme.label_font_family), "label_font's own composed string must end with the same bare family");
}
#[test]
fn figure_theme_highlight_resolves_through_the_theme_never_a_hardcoded_literal() {
let light = Theme::light_report();
let dark = Theme::dark_deck();
assert_eq!(light.figure_theme().highlight, light.color_hex(ColorRole::Accent));
assert_eq!(dark.figure_theme().highlight, dark.color_hex(ColorRole::Accent));
}
}