mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +03:00
* 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>
This commit is contained in:
@@ -20,8 +20,9 @@ use crate::checks::measures::{
|
||||
};
|
||||
use crate::checks::rules::{
|
||||
check_borders, check_colors, check_glow, check_hero_eyebrow, check_icon_tile,
|
||||
check_italic_serif, check_motion, is_emoji_only_text, BorderOpts, ColorOpts, GlowOpts,
|
||||
HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit, Sides, HEADING_TAGS,
|
||||
check_italic_serif, check_motion, check_placeholder_colors, is_emoji_only_text, BorderOpts,
|
||||
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
|
||||
Sides, HEADING_TAGS,
|
||||
};
|
||||
use crate::checks::text_rules::{
|
||||
CURSOR_FIRST_VIEWPORT_PX, CURSOR_GLYPH_RE, POSITIONED_CHILD_INTERACTIVE_SELECTOR,
|
||||
@@ -459,8 +460,8 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
} else {
|
||||
resolve_gradient_stops(dom, el)
|
||||
};
|
||||
check_colors(&ColorOpts {
|
||||
tag,
|
||||
let color_opts = ColorOpts {
|
||||
tag: tag.clone(),
|
||||
text_color: parse_rgb_or_any(&dom.style(el, "color")),
|
||||
bg_color: own_bg,
|
||||
effective_bg: if surface_unresolved {
|
||||
@@ -477,7 +478,36 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
bg_image: Some(dom.style(el, "backgroundImage")),
|
||||
class_list: Some(class_attr(dom, el)),
|
||||
detector_is_browser: true,
|
||||
})
|
||||
};
|
||||
let mut findings = check_colors(&color_opts);
|
||||
if tag == "input" || tag == "textarea" {
|
||||
let placeholder = dom.attr(el, "placeholder").unwrap_or_default();
|
||||
let placeholder = js::trim(&placeholder);
|
||||
if !placeholder.is_empty() {
|
||||
let skip = if tag == "input" {
|
||||
let t = js::to_lower_case(&dom.attr(el, "type").unwrap_or_else(|| "text".into()));
|
||||
matches!(
|
||||
t.as_str(),
|
||||
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
|
||||
| "reset" | "range" | "color"
|
||||
)
|
||||
} else {
|
||||
false
|
||||
} || !matches_or_false(dom, el, ":placeholder-shown");
|
||||
if !skip {
|
||||
if let Some(ph_raw) = dom.pseudo_style(el, "::placeholder", "color") {
|
||||
if let Some(ph_color) = parse_rgb_or_any(&ph_raw) {
|
||||
findings.extend(check_placeholder_colors(
|
||||
&color_opts,
|
||||
placeholder,
|
||||
ph_color,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
// ── icon tile / italic serif / hero eyebrow ───────────────────────────────
|
||||
@@ -1378,6 +1408,61 @@ mod tests {
|
||||
assert!(check_element_pseudo_stripe_dom(&d, card).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_low_contrast_flags() {
|
||||
let (mut d, body) = page();
|
||||
let input = d.add(Some(body), "input");
|
||||
visible(&mut d, input);
|
||||
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
|
||||
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
|
||||
d.set_styles(
|
||||
input,
|
||||
&[
|
||||
("backgroundColor", "rgb(255, 255, 255)"),
|
||||
("color", "rgb(0, 0, 0)"),
|
||||
("fontSize", "16px"),
|
||||
("fontWeight", "400"),
|
||||
("webkitBackgroundClip", "border-box"),
|
||||
],
|
||||
);
|
||||
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
|
||||
d.add_selector(input, ":placeholder-shown");
|
||||
let hits = check_element_colors_dom(&d, input);
|
||||
assert!(
|
||||
hits.iter().any(|h| {
|
||||
h.id == "low-contrast"
|
||||
&& h.snippet.contains("placeholder \"Pale Placeholder On White Field\"")
|
||||
}),
|
||||
"{hits:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_skips_when_not_shown() {
|
||||
let (mut d, body) = page();
|
||||
let input = d.add(Some(body), "input");
|
||||
visible(&mut d, input);
|
||||
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
|
||||
d.set_attr(input, "value", "");
|
||||
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
|
||||
d.set_styles(
|
||||
input,
|
||||
&[
|
||||
("backgroundColor", "rgb(255, 255, 255)"),
|
||||
("color", "rgb(0, 0, 0)"),
|
||||
("fontSize", "16px"),
|
||||
("fontWeight", "400"),
|
||||
("webkitBackgroundClip", "border-box"),
|
||||
],
|
||||
);
|
||||
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
|
||||
let hits = check_element_colors_dom(&d, input);
|
||||
assert!(
|
||||
hits.iter().all(|h| h.id != "low-contrast"),
|
||||
"live filled field must not score a hidden placeholder, {hits:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_low_contrast_on_resolved_surface_and_pseudo_surface() {
|
||||
let (mut d, body) = page();
|
||||
|
||||
+163
-70
@@ -4,7 +4,8 @@
|
||||
//! `undefined` / `null` distinctions the source relies on.
|
||||
|
||||
use crate::color::{
|
||||
color_to_hex, contrast_ratio, get_hue, has_chroma, is_neutral_color, relative_luminance, Rgba,
|
||||
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,
|
||||
@@ -155,75 +156,10 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
||||
|
||||
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
|
||||
let text_color = opts.text_color.unwrap();
|
||||
let is_gradient_clipped_text = bg_clip == "text";
|
||||
let bgs: Option<Vec<Rgba>> = if is_gradient_clipped_text {
|
||||
None
|
||||
} else if let Some(bg) = opts.effective_bg {
|
||||
Some(vec![bg])
|
||||
} else {
|
||||
match &opts.effective_bg_stops {
|
||||
Some(stops) if !stops.is_empty() => Some(stops.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(bgs) = bgs {
|
||||
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]))
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// 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)) {
|
||||
@@ -281,6 +217,119 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
||||
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()
|
||||
@@ -1044,6 +1093,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
|
||||
Reference in New Issue
Block a user