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>
This commit is contained in:
Abdul Wahab
2026-09-10 13:49:59 -07:00
committed by GitHub
co-authored by Cursor
parent 3e1f67c52c
commit cb56ed6c19
17 changed files with 688 additions and 137 deletions
+90 -5
View File
@@ -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
View File
@@ -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"));