Files
pbakaus_impeccable/crates/core/src/checks/rules.rs
T
cb56ed6c19 Fix: detect placeholder contrast (#790) (#799)
* Fix: detect placeholder contrast (#790)

`detect` never read `::placeholder` color, so pale placeholders passed. Score them with the same WCAG math as body text, without host class/clip heuristics.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: match descendant ::placeholder hosts (#790)

`.form ::placeholder` kept the ancestor as the host. Reuse the hover combinator star-fill so the color lands on the inputs inside.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: placeholder-shown and gradient alpha (#790)

Browser scans skip when :placeholder-shown is false, so a live filled field does not keep the HTML value attribute's empty state. Translucent placeholders flatten over each gradient stop before scoring.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: trailing combinator only for ::placeholder hosts (#790)

`star_empty_compounds` turned `.label + ::placeholder` into `.label *+*`. Fill only a trailing empty compound so adjacent-sibling hosts still match.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 13:49:59 -07:00

1148 lines
39 KiB
Rust

//! Port of `cli/engine/rules/checks.mjs` Section 3: the pure element checks
//! and their helpers. Every function keeps the JS name in its doc comment;
//! opts objects become structs whose `Option` fields mirror the JS
//! `undefined` / `null` distinctions the source relies on.
use crate::color::{
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
relative_luminance, Rgba,
};
use crate::constants::{
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
WCAG_LARGE_TEXT_PX,
};
use crate::js::{
self, ci, math_max, math_round, number_to_string, parse_float, parse_int, string_to_number,
to_fixed, WS,
};
use crate::js_ext_a::{num_truthy, slice_utf16_start, split_commas_outside_parens, utf16_length};
use once_cell::sync::Lazy;
use regex::Regex;
/// The hit and option structs these checks are written against are shared;
/// re-exported so `checks::rules` stays one path.
pub use impeccable_foundation::rules::types::*;
macro_rules! re {
($name:ident, $pat:expr) => {
static $name: Lazy<Regex> = Lazy::new(|| Regex::new(&$pat).expect(stringify!($name)));
};
}
const SIDE_NAMES: [&str; 4] = ["Top", "Right", "Bottom", "Left"];
/// JS: checks.mjs#checkBorders
pub fn check_borders(
tag: &str,
widths: &Sides<f64>,
colors: &Sides<Option<&str>>,
radius: f64,
opts: &BorderOpts,
) -> Vec<RuleHit> {
let span_badge = tag == "span" && opts.badge_like;
if set_has(BORDER_SAFE_TAGS, tag) && !span_badge {
return Vec::new();
}
if opts.status_context {
return Vec::new();
}
let mut findings = Vec::new();
for i in 0..4 {
let w = widths.get(i);
if w < 1.0 || is_neutral_color(colors.get(i)) {
continue;
}
let mut max_other = f64::NEG_INFINITY;
for j in 0..4 {
if j != i {
max_other = math_max(max_other, widths.get(j));
}
}
if !(w >= 2.0 && (max_other <= 1.0 || w >= max_other * 2.0)) {
continue;
}
let sn = SIDE_NAMES[i].to_lowercase();
let is_side = i == 1 || i == 3;
let w_s = number_to_string(w);
let r_s = number_to_string(radius);
if is_side {
if span_badge {
continue;
}
if radius > 0.0 {
findings.push(RuleHit::new(
"side-tab",
format!("border-{sn}: {w_s}px + border-radius: {r_s}px"),
));
} else if w >= 3.0 {
findings.push(RuleHit::new("side-tab", format!("border-{sn}: {w_s}px")));
}
} else if radius > 0.0 && w >= 2.0 {
findings.push(RuleHit::new(
"border-accent-on-rounded",
format!("border-{sn}: {w_s}px + border-radius: {r_s}px"),
));
} else if !opts.tab_context && w >= 3.0 && w <= 12.0 {
findings.push(RuleHit::new("side-tab", format!("border-{sn}: {w_s}px")));
}
}
findings
}
re!(GRADIENT_CI, ci("gradient"));
re!(
TW_GRAY_TEXT,
format!(r"{B}text-(?:gray|slate|zinc|neutral|stone)-{D}+{B}")
);
re!(
TW_COLOR_BG,
format!(
r"{B}bg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-{D}+{B}"
)
);
re!(TW_BG_CLIP_TEXT, format!(r"{B}bg-clip-text{B}"));
/// JS: detect-text.mjs#TW_SOLID_CHROMATIC_BG_RE and checks.mjs#checkColors's
/// `colorBgMatch`, whose `\d+(?!\/)\b` skips a `bg-blue-500/10` opacity tint
/// (#707). The `regex` crate has no lookahead: `\d+` is already maximal before
/// the word boundary, so the only thing left to test is the byte after it.
pub fn find_solid_chromatic_bg(s: &str) -> Option<&str> {
let mut from = 0usize;
while let Some(m) = TW_COLOR_BG.find_at(s, from) {
if s.as_bytes().get(m.end()) != Some(&b'/') {
return Some(m.as_str());
}
from = m.start() + 1;
}
None
}
re!(TW_BG_GRADIENT_TO, format!(r"{B}bg-gradient-to-"));
re!(
TW_PURPLE_TEXT,
format!(r"{B}text-(?:purple|violet|indigo)-{D}+{B}")
);
re!(TW_TEXT_XL, format!(r"{B}text-(?:[2-9]xl){B}"));
re!(
TW_FROM_PURPLE,
format!(r"{B}from-(?:purple|violet|indigo)-{D}+{B}")
);
re!(
TW_TO_PURPLE,
format!(r"{B}to-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-{D}+{B}")
);
fn is_heading_123(tag: &str) -> bool {
matches!(tag, "h1" | "h2" | "h3")
}
/// JS: checks.mjs#checkColors
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
let tag = opts.tag.as_str();
let bg_image = opts.bg_image.as_deref().unwrap_or("");
let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
if set_has(SAFE_TAGS, tag) {
let own_bg = opts
.bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
let is_styled_control =
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
if !is_styled_control {
return Vec::new();
}
}
let mut findings = Vec::new();
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
let text_color = opts.text_color.unwrap();
// Gradient-clipped text paints the gradient, not `color`, so there
// is no background to score it against.
if bg_clip != "text" {
findings.extend(contrast_findings(opts, &text_color));
}
if has_chroma(Some(&text_color), Some(50.0)) {
let hue = get_hue(Some(&text_color));
if hue >= 260.0 && hue <= 310.0 && (is_heading_123(tag) || opts.font_size >= 20.0) {
findings.push(RuleHit::new(
"ai-color-palette",
format!(
"Purple/violet text ({}) on heading",
color_to_hex(Some(&text_color))
),
));
}
}
}
if bg_clip == "text" && !bg_image.is_empty() && bg_image.contains("gradient") {
findings.push(RuleHit::new(
"gradient-text",
"background-clip: text + gradient".to_string(),
));
}
if let Some(class_str) = opts.class_list.as_deref().filter(|s| !s.is_empty()) {
let gray_match = TW_GRAY_TEXT.find(class_str);
let color_bg_match = find_solid_chromatic_bg(class_str);
if let (Some(g), Some(c)) = (gray_match, color_bg_match) {
findings.push(RuleHit::new(
"gray-on-color",
format!("{} on {}", g.as_str(), c),
));
}
if TW_BG_CLIP_TEXT.is_match(class_str) && TW_BG_GRADIENT_TO.is_match(class_str) {
findings.push(RuleHit::new(
"gradient-text",
"bg-clip-text + bg-gradient (Tailwind)".to_string(),
));
}
if let Some(p) = TW_PURPLE_TEXT.find(class_str) {
if is_heading_123(tag) || TW_TEXT_XL.is_match(class_str) {
findings.push(RuleHit::new(
"ai-color-palette",
format!("{} on heading", p.as_str()),
));
}
}
if TW_FROM_PURPLE.is_match(class_str) && TW_TO_PURPLE.is_match(class_str) {
findings.push(RuleHit::new(
"ai-color-palette",
"Purple/violet gradient (Tailwind)".to_string(),
));
}
}
findings
}
/// The contrast scoring `check_colors` and `check_placeholder_colors`
/// share: gray-on-color, then WCAG AA against the worst background. The
/// backgrounds are the composited `effective_bg`, or the gradient stops when
/// no opaque surface resolved; with neither there is nothing to score.
fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec<RuleHit> {
let bgs: Vec<Rgba> = if let Some(bg) = opts.effective_bg {
vec![bg]
} else {
match &opts.effective_bg_stops {
Some(stops) if !stops.is_empty() => stops.clone(),
_ => return Vec::new(),
}
};
let mut findings = Vec::new();
let text_lum = relative_luminance(text_color);
let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
"gradient({})",
bgs.iter()
.map(|b| color_to_hex(Some(b)))
.collect::<Vec<_>>()
.join(", ")
),
};
findings.push(RuleHit::new(
"gray-on-color",
format!("text {} on bg {}", color_to_hex(Some(text_color)), bg_label),
));
}
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(text_color, b)).collect();
let mut worst_idx = 0usize;
for i in 1..ratios.len() {
if ratios[i] < ratios[worst_idx] {
worst_idx = i;
}
}
let ratio = ratios[worst_idx];
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio < threshold {
let is_alpha_fallback_fp = !opts.detector_is_browser
&& opts.effective_bg.is_none()
&& text_color.a.map_or(false, |a| a < 1.0);
if !is_alpha_fallback_fp {
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
to_fixed(ratio, 2)
} else {
to_fixed(ratio, 1)
};
findings.push(RuleHit::new(
"low-contrast",
format!(
"{}:1 (need {}:1) — text {} on {}",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(text_color)),
color_to_hex(Some(&bgs[worst_idx]))
),
));
}
}
findings
}
/// Placeholder text contrast, sibling of `check_hover_contrast`. Skips the
/// SAFE_TAGS gate and the host heuristics in `check_colors` (class list,
/// clip, gradient) because the host is an empty control; only the
/// placeholder glyphs are scored. A translucent placeholder is flattened
/// over the composited background first, including each gradient stop when
/// no opaque surface resolved. Snippets carry the placeholder string so
/// fixture tests can key on it.
pub fn check_placeholder_colors(
opts: &ColorOpts,
placeholder_text: &str,
mut text_color: Rgba,
) -> Vec<RuleHit> {
let mut flat: Option<ColorOpts> = None;
if text_color.a.map_or(false, |a| a < 1.0) {
if let Some(bg) = opts.effective_bg {
text_color = composite_color_over(&text_color, &bg);
} else if let Some(stops) = opts.effective_bg_stops.as_ref().filter(|s| !s.is_empty()) {
let mut worst_i = 0usize;
let mut worst_ratio = f64::MAX;
let mut worst_fg = text_color;
for (i, stop) in stops.iter().enumerate() {
let fg = composite_color_over(&text_color, stop);
let r = contrast_ratio(&fg, stop);
if r < worst_ratio {
worst_ratio = r;
worst_i = i;
worst_fg = fg;
}
}
text_color = worst_fg;
let mut o = opts.clone();
o.effective_bg = Some(stops[worst_i]);
o.effective_bg_stops = None;
flat = Some(o);
}
}
let opts = flat.as_ref().unwrap_or(opts);
let mut findings = contrast_findings(opts, &text_color);
for h in &mut findings {
h.snippet = format!("placeholder \"{}\" {}", placeholder_text, h.snippet);
}
findings
}
/// JS: checks.mjs#checkHoverContrast
pub fn check_hover_contrast(opts: &HoverContrastOpts) -> Vec<RuleHit> {
if !opts.has_direct_text || opts.is_emoji_only || opts.text_color.is_none() || opts.bg.is_none()
{
return Vec::new();
}
if set_has(SAFE_TAGS, &opts.tag) && !opts.own_bg_alpha.map_or(false, |a| a > 0.5) {
return Vec::new();
}
let text_color = opts.text_color.unwrap();
let bg = opts.bg.unwrap();
let ratio = contrast_ratio(&text_color, &bg);
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio >= threshold {
return Vec::new();
}
vec![RuleHit::new(
"low-contrast",
format!(
":hover state {}:1 (need {}:1) — text {} on {}",
to_fixed(ratio, 1),
number_to_string(threshold),
color_to_hex(Some(&text_color)),
color_to_hex(Some(&bg))
),
)]
}
// ─── isCardLikeFromProps / HEADING_TAGS ─────────────────────────────────────
/// JS: checks.mjs#isCardLikeFromProps
pub fn is_card_like_from_props(
has_shadow: bool,
has_border: bool,
has_radius: bool,
has_bg: bool,
) -> bool {
if !has_shadow && !has_border {
return false;
}
has_radius || has_bg
}
/// JS: checks.mjs#checkIconTile
pub fn check_icon_tile(opts: &IconTileOpts) -> Vec<RuleHit> {
if !is_heading_tag(&opts.heading_tag) {
return Vec::new();
}
let sibling_tag = match opts.sibling_tag.as_deref() {
None | Some("") => return Vec::new(),
Some(t) => t,
};
if is_heading_tag(sibling_tag) {
return Vec::new();
}
let w = opts.sibling_width;
let h = opts.sibling_height;
if !(w >= 32.0 && w <= 128.0) {
return Vec::new();
}
if !(h >= 32.0 && h <= 128.0) {
return Vec::new();
}
let ratio = w / h;
if ratio < 0.7 || ratio > 1.4 {
return Vec::new();
}
let bg_visible = opts
.sibling_bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.1))
|| opts
.sibling_bg_image
.as_deref()
.map_or(false, |s| !s.is_empty() && s != "none");
let border_visible = opts.sibling_border_width > 0.0;
if !bg_visible && !border_visible {
return Vec::new();
}
if opts.sibling_border_radius >= w / 2.0 {
return Vec::new();
}
if !opts.has_icon_child {
return Vec::new();
}
if num_truthy(opts.icon_child_width) && opts.icon_child_width >= w * 0.95 {
return Vec::new();
}
if num_truthy(opts.heading_top)
&& num_truthy(opts.sibling_bottom)
&& opts.sibling_bottom > opts.heading_top + 4.0
{
return Vec::new();
}
let text = slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
vec![RuleHit::new(
"icon-tile-stack",
format!(
"{}x{}px icon tile above {} \"{}\"",
number_to_string(math_round(w)),
number_to_string(math_round(h)),
opts.heading_tag,
text
),
)]
}
/// JS `f.trim().replace(/^['"]|['"]$/g, '')`.
fn strip_font_quotes(f: &str) -> &str {
let t = js::trim(f);
let is_quote = |b: u8| b == b'\'' || b == b'"';
let bytes = t.as_bytes();
let start = if !bytes.is_empty() && is_quote(bytes[0]) {
1
} else {
0
};
let end = if bytes.len() > start && is_quote(bytes[bytes.len() - 1]) {
bytes.len() - 1
} else {
bytes.len()
};
&t[start..end]
}
/// JS: checks.mjs#resolveSerif
pub fn resolve_serif(font_family: Option<&str>) -> SerifResolution {
let none = SerifResolution {
primary: None,
is_serif: false,
};
let ff = match font_family {
None | Some("") => return none,
Some(s) => s,
};
let tokens: Vec<String> = ff
.split(',')
.map(|f| js::to_lower_case(strip_font_quotes(f)))
.collect();
let primary = tokens
.iter()
.find(|f| !f.is_empty() && !set_has(GENERIC_FONTS, f))
.cloned();
let primary = match primary {
None => return none,
Some(p) => p,
};
if set_has(KNOWN_SERIF_FONTS, &primary) {
return SerifResolution {
primary: Some(primary),
is_serif: true,
};
}
if tokens.iter().any(|t| t == "serif") {
return SerifResolution {
primary: Some(primary),
is_serif: true,
};
}
SerifResolution {
primary: Some(primary),
is_serif: false,
}
}
/// JS: checks.mjs#checkItalicSerif
pub fn check_italic_serif(opts: &ItalicSerifOpts) -> Vec<RuleHit> {
if opts.font_style.as_deref() != Some("italic") {
return Vec::new();
}
let tag = opts.tag.as_str();
if tag != "h1" && !(tag == "h2" && opts.font_size >= 48.0) {
return Vec::new();
}
if opts.font_size < 48.0 {
return Vec::new();
}
let res = resolve_serif(opts.font_family.as_deref());
if !res.is_serif {
return Vec::new();
}
let text = slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
vec![RuleHit::new(
"italic-serif-display",
format!(
"italic serif {} ({}) at {}px \"{}\"",
tag,
res.primary.as_deref().unwrap_or("serif"),
number_to_string(math_round(opts.font_size)),
text
),
)]
}
// ─── isAccentColor ──────────────────────────────────────────────────────────
re!(
ACCENT_RGB_STRICT,
format!(r"rgba?\({WS}*({D}+){WS}*,{WS}*({D}+){WS}*,{WS}*({D}+)")
);
re!(ACCENT_HEX, format!(r"^#([0-9a-fA-F]{{3,8}}){B}"));
re!(ACCENT_OKLCH_HEAD, format!(r"^{}\(", ci("oklch")));
re!(ACCENT_NUMS, format!(r"{D}*\.{D}+|{D}+"));
re!(
ACCENT_HSL,
format!(r"{}[aA]?\({WS}*[0-9.]+{WS}*,{WS}*([0-9.]+)%", ci("hsl"))
);
/// JS: checks.mjs#isAccentColor
pub fn is_accent_color(css_color: &str) -> bool {
if css_color.is_empty() {
return false;
}
let s = js::trim(css_color);
if let Some(m) = ACCENT_RGB_STRICT.captures(s) {
let r = string_to_number(&m[1]);
let g = string_to_number(&m[2]);
let b = string_to_number(&m[3]);
return (js::math_max3(r, g, b) - js::math_min3(r, g, b)) >= 40.0;
}
if let Some(m) = ACCENT_HEX.captures(s) {
let raw = &m[1];
let h: String = if raw.len() == 3 || raw.len() == 4 {
let doubled: String = raw.chars().flat_map(|c| [c, c]).collect();
doubled.chars().take(6).collect()
} else {
raw.chars().take(6).collect()
};
if h.len() == 6 {
let r = parse_int(&h[0..2], 16);
let g = parse_int(&h[2..4], 16);
let b = parse_int(&h[4..6], 16);
return (js::math_max3(r, g, b) - js::math_min3(r, g, b)) >= 40.0;
}
}
if ACCENT_OKLCH_HEAD.is_match(s) {
let nums: Vec<&str> = ACCENT_NUMS.find_iter(s).map(|m| m.as_str()).collect();
if nums.len() >= 2 {
let c = parse_float(nums[1]);
return !c.is_nan() && c >= 0.05;
}
}
if let Some(m) = ACCENT_HSL.captures(s) {
let sat = parse_float(&m[1]);
return !sat.is_nan() && sat >= 20.0;
}
false
}
// ─── resolveHeroHeadingSizePx ───────────────────────────────────────────────
re!(
SIMPLE_LENGTH,
format!(r"^(-?{D}*\.?{D}+){WS}*(px|rem|em|%)?$")
);
re!(CLAMP_RE, format!(r"^clamp\(({DOT}*)\)$"));
fn simple_length_px(token: &str) -> Option<f64> {
let m = SIMPLE_LENGTH.captures(js::trim(token))?;
let amount = string_to_number(&m[1]);
if !amount.is_finite() {
return None;
}
match m.get(2).map(|u| u.as_str()) {
Some("rem") | Some("em") => Some(amount * 16.0),
Some("%") => Some(amount * 0.16),
_ => Some(amount),
}
}
/// JS: checks.mjs#resolveHeroHeadingSizePx
pub fn resolve_hero_heading_size_px(value: Option<&str>) -> f64 {
let input = js::to_lower_case(js::trim(value.unwrap_or("")));
if input.is_empty() {
return 0.0;
}
if let Some(direct) = simple_length_px(&input) {
return direct;
}
if let Some(m) = CLAMP_RE.captures(&input) {
let parts: Vec<&str> = m[1].split(',').collect();
if parts.len() == 3 {
let bounds: Vec<f64> = [simple_length_px(parts[0]), simple_length_px(parts[2])]
.into_iter()
.flatten()
.collect();
if !bounds.is_empty() {
let mut mx = f64::NEG_INFINITY;
for b in bounds {
mx = math_max(mx, b);
}
return mx;
}
}
}
0.0
}
/// JS: checks.mjs#checkHeroEyebrow
pub fn check_hero_eyebrow(opts: &HeroEyebrowOpts) -> Vec<RuleHit> {
if opts.heading_tag != "h1" {
return Vec::new();
}
if opts.heading_in_application_context {
return Vec::new();
}
if !(opts.heading_font_size >= 48.0) {
return Vec::new();
}
let sibling_tag = match opts.sibling_tag.as_deref() {
None | Some("") => return Vec::new(),
Some(t) => t,
};
if is_heading_tag(sibling_tag) {
return Vec::new();
}
let text = js::trim(opts.sibling_text.as_deref().unwrap_or(""));
let text_len = utf16_length(text);
if text_len < 2 || text_len > 60 {
return Vec::new();
}
if !(opts.sibling_font_size > 0.0 && opts.sibling_font_size <= 14.0) {
return Vec::new();
}
let is_uppercased = opts.sibling_text_transform.as_deref() == Some("uppercase")
|| (text.bytes().any(|b| b.is_ascii_uppercase())
&& !text.bytes().any(|b| b.is_ascii_lowercase()));
let is_classic_tracked = is_uppercased && opts.sibling_letter_spacing >= 1.6;
let weight = {
let n = match opts.sibling_font_weight.as_deref() {
None => f64::NAN,
Some(s) => string_to_number(s),
};
if num_truthy(n) {
n
} else {
400.0
}
};
let is_accent_bold =
weight >= 700.0 && is_accent_color(opts.sibling_color.as_deref().unwrap_or(""));
let is_dash_prefixed = opts.sibling_has_accent_dash_pseudo;
if !is_classic_tracked && !is_accent_bold && !is_dash_prefixed {
return Vec::new();
}
let heading_text_snippet =
slice_utf16_start(js::trim(opts.heading_text.as_deref().unwrap_or("")), 60);
let eyebrow_snippet = slice_utf16_start(text, 40);
let style = if is_classic_tracked {
"tracked-caps"
} else if is_accent_bold {
"accent-bold"
} else {
"dash-prefix"
};
vec![RuleHit::new(
"hero-eyebrow-chip",
format!(
"eyebrow chip ({}) \"{}\" above {} \"{}\"",
style, eyebrow_snippet, opts.heading_tag, heading_text_snippet
),
)]
}
/// JS: checks.mjs#checkKickerAboveHeading
pub fn check_kicker_above_heading(candidates: &[KickerCandidate]) -> Vec<RuleHit> {
candidates
.iter()
.map(|c| {
RuleHit::new(
"kicker-above-heading",
format!(
"kicker \"{}\" above {} \"{}\"",
c.kicker_text, c.heading_tag, c.heading_text
),
)
})
.collect()
}
// ─── checkMotion ────────────────────────────────────────────────────────────
/// JS `LAYOUT_TRANSITION_PROPS`.
pub const LAYOUT_TRANSITION_PROPS: &[&str] = &[
"width",
"height",
"padding",
"margin",
"max-height",
"max-width",
"min-height",
"min-width",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
];
re!(
BOUNCE_NAME,
format!(
"{}|{}|{}|{}|{}",
ci("bounce"),
ci("elastic"),
ci("wobble"),
ci("jiggle"),
ci("spring")
)
);
re!(TW_ANIMATE_BOUNCE, format!(r"{B}animate-bounce{B}"));
/// JS `/cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g`.
pub(crate) static BEZIER_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"cubic-bezier\({WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*,{WS}*([0-9.-]+){WS}*\)"
))
.expect("BEZIER_RE")
});
/// JS: checks.mjs#checkMotion
pub fn check_motion(opts: &MotionOpts) -> Vec<RuleHit> {
if set_has(SAFE_TAGS, &opts.tag) {
return Vec::new();
}
let mut findings = Vec::new();
if let Some(name) = opts.animation_name.as_deref() {
if !name.is_empty() && name != "none" && BOUNCE_NAME.is_match(name) {
findings.push(RuleHit::new("bounce-easing", format!("animation: {name}")));
}
}
if let Some(cls) = opts.class_list.as_deref() {
if !cls.is_empty() && TW_ANIMATE_BOUNCE.is_match(cls) {
findings.push(RuleHit::new(
"bounce-easing",
"animate-bounce (Tailwind)".to_string(),
));
}
}
if let Some(tf) = opts.timing_functions.as_deref() {
if !tf.is_empty() {
for m in BEZIER_RE.captures_iter(tf) {
let y1 = parse_float(&m[2]);
let y2 = parse_float(&m[4]);
if y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1 {
findings.push(RuleHit::new(
"bounce-easing",
format!("cubic-bezier({}, {}, {}, {})", &m[1], &m[2], &m[3], &m[4]),
));
break;
}
}
}
}
if let Some(tp) = opts.transition_property.as_deref() {
if !tp.is_empty() && tp != "all" && tp != "none" {
let layout_found: Vec<String> = tp
.split(',')
.map(|p| js::to_lower_case(js::trim(p)))
.filter(|p| set_has(LAYOUT_TRANSITION_PROPS, p))
.collect();
if !layout_found.is_empty() {
findings.push(RuleHit::new(
"layout-transition",
format!("transition: {}", layout_found.join(", ")),
));
}
}
}
findings
}
fn glow_scan(value: Option<&str>, prop: &str, on_dark_bg: bool) -> Option<RuleHit> {
let value = match value {
None | Some("") | Some("none") => return None,
Some(v) => v,
};
for layer in split_commas_outside_parens(value) {
let info = match find_shadow_color(layer) {
Some(i) => i,
None => continue,
};
let color = match info.color {
Some(c) => c,
None => continue,
};
if !has_chroma(Some(&color), Some(30.0)) {
continue;
}
let vals = extract_shadow_lengths(layer, Some((info.start, info.end)));
if vals.len() < 3 || vals[2] <= 4.0 {
continue;
}
if vals[0] == 0.0 && vals[1] == 0.0 {
return Some(RuleHit::new(
"dark-glow",
format!("Zero-offset {} glow ({})", prop, color_to_hex(Some(&color))),
));
}
if on_dark_bg {
return Some(RuleHit::new(
"dark-glow",
format!(
"Colored {} glow ({}) on dark background",
prop,
color_to_hex(Some(&color))
),
));
}
}
None
}
/// JS: checks.mjs#checkGlow
pub fn check_glow(opts: &GlowOpts) -> Vec<RuleHit> {
let on_dark_bg = opts
.effective_bg
.map_or(false, |bg| relative_luminance(&bg) < 0.1);
let found = glow_scan(opts.box_shadow.as_deref(), "box-shadow", on_dark_bg)
.or_else(|| glow_scan(opts.text_shadow.as_deref(), "text-shadow", on_dark_bg));
match found {
Some(f) => vec![f],
None => Vec::new(),
}
}
// ─── Section 6 shared: flat type hierarchy ──────────────────────────────────
/// JS: checks.mjs#TYPE_HIERARCHY_SELECTOR
pub const TYPE_HIERARCHY_SELECTOR: &str = "h1,h2,h3,h4,h5,h6,p,li,td,th,dd,blockquote,figcaption";
/// JS: checks.mjs#TYPE_HIERARCHY_MIN_ROLES
pub const TYPE_HIERARCHY_MIN_ROLES: usize = 3;
/// JS: checks.mjs#TYPE_HIERARCHY_MIN_STEP_RATIO
pub const TYPE_HIERARCHY_MIN_STEP_RATIO: f64 = 1.25;
/// One `{ role, size }` entry the JS pushes into `samples`.
#[derive(Debug, Clone, PartialEq)]
pub struct TypeSample {
pub role: String,
pub size: f64,
}
/// JS: checks.mjs#typeHierarchyRole
pub fn type_hierarchy_role(tag: &str) -> String {
let tag = js::to_lower_case(tag);
let b = tag.as_bytes();
if b.len() == 2 && b[0] == b'h' && (b'1'..=b'6').contains(&b[1]) {
tag
} else {
"body".to_string()
}
}
/// JS: checks.mjs#dominantTypeRoleSize
fn dominant_type_role_size(samples: &[f64]) -> Option<f64> {
// `new Map()` keeps insertion order; the JS sorts by count desc then size asc.
let mut counts: Vec<(f64, f64)> = Vec::new();
for size in samples {
match counts
.iter_mut()
.find(|(k, _)| crate::js_ext_b::same_value_zero(*k, *size))
{
Some(slot) => slot.1 += 1.0,
None => counts.push((*size, 1.0)),
}
}
let mut ranked = counts;
ranked.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
});
if ranked.len() > 1 && ranked[0].1 == ranked[1].1 {
return None;
}
ranked.first().map(|(size, _)| *size)
}
/// JS: checks.mjs#checkFlatTypeHierarchySamples
pub fn check_flat_type_hierarchy_samples(samples: &[TypeSample]) -> Vec<RuleHit> {
// `new Map()` keyed by role, in first-seen order.
let mut by_role: Vec<(String, Vec<f64>)> = Vec::new();
for sample in samples {
let size = math_round(sample.size * 10.0) / 10.0;
if sample.role.is_empty() || !size.is_finite() || size < 8.0 || size >= 200.0 {
continue;
}
match by_role.iter_mut().find(|(r, _)| *r == sample.role) {
Some(slot) => slot.1.push(size),
None => by_role.push((sample.role.clone(), vec![size])),
}
}
let mut roles: Vec<(String, f64)> = by_role
.into_iter()
.filter_map(|(role, sizes)| dominant_type_role_size(&sizes).map(|size| (role, size)))
.collect();
if roles.len() < TYPE_HIERARCHY_MIN_ROLES {
return Vec::new();
}
roles.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
// JS-PARITY: checks.mjs sorts ties with `a.role.localeCompare(b.role)`.
// Every role is `body` or `h1`..`h6`, lowercase ASCII, where the ICU
// root collation and byte order agree.
.then_with(|| a.0.cmp(&b.0))
});
let mut largest_step = 1.0f64;
for i in 1..roles.len() {
largest_step = math_max(largest_step, roles[i].1 / roles[i - 1].1);
}
if largest_step >= TYPE_HIERARCHY_MIN_STEP_RATIO {
return Vec::new();
}
let role_sizes: Vec<String> = roles
.iter()
.map(|(role, size)| format!("{} {}px", role, number_to_string(*size)))
.collect();
vec![RuleHit::new(
"flat-type-hierarchy",
format!(
"Role sizes: {} (largest adjacent step {}:1; target {}:1)",
role_sizes.join(", "),
to_fixed(largest_step, 2),
number_to_string(TYPE_HIERARCHY_MIN_STEP_RATIO)
),
)]
}
#[cfg(test)]
mod tests {
use super::*;
// Expected values below were produced by running the JS functions in
// Node against the same inputs.
#[test]
fn hover_contrast_matches_node() {
let hits = check_hover_contrast(&HoverContrastOpts {
tag: "div".into(),
text_color: Some(Rgba::new(120.0, 120.0, 120.0, 1.0)),
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
own_bg_alpha: Some(1.0),
font_size: 16.0,
font_weight: 400.0,
has_direct_text: true,
is_emoji_only: false,
});
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].snippet,
":hover state 4.4:1 (need 4.5:1) — text #787878 on #ffffff"
);
// SAFE_TAGS suppression without an own background.
let none = check_hover_contrast(&HoverContrastOpts {
tag: "a".into(),
text_color: Some(Rgba::new(120.0, 120.0, 120.0, 1.0)),
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
own_bg_alpha: Some(0.2),
font_size: 16.0,
font_weight: 400.0,
has_direct_text: true,
is_emoji_only: false,
});
assert!(none.is_empty());
// Large bold text uses the 3:1 threshold.
let large = check_hover_contrast(&HoverContrastOpts {
tag: "button".into(),
text_color: Some(Rgba::new(160.0, 160.0, 160.0, 1.0)),
bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
own_bg_alpha: Some(1.0),
font_size: 19.0,
font_weight: 700.0,
has_direct_text: true,
is_emoji_only: false,
});
assert_eq!(
large[0].snippet,
":hover state 2.6:1 (need 3:1) — text #a0a0a0 on #ffffff"
);
}
#[test]
fn hero_heading_size_matches_node() {
assert_eq!(resolve_hero_heading_size_px(Some("3rem")), 48.0);
assert_eq!(resolve_hero_heading_size_px(Some(" 56PX ")), 56.0);
assert_eq!(resolve_hero_heading_size_px(Some("200%")), 32.0);
assert_eq!(
resolve_hero_heading_size_px(Some("clamp(2rem, 5vw, 4.5rem)")),
72.0
);
assert_eq!(
resolve_hero_heading_size_px(Some("clamp(40px, 6vw, 10vw)")),
40.0
);
assert_eq!(resolve_hero_heading_size_px(Some("clamp(5vw, 6vw)")), 0.0);
assert_eq!(resolve_hero_heading_size_px(Some("5vw")), 0.0);
assert_eq!(resolve_hero_heading_size_px(None), 0.0);
assert_eq!(resolve_hero_heading_size_px(Some("")), 0.0);
assert_eq!(resolve_hero_heading_size_px(Some(".5em")), 8.0);
}
#[test]
fn shadow_helpers_match_node() {
let c = find_shadow_color("0 0 20px rgba(59,130,246,0.4)").unwrap();
assert_eq!((c.start, c.end), (9, 29));
assert_eq!(c.color, Some(Rgba::new(59.0, 130.0, 246.0, 0.4)));
let hex = find_shadow_color("0 0 20px #3b82f6").unwrap();
assert_eq!((hex.start, hex.end), (9, 16));
let named = find_shadow_color("inset 0 0 4px Red").unwrap();
assert_eq!((named.start, named.end), (14, 17));
assert_eq!(named.color, Some(Rgba::new(255.0, 0.0, 0.0, 1.0)));
assert!(find_shadow_color("0 0 4px var(--x)").is_none());
let p3 = find_shadow_color("0 0 4px color(display-p3 1 0 0)").unwrap();
assert_eq!((p3.start, p3.end), (8, 31));
assert_eq!(p3.color, Some(Rgba::new(255.0, 0.0, 0.0, 1.0)));
assert_eq!(
extract_shadow_lengths("0 0 20px rgba(59,130,246,0.4)", Some((9, 29))),
vec![0.0, 0.0, 20.0]
);
assert_eq!(
extract_shadow_lengths("0 1rem .5em 2px", None),
vec![0.0, 16.0, 8.0, 2.0]
);
assert_eq!(
extract_shadow_lengths("rgb(1, 2, 3) 0px 0px 20px", None),
vec![1.0, 2.0, 3.0, 0.0, 0.0, 20.0]
);
}
#[test]
fn gray_on_color_opacity_tint() {
let hits = |class_list: &str| {
check_colors(&ColorOpts {
tag: "div".to_string(),
font_size: 14.0,
font_weight: 400.0,
has_direct_text: true,
class_list: Some(class_list.to_string()),
..Default::default()
})
.into_iter()
.filter(|h| h.id == "gray-on-color")
.map(|h| h.snippet)
.collect::<Vec<_>>()
};
// #707: a `/10` opacity tint is not a solid chromatic fill.
assert!(hits("text-slate-300 hover:bg-red-500/10").is_empty());
assert!(hits("text-slate-300 bg-red-500/10").is_empty());
assert_eq!(hits("text-slate-300 bg-red-500"), vec!["text-slate-300 on bg-red-500"]);
// A later solid class still pairs when an earlier one is a tint.
assert_eq!(
hits("text-slate-300 bg-red-500/10 bg-teal-600"),
vec!["text-slate-300 on bg-teal-600"]
);
}
#[test]
fn placeholder_colors_ignore_host_class_heuristics() {
let opts = ColorOpts {
tag: "input".to_string(),
effective_bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
font_size: 24.0,
font_weight: 400.0,
class_list: Some("text-slate-300 bg-red-500".to_string()),
bg_clip: Some("text".to_string()),
bg_image: Some("linear-gradient(red, blue)".to_string()),
..Default::default()
};
let ink = check_placeholder_colors(&opts, "Name", Rgba::new(26.0, 26.0, 26.0, 1.0));
assert!(ink.is_empty(), "{ink:?}");
let pale = check_placeholder_colors(&opts, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert_eq!(pale.len(), 1);
assert_eq!(pale[0].id, "low-contrast");
assert!(pale[0].snippet.contains("placeholder \"Name\""), "{pale:?}");
// No resolved surface and no gradient stops: nothing to score.
let unresolved = ColorOpts {
effective_bg: None,
effective_bg_stops: None,
..opts.clone()
};
let none = check_placeholder_colors(&unresolved, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert!(none.is_empty(), "{none:?}");
// Translucent black over a light gradient: flatten per stop, then score.
let gradient = ColorOpts {
effective_bg: None,
effective_bg_stops: Some(vec![
Rgba::new(255.0, 255.0, 255.0, 1.0),
Rgba::new(240.0, 240.0, 240.0, 1.0),
]),
..opts.clone()
};
let wash = check_placeholder_colors(
&gradient,
"Name",
Rgba::new(0.0, 0.0, 0.0, 0.2),
);
assert_eq!(wash.len(), 1, "{wash:?}");
assert_eq!(wash[0].id, "low-contrast");
}
#[test]
fn heading_tags_and_card_like() {
assert!(is_heading_tag("h4"));
assert!(!is_heading_tag("div"));
assert!(is_card_like_from_props(true, false, false, true));
assert!(!is_card_like_from_props(false, false, true, true));
}
}