pub const WIDGETS_COMPONENT_CSS: &str = include_str!("widgets.css");
pub const WIDGETS_CSS: &str = concat!(
include_str!("tokens.css"),
"\n",
include_str!("widgets.css")
);
pub const WIDGETS_CSS_PATH: &str = "/static/css/autumn-widgets.css";
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
#[test]
fn widgets_css_bundles_tokens_and_components() {
assert!(WIDGETS_CSS.contains(crate::ui::tokens::TOKENS_CSS));
assert!(WIDGETS_CSS.contains(WIDGETS_COMPONENT_CSS));
}
#[test]
fn component_css_references_tokens_not_hardcoded_colors() {
for token_var in ["var(--primary)", "var(--border)", "var(--radius)"] {
assert!(
WIDGETS_COMPONENT_CSS.contains(token_var),
"WIDGETS_COMPONENT_CSS should reference {token_var}"
);
}
}
#[test]
fn renamed_widget_classes_are_backed_by_component_css() {
fn has_rule(css: &str, class: &str) -> bool {
let needle = format!(".{class}");
css.match_indices(&needle).any(|(i, _)| {
matches!(
css[i + needle.len()..].chars().next(),
Some('{' | ' ' | ',' | ':' | '\n' | '\t')
)
})
}
for class in [
"autumn-card",
"autumn-card__header",
"autumn-card__title",
"autumn-card__body",
"autumn-card__footer",
"autumn-stat-card",
"autumn-stat-card__label",
"autumn-stat-card__value",
"autumn-stat-card__link",
"autumn-search-empty",
"autumn-autocomplete-empty",
"autumn-alert__icon-svg",
"autumn-active",
] {
assert!(
has_rule(WIDGETS_COMPONENT_CSS, class),
"widget emits .{class} but WIDGETS_COMPONENT_CSS has no rule for it"
);
}
}
#[test]
fn widget_focus_states_keep_an_outline_and_base_rules_have_none() {
assert!(
!WIDGETS_COMPONENT_CSS.contains("outline: none"),
"`outline: none` leaves no focus indicator under forced-colors mode"
);
for selector in [
".autumn-field__input:focus {",
".autumn-submit:focus {",
".autumn-search__input:focus {",
".autumn-autocomplete__input:focus {",
] {
let start = WIDGETS_COMPONENT_CSS
.find(selector)
.unwrap_or_else(|| panic!("missing `{selector}` rule"));
let end = WIDGETS_COMPONENT_CSS[start..].find('}').unwrap() + start;
assert!(
WIDGETS_COMPONENT_CSS[start..end].contains("outline: 2px solid transparent;"),
"`{selector}` must declare a transparent outline"
);
}
assert_eq!(
WIDGETS_COMPONENT_CSS
.matches("outline: 2px solid transparent;")
.count(),
4,
"the transparent outline belongs on the four :focus rules only"
);
}
fn luminance(hex: &str) -> f64 {
let channel = |i: usize| {
let c = f64::from(u8::from_str_radix(&hex[1 + i * 2..3 + i * 2], 16).unwrap()) / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.0722_f64.mul_add(
channel(2),
0.7152_f64.mul_add(channel(1), 0.2126 * channel(0)),
)
}
fn contrast(a: &str, b: &str) -> f64 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
fn resolve(value: &str) -> String {
let value = value.trim();
if let Some(args) = value
.strip_prefix("color-mix(in srgb,")
.and_then(|v| v.strip_suffix(')'))
{
let (first, second) = args.split_once("%,").unwrap();
let (a, pct) = first.trim().rsplit_once(' ').unwrap();
let weight = pct.parse::<u32>().unwrap();
let (a, b) = (resolve(a), resolve(second));
let channel =
|h: &str, i: usize| u32::from_str_radix(&h[1 + i * 2..3 + i * 2], 16).unwrap();
let mixed = (0..3).fold(String::from("#"), |mut out, i| {
let c = (channel(&a, i) * weight + channel(&b, i) * (100 - weight) + 50) / 100;
write!(out, "{c:02x}").unwrap();
out
});
return mixed;
}
let Some(inner) = value.strip_prefix("var(").and_then(|v| v.strip_suffix(')')) else {
return value.to_owned();
};
let (name, fallback) = inner
.split_once(',')
.map_or((inner, None), |(n, f)| (n, Some(f)));
let tokens = crate::ui::tokens::TOKENS_CSS;
let def = format!("{}:", name.trim());
tokens
.find(&def)
.map(|at| {
let rest = &tokens[at + def.len()..];
rest[..rest.find(';').unwrap()].trim().to_owned()
})
.or_else(|| fallback.map(resolve))
.unwrap_or_else(|| panic!("`{name}` is neither a token nor has a fallback"))
}
fn declared(selector: &str, prop: &str) -> String {
let at = WIDGETS_COMPONENT_CSS
.find(&format!("{selector} {{"))
.unwrap_or_else(|| panic!("missing `{selector}` rule"));
let body = &WIDGETS_COMPONENT_CSS[at..];
let body = &body[..body.find('}').unwrap()];
let line = body
.lines()
.filter_map(|l| l.trim().strip_prefix(&format!("{prop}:")))
.next_back()
.unwrap_or_else(|| panic!("`{selector}` has no `{prop}`"));
resolve(line.trim_end_matches(';'))
}
#[test]
fn status_chip_text_is_independent_of_the_theme_foreground() {
for selector in [
".badge--success",
".badge--warning",
".alert-success",
".alert-warning",
] {
let at = WIDGETS_COMPONENT_CSS
.find(&format!("{selector} {{"))
.unwrap();
let body = &WIDGETS_COMPONENT_CSS[at..];
let body = &body[..body.find('}').unwrap()];
assert!(!body.contains("var(--text"), "{selector} reads --text");
}
}
#[test]
fn widget_text_meets_aa_contrast_against_its_own_background() {
let surface = "var(--surface)";
let mut failures = Vec::new();
for (selector, bg) in [
(".badge--success", "background"),
(".badge--warning", "background"),
(".badge--danger", "background"),
(".alert-success", "background"),
(".alert-warning", "background"),
(".alert-error", "background"),
(".autumn-job-status__success", ""),
(".autumn-job-status__error", ""),
(
".wizard-step--upcoming .wizard-step__number",
"background-color",
),
] {
let bg = if bg.is_empty() {
resolve(surface)
} else {
declared(selector, bg)
};
let ratio = contrast(&declared(selector, "color"), &bg);
if ratio < 4.5 {
failures.push(format!("{selector}: {ratio:.2}:1"));
}
}
assert!(failures.is_empty(), "below 4.5:1 — {failures:?}");
}
}